Skip to content

provision_worktree's exclude patterns are repo-wide and permanent: new files under .claude/skills silently stop being staged in the main checkout #384

Description

@tjoignant

provision_worktree shields a transient worktree by writing repo-wide, permanent exclude patterns. The patterns name directories that projects legitimately track (.claude/skills, .claude/settings.json), and nothing ever removes them — so long after the run, every new file under those paths is invisible to git add -A in the main checkout.

Observed on bmad-loop 0.9.0, git 2.30.0, macOS.

What happens

worktree_flow.py:286-293 builds the shield set from the CLI profiles:

patterns = {f"/{p.skill_tree}" for p in profiles}                              # -> /.claude/skills
patterns |= {f"/{p.hooks.config_path}" for p in profiles if not p.hookless}    # -> /.claude/settings.json
patterns |= {f"/{rel}" for rel in seeded}
reason = _worktree_local_exclude(worktree, sorted(patterns))

_worktree_local_exclude (install.py:734) resolves its target with git rev-parse --git-common-dir (install.py:787-793). From a linked worktree that answers with the main repo's .git, so the patterns are appended to the repository-wide .git/info/exclude.

The defaults in adapters/profile.py:50,62 are .claude/settings.json and .claude/skills, so a stock claude profile writes exactly:

/.claude/settings.json
/.claude/skills

Two properties combine badly:

  1. Scope — the file is shared by the main checkout and every worktree, so a shield meant for one unit applies to the user's own working tree.
  2. Lifetime_worktree_local_exclude is the only writer and has no counterpart remover; gc_run_worktrees reclaims the worktree and leaves the lines behind. isolation back to "none" doesn't matter; the lines outlive the feature that wrote them.

The docstring argues the write is safe because it "does not affect already-tracked files." True, and that is precisely what makes it hard to notice: .claude/skills/ is full of tracked files that keep diffing normally, while every newly created sibling silently disappears from git status and git add -A.

Impact in my repo

/.claude/skills sat in .git/info/exclude across two BMAD-METHOD installer upgrades. Both upgrade commits captured only modifications to files that were already tracked; 51 new files were never staged, including three whole skills (bmad-review, bmad-editorial-review, bmad-deep-recon).

The merged result was an internally inconsistent repo: _bmad/_config/bmad-help.csv and files-manifest.csv referenced bmad-review, and the committed deprecation stubs (bmad-editorial-review-prose, bmad-review-adversarial-general, …) forwarded to it — but a fresh clone had no such skill. Nothing in the diff of either PR could reveal why, since .git/info/exclude is unversioned and unreviewable by construction.

.bmad-loop/runs/*/state.json records "isolation": "worktree" on 17 runs here, so this is the ordinary path, not an exotic configuration.

Two things I verified before filing

A per-worktree info/exclude does not exist. Writing /marker.txt into .git/worktrees/<id>/info/exclude has no effect — git only reads $GIT_COMMON_DIR/info/exclude:

$ git -C ../wt rev-parse --git-dir
.../main-repo/.git/worktrees/wt
$ printf '/marker.txt\n' > .../main-repo/.git/worktrees/wt/info/exclude
$ touch ../wt/marker.txt && git -C ../wt status --short
?? marker.txt

So the choice of the common dir is not the bug — it is the only exclude file git honors. Any fix that just redirects the write to the worktree's private git dir silently does nothing.

core.excludesFile scoped per-worktree does work. With extensions.worktreeConfig:

$ git config extensions.worktreeConfig true
$ git -C ../wt config --worktree core.excludesFile "$PWD/.git/worktrees/wt/info/exclude"
$ git -C ../wt status --short          # worktree: shielded
$ touch marker.txt && git status --short
?? marker.txt                          # main checkout: unaffected

Suggested fixes, roughly in order of preference

  1. Scope the shield to the worktree via git config --worktree core.excludesFile <path> pointing at a private file, gated on extensions.worktreeConfig. Correct scope and correct lifetime (the config dies with the worktree). Caveats worth weighing: extensions.worktreeConfig is repo-wide state, it moves an existing core.excludesFile into the main worktree's config on first enable, and it would clobber a user's own per-worktree core.excludesFile — so it needs the same read-modify-write care the current helper already has.

  2. If the shared file stays, make the block reversible. Fence it with markers and delete it on reclaim, e.g.

    # bmad-loop <run-id> — removed on reclaim
    /.claude/skills
    # end bmad-loop
    

    Claude Code writes its own runtime state into the same file behind a # claude-code-runtime marker, so the convention is already established there. This also gives Exclude helper's read-modify-write is unserialized: concurrent worktree provisioning silently loses patterns #381's interleaving a smaller blast radius.

  3. Cheap mitigation, independent of the above: skip any pattern whose path already contains tracked files (git ls-files --error-unmatch <path>). For a tracked path the exclude cannot shield the files it names anyway — those are tracked, so git add -A stages them regardless — and its only real effect is hiding their untracked siblings. Dropping those patterns costs nothing and removes the surprising case entirely.

Happy to send a PR for whichever direction you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions