Skip to content

Lesson Gitignore Anchor Propagation

Priscila Saboia Moreira edited this page Jul 1, 2026 · 2 revisions

type: synthesis up: "Home_llm-wiki-memory-template" criticizes: "Sync-Flow" related: "Lesson-Parallel-File-Drift" tags: [lesson, footgun, sync, gitignore]

Lesson: Gitignore Anchor Propagation

A .gitignore entry's leading-slash anchor resolves to the repo root of whichever clone the file currently lives in. Shipping such an entry through the template-sync path moves the anchor with the file and changes what it matches. The first instance was reported on 2026-06-18 by a contributor syncing AI-Sci-Disc-Mem up to template HEAD 80e2ee6 and fixed in PR #39.

The incident

The template's tracked .gitignore ended with a block of dev-self-only entries:

# Dev-mode self-dogfooding artifacts (template contributors only).
# ... never propagate to derived projects ...
/CLAUDE.md
/wiki/llm-wiki-memory-template.wiki/
/.claude/settings.json
/.claude/hooks/

The block's own comment claimed it should "never propagate to derived projects." But .gitignore is in Sync-Flow's ALWAYS_FILES, so scripts/update-from-template.sh copied the file verbatim into every derived project. In a derived repo, /CLAUDE.md then resolved to that repo's own CLAUDE.md, /.claude/settings.json to its own settings, and so on. The block's intended scope (the template clone) and its actual scope (the synced file's current clone) were not the same.

Why it was mostly latent

.gitignore does not affect files that are already tracked. Derived projects already commit CLAUDE.md and .claude/settings.json, so most projects saw no immediate breakage. The footgun bit in three concrete cases:

  1. Projects that legitimately track .claude/hooks/ (e.g., a shared SessionStart hook the team wants checked in).
  2. Any matching path that happens to be untracked at the time — a freshly regenerated CLAUDE.md, a not-yet-committed settings.json.
  3. Contributor confusiongit check-ignore CLAUDE.md reporting "ignored" with no obvious cause. The block was also silently re-introduced on every update-from-template.sh run, so manual trimming did not stick.

Root cause

The class of bug is "scope-sensitive content shipped through a scope-preserving channel." ALWAYS_FILES syncs .gitignore byte-for-byte; the .gitignore parser anchors leading slashes against whatever repo root the file currently lives in. The two assumptions silently disagreed.

This is a cousin of Lesson-Parallel-File-Drift. Drift is about the same content needing to exist in two files and getting out of sync. This is about the same file needing to behave differently in two clones and being unable to. The shared shape is: an asset whose correct content depends on which scope it is read in, sharing one tracked representation.

The fix (PR #39)

Move the dev-self entries out of the tracked .gitignore and into .git/info/exclude. exclude is per-clone, lives outside the working tree, and is never propagated by any sync mechanism — exactly the scope dev-self artifacts need. scripts/instantiate.sh --dev-self writes a marked block to .git/info/exclude idempotently (BEGIN/END markers; re-runs replace the block atomically). The tracked .gitignore keeps only entries that are correct in every clone.

Derived repos that had already received the bad block auto-healed on the next update-from-template.sh run at the time: the new template .gitignore no longer contained the block, the hash differed, the script overwrote the local copy.

Status update (2026-07-01): the sync channel itself was closed

The manifest consolidation (PR #60) moved .gitignore out of the always-synced list entirely: it is now TEMPLATE_HOST_OWNED (append-only) in scripts/lib/template-manifest.sh, and update-from-template.sh never writes it — verified by running the script against a host with a divergent .gitignore (file byte-identical after an apply run; the post-sync report prints an advisory instead). Two consequences for this lesson:

  • The propagation channel that caused the incident no longer exists; the leading-slash question below now applies to adopt's append-only grant payload and to other path-interpreted synced files, not to .gitignore sync.
  • The auto-heal described above no longer operates. A derived repo that still carries a bad block keeps it until someone acts on the divergence advisory manually.

Pattern recognition for future PRs

Before shipping a new .gitignore rule through adopt's append-only grant payload (lw_manifest_known_grant_payload in the manifest), ask: does the path I am ignoring exist in every clone of the template (template self-instance and every derived project)? If yes, the leading slash is fine — the anchor matches a real always-template-only path in every clone. If no — if the path is only meaningful in the template's own clone, or only in a derived clone — the entry does not belong in the tracked file. Move it to .git/info/exclude for the template clone, or to a derived-project-specific mechanism (instantiate.sh substitution, an overlay setup script, the per-project .git/info/exclude) for the derived side.

Same question generalizes beyond .gitignore: any file in the manifest's sync arrays whose content is interpreted relative to a path (.git/info/*, .editorconfig roots, .gitattributes, CI workflow paths) carries the same risk.

See also

  • Sync-Flow: the sync contract whose then-current ALWAYS_FILES list turned a template-local block into a cross-project leak; .gitignore is host-owned there since PR #60
  • Lesson-Parallel-File-Drift: sister footgun — same content in two files, vs same file in two scopes
  • Template-vs-Derived-Projects: the two scopes the assumption disagreed across
  • Lesson-Template-Contribution-Discipline: contribution-layer rationalizations the contributor's bug report sidestepped (specifically, "trust the comment in the block, not the actual sync behavior")

Clone this wiki locally