Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0802fb8
fix(install): scope the worktree git-add shield per-worktree (#384)
pbean Jul 29, 2026
84a00fd
Merge remote-tracking branch 'origin/main' into fix/384-worktree-scop…
pbean Jul 29, 2026
0c21f3f
test(install): scope the xdg probe's env pin to the probe
pbean Jul 29, 2026
d56e79a
docs(install): correct _shield_git's chokepoint rationale
pbean Jul 29, 2026
3ff7097
fix(install): gate the worktree shield on git 2.20 (#384)
pbean Jul 29, 2026
c6d5960
fix(install): read extensions.worktreeConfig from the shared config (…
pbean Jul 29, 2026
ab0cef9
fix(install): resolve a relative inherited excludesFile like git does…
pbean Jul 29, 2026
6d2936f
refactor(install): route the shield's git through the chokepoint (#389)
pbean Jul 29, 2026
cf8648d
docs: record the shield's git 2.20 floor and chokepoint move (#384, #…
pbean Jul 29, 2026
cc9e338
fix(install): re-seed an exclude left empty by a failed write (#384)
pbean Jul 29, 2026
9fd0f73
fix(install): read the shield's git dirs one answer at a time (#384)
Jul 30, 2026
97288e1
docs(install): record why the shield's probes skip --includes (#384)
Jul 30, 2026
1f4773b
fix(install): copy inherited excludes as bytes (#384)
Jul 30, 2026
d0e50ad
fix(install): degrade the shield when git hides the excludes (#384)
Jul 30, 2026
11b0dc5
docs(install): tighten the -z rationale comment (#384)
Jul 30, 2026
d4a1cc9
fix(install): roll back worktreeConfig when activation fails (#384)
Jul 30, 2026
8494983
fix(install): funnel both activation failure shapes into the rollback…
Jul 30, 2026
0cea46e
fix(install): close two shield defects from Codex round 8 (#384)
Jul 30, 2026
ac13182
docs(install): state what the ownership guard does not cover (#384)
Jul 30, 2026
33c6b97
test(install): fix a Windows-only fault in the round-8 rollback fixtu…
Jul 30, 2026
d99227b
fix(install): close three shield defects from CodeRabbit round 9 (#384)
Jul 30, 2026
d31c2b1
fix(install): close two shield defects from Codex round 10 (#384)
Jul 30, 2026
5b57e27
docs(install): scope the flag-payment claim to degrades above it (#384)
Jul 30, 2026
18c95df
fix(install): report a common-dir probe fault instead of skipping (#384)
Jul 30, 2026
2275e19
fix(install): resolve a relative XDG_CONFIG_HOME like git does (#384)
Jul 30, 2026
136be7d
docs(changelog): note the XDG fallback resolves relatively too (#384)
Jul 30, 2026
f2050bd
fix(platform): lock a peer's lock file without write access (#384)
Jul 30, 2026
9d283fd
fix(platform): derive the lock's mode from the directory, not the uma…
Jul 30, 2026
c272826
fix(install): strip only rev-parse's terminator, not path bytes (#384)
Jul 30, 2026
e148e4d
fix(install): refuse a shared repository instead of shielding it (#384)
Jul 30, 2026
ba9f11c
fix(platform): drop the cross-user lock machinery, state 0o600 (#384)
Jul 30, 2026
f5982dc
docs(changelog): note a shared repository is refused, not shielded (#…
Jul 30, 2026
f7f8bce
fix(install): re-append a shield pattern an inherited negation cancel…
Jul 30, 2026
b5f0834
fix(install): verify the shield actually applies, not just that it wr…
Jul 30, 2026
2bbefed
test(install): compare against the repr the reason renders (#384)
Jul 30, 2026
c4ba8e9
fix(install): ask git where its home is, not Python (#384)
Jul 30, 2026
6be43e0
fix(install): shield a repo whose sharedRepository is owner-only (#384)
Jul 30, 2026
8e2ed54
docs(install): name the barrier that makes worktree scope unreadable …
Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 92 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,94 @@ story <id>`, the same annotation a sweep bundle writes. Both sprint and stories

### Fixed

- **The worktree git-add shield no longer hides new files in your own checkout (#384).** Worktree
provisioning shielded the tool files it writes (`.claude/skills`, the per-CLI hook config, seeded
configs) by appending to `.git/info/exclude` — a file shared with the main checkout and every
sibling worktree, permanent, and unversioned. Those paths are ones projects legitimately track, so
every **new** file under them silently stopped being staged by `git add -A` in the operator's own
checkout, long after the run: one report lost 51 files and three whole skills across two upgrade
commits, with nothing in either diff able to reveal why. The shield now writes a private exclude in
the worktree's own gitdir, activated with a worktree-scoped `core.excludesFile`, so it applies to
that unit alone and `git worktree remove` deletes it along with the worktree. The operator's
`core.excludesFile` is copied in **byte for byte** when the private file is created, since the
worktree-scoped key shadows it and git never concatenates across config scopes — so the copy has
to survive an exclude file in any legacy encoding at all (its patterns are paths, and POSIX paths
are arbitrary bytes). Relative values — of that key, or of an `XDG_CONFIG_HOME`
git falls back through — are resolved against the worktree, the way git does, rather
than against wherever the orchestrator was launched. Paths are read NUL-terminated, so an
excludes file whose path begins or ends in whitespace is copied rather than mangled. An excludes
file that **exists but cannot be read** — or a **git that will not say which file applies** (a
timeout, a failed spawn, or any answer to that query other than a definite "there is no such
key") — skips the shield with a journaled reason instead:
activating over patterns that could not be copied would shadow them exactly as an empty copy did,
and not knowing whether there is anything to copy has the same standing as failing to read it.
Only a definite **absent** answer stays a silent no-op — there is nothing to shadow. The skip is
also **notified**, not just journaled, since skipping is only defensible if you find out. The same
rule governs the pair of `rev-parse` probes that identify the repository: only a failure of the
first is the expected silent skip (a plain directory, no git at all); once git has answered it, a
fault on the second is journaled rather than swallowed. An
**explicitly empty** `core.excludesFile` is an answer too, and not the same one as unset: git
honors it as "no excludes file at all" and does **not** fall back to `$XDG_CONFIG_HOME/git/ignore`,
so patterns you switched off are no longer copied into the private file and switched back on inside
the worktree — the mirror image of the same file-loss bug, over-ignoring instead of under-ignoring.
Concurrent runs against one repository are **serialized** on a lock in `.git/`, and a failed
activation never rolls the repo-format flag back while another worktree's `config.worktree` still
depends on it: without either, one run's failed shield silently switched off a sibling run's
working one mid-story. A repository configured as **shared between OS users**
(`core.sharedRepository`) is refused with a journaled reason rather than shielded — but only
where the value really grants peer access: `umask`, a false boolean, and an octal filemode with
no group or other bits (`0600`, `0700`, `0711`) leave the repository private to you, and it is
shielded normally.
Three caveats: this enables `extensions.worktreeConfig`, a **permanent**
repo-format flag that is never removed — written at the last possible moment, so a run that
degrades away **above** it leaves your repo's format untouched, and wherever it could be left set
without a working shield (the enable failing, the activation failing, or the activation succeeding
without taking effect) the flag is rolled back. It outlives a failed shield only where the rollback was declined because a sibling
worktree depends on the flag, or could not be made at all — and both are named in the reason — the
lock leaves a
zero-length `.git/bmad-loop-shield.lock` behind (inside `.git`, so never in your working tree and
never stageable) — and where git requires that flag's
prerequisites be moved by hand first (`core.bare = true` or `core.worktree` in the shared config)
the shield is skipped with a journaled reason rather than widened back. It also needs **git 2.20**,
the release that introduced the flag and `git config --worktree`; below that the shield is skipped
and no repo-format change is made, since git that old refuses a repository carrying the flag. Lines
an older bmad-loop already wrote into `.git/info/exclude` are **not** removed for you — delete them
by hand (a `validate` warning lands separately).
- **The git-add shield's patterns survive an inherited negation (#384).** A pattern the seeded
excludes already carried was treated as done — but gitignore's rule is last match wins, so a
`!` line below it cancelled the shield's own pattern and the provisioned tool files stayed
stageable, with nothing reported because nothing had failed. A pattern is now re-appended
unless it already sits after the last negation in the file.
- **The git-add shield checks that it actually applies (#384).** Writing the worktree-scoped
`core.excludesFile` proved only that the value was stored, not that git reads it: config supplied
through the environment (`GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS`) outranks the worktree scope,
so an operator carrying an ambient `core.excludesFile` got a shield that reported success and never
applied — the provisioned tool files stayed stageable, with nothing reported because nothing had
failed. The shield now asks git which excludes file it resolves and skips with a journaled reason
when that is not the one it just wrote.
- **The git-add shield finds the same global ignore file git does, on Windows too (#384).** With
`core.excludesFile` and `XDG_CONFIG_HOME` both unset, git falls back to `$HOME/.config/git/ignore`
— and it locates that with `HOME` on every platform, while Python's `Path.home()` reads
`USERPROFILE` on Windows and never consults `HOME` at all. Git for Windows derives `HOME` itself,
preferring a `HOMEDRIVE`+`HOMEPATH` home share over `USERPROFILE`, so the two disagreed for anyone
whose shell sets `HOME` (Git Bash and MSYS2 do) or whose home directory is a network drive. The
wrong path is simply not a file, so the copy came back empty and the shield then shadowed the very
global ignores it exists to preserve — silently, since nothing had failed. The shield now asks git
where its home is rather than guessing, and a home git will not resolve skips the shield with a
journaled reason instead of being assumed to mean "there is no ignore file".
- **The git-add shield no longer opens its own safety gates when git cannot answer (#384).** The
three probes that ask whether enabling `extensions.worktreeConfig` is safe read every non-zero
exit code as "that key is not set", so a git that could not answer — rather than one answering
"absent" — let a repository that really sets `core.worktree` cross the gate and take a permanent,
irreversible repo-format change. Only exit code 1 means "no such key" now; anything else skips the
shield with a reason. Separately, the write that enables the flag handled git _refusing_ but not
git _failing to reply_, so a timeout landing after the config was already replaced left the flag
set with no shield ever activated; that path now rolls the flag back too. A rollback of a flag
that was already absent is no longer reported as a rollback that failed.
- **The git-add shield's git calls run on the shared chokepoint (#389).** They were the last bare
`git` spawns outside `verify._run_git`, so they missed its `LC_ALL=C` pin — leaving a degrade
reason that quotes git's stderr in whatever language the box speaks — and used a hardcoded 120s
timeout instead of the configured `[limits] git_timeout_s`. Both now apply.
- **A non-UTF-8 filename no longer crashes the run past every git guard (#377).** `verify._run_git`
is the sole git spawn point and exists to give git's faults a type its ~50 `except GitError`
guards can catch, but it decoded git's output strictly and translated only a timeout and a spawn
Expand Down Expand Up @@ -296,9 +384,10 @@ story <id>`, the same annotation a sweep bundle writes. Both sprint and stories

- **A worktree's local git exclude really is best-effort now (#359).** `_worktree_local_exclude`
guarded only its `git rev-parse` call; the filesystem tail behind it (resolve, mkdir, read, write)
crashed the run — a symlink loop raises `RuntimeError` on 3.11/3.12, an exclude file that is not
UTF-8 raises `UnicodeDecodeError`, a seeded path whose filename is not UTF-8 raises
`UnicodeEncodeError` on the way back out, and a read-only `.git` raises `OSError`. The tail is guarded and
crashed the run — a symlink loop raises `RuntimeError` on 3.11/3.12, a read-only `.git` raises
`OSError`, and the codec faults of the day raised `UnicodeError` (an exclude file that is not UTF-8
on the way in, a seeded path whose filename is not UTF-8 on the way back out — both since removed
as fault modes, because the exclude payload is now read, deduped and written as bytes). The tail is guarded and
degrades to a reason string, and provisioning journals it as `worktree-exclude-degraded` on the
story rather than swallowing it: without the exclude the unit's `git add -A` would commit the
provisioned skill trees and tool configs into the story's merge. Git being unqueryable at all
Expand Down
3 changes: 2 additions & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Off by default (`[scm] isolation = "none"` — work in place on the checked-out branch, byte-for-byte the prior behavior). Set `isolation = "worktree"` and each story (and each sweep bundle) runs in its own `git worktree` on a `bmad-loop/<run_id>[/<story>]` branch cut from the target branch, then merges back **locally** — the main checkout stays free while a run is in flight.
- Merge knobs: `merge_strategy` (`ff` / `merge` / `squash`), `target_branch` (default = branch checked out at run start; created if missing — a detached HEAD or unborn repo pauses the run instead of merging onto an unreferenced commit), `branch_per` (`story` or a shared `run` branch; `run` forces `delete_branch = false`), and `delete_branch`.
- Failed-unit forensics: a deferred/escalated unit's worktree + branch stay mounted (`keep_failed`, default on) and its full diff is preserved to `run_dir/failed/<unit>/changes.patch`; `failed_diff_max_mb` caps per-file untracked-file size (oversized skipped with a marker), `failed_diff_unlimited` lifts the cap.
- Config seeding: a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) would be missing — an isolated session couldn't reach its MCP server. With `seed_adapter_defaults` (default on) each loaded adapter's own `seed_files` are copied in from the main repo before the session launches; `worktree_seed` adds extra paths. Copy-when-absent at file granularity — a directory entry whose destination already exists (a worktree checkout carries its tracked children) still seeds the children that are missing — seeded before the hook-merge (a seeded `settings.json` keeps its content and just gains the Stop hook), and shielded from the unit's `git add -A`.
- Config seeding: a worktree checks out _tracked_ files only, so a project's gitignored MCP/CLI configs (`.mcp.json`, `.claude/settings.json`, `.codex/config.toml`, `.gemini/settings.json`) would be missing — an isolated session couldn't reach its MCP server. With `seed_adapter_defaults` (default on) each loaded adapter's own `seed_files` are copied in from the main repo before the session launches; `worktree_seed` adds extra paths. Copy-when-absent at file granularity — a directory entry whose destination already exists (a worktree checkout carries its tracked children) still seeds the children that are missing — seeded before the hook-merge (a seeded `settings.json` keeps its content and just gains the Stop hook), and shielded from the unit's `git add -A` — in a private exclude scoped to that worktree alone (see below), never repo-wide.
- The git-add shield is scoped to the worktree and expires with it (#384). The provisioned tool files (skill trees, the per-CLI hook config, seeded configs) are excluded through a private `.git/worktrees/<id>/info/exclude`, activated by a worktree-scoped `core.excludesFile` — so the shield applies to that unit only, and `git worktree remove` deletes it along with the worktree. Storing that key is not the same as git reading it: config supplied through the environment (`GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS`, or a `git -c` above us) is **command** scope, which outranks **worktree** scope, so an ambient `core.excludesFile` of your own would leave a shield that reported success and never applied. After activating, bmad-loop asks git which excludes file it actually resolves; if that is not the one just written, the shield is skipped with a journaled reason rather than reported as working. The repository-wide `.git/info/exclude` is never written: it is shared with your own checkout and permanent, so shielding through it made every **new** file under a tracked tool dir (`.claude/skills`, `.claude/settings.json`) silently invisible to your `git add -A`, long after the run. Because that key shadows your own `core.excludesFile` (git reads it from the most specific scope and never concatenates), your excludes file is copied into the private one **byte for byte** when it is created — any encoding, since exclude patterns are paths and POSIX paths are arbitrary bytes — and any path, read NUL-terminated so leading or trailing whitespace in the filename survives. An excludes file that exists but **cannot be read**, or a **git that will not say which file applies** (a timeout, a failed spawn, or any answer to that one query other than a definite "there is no such key"), skips the shield with a journaled and notified reason instead of shadowing patterns it could not copy: not knowing whether there is anything to copy has the same standing as knowing there is and failing to read it. Only a definite **absent** answer is a silent no-op. The same rule governs the two `rev-parse` probes that identify the repository: only a failure of the first is the expected silent skip (you handed it a plain directory, or git is missing) — once git has answered that one, a fault on the second is journaled rather than swallowed. An **explicitly empty** `core.excludesFile` is not an unset one: git reads that as "no excludes file at all" and does **not** fall back to `$XDG_CONFIG_HOME/git/ignore`, so neither does the shield — patterns you deliberately switched off stay off instead of being copied into the private file and re-applied inside the worktree. Two runs against one repository are **serialized**: the probe → enable → activate → rollback sequence is taken under an exclusive lock, and a failed activation never rolls the repo-format flag back while another worktree's `config.worktree` still depends on it — otherwise one run's failure would silently switch off a sibling run's live shield. Three residues are worth knowing about: enabling this needs `extensions.worktreeConfig`, a permanent repo-format flag bmad-loop sets once and never removes — written at the last possible moment, so a degrade **above** it never leaves your repo marked for a shield that did not apply, and wherever it could be left set without a working shield — the enable failing, the activation failing, or the activation succeeding without taking effect — the flag is rolled back. It outlives a failed shield only where that rollback was declined because a sibling worktree depends on the flag, or could not be made at all, and the reason says which — the lock leaves a zero-length `.git/bmad-loop-shield.lock` behind (inside `.git`, so never in your working tree and never stageable), and lines an older bmad-loop already wrote into `.git/info/exclude` are **not** removed for you — delete them by hand. Where the flag cannot be set safely (`core.bare = true` or `core.worktree` in the shared config, which git requires you move first), the shield is skipped with a journaled reason rather than widened back — and a git that cannot **answer** those two questions is treated the same way rather than as a "no", since reading "git failed" as "that key is unset" is what would open the gate. It also needs **git 2.20 or newer** — the release that added both the flag and `git config --worktree`; on anything older (or a git that will not report its version) the shield is skipped the same way, and the repo-format flag is deliberately _not_ written, since git that old refuses a repository carrying it.
- Run state never moves into a worktree — `.bmad-loop/` always lives in the main repo; spec paths are persisted relative to the worktree so a kept-failed run stays portable.
- Merge-back is serialized; `max_parallel` is a validated knob clamped to `1` until parallel fan-out is built. The `repo_root` key in `_bmad/bmm/config.yaml` (defaults to the project dir) decouples where git/code work happens from where run state lives (monorepos).
- `commit_message_template` (`{story_key}` / `{run_id}` substituted) customizes story/bundle commit messages.
Expand Down
Loading