Skip to content

fix(install): guard the worktree exclude's filesystem tail (#359) - #373

Merged
pbean merged 4 commits into
mainfrom
fix/359-worktree-exclude-best-effort
Jul 29, 2026
Merged

fix(install): guard the worktree exclude's filesystem tail (#359)#373
pbean merged 4 commits into
mainfrom
fix/359-worktree-exclude-best-effort

Conversation

@pbean

@pbean pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #359.

install._worktree_local_exclude documented itself as "Best-effort — skipped if git can't be
queried", but the only try covered the git rev-parse --git-common-dir subprocess. Everything
after it was unguarded and propagated out of a function whose contract says it never does:

Line Call Escapes as
751 (worktree / common_dir).resolve() RuntimeError on a symlink loop — 3.11/3.12 only, and the repo floor is 3.11
753 exclude.parent.mkdir(...) OSError (read-only .git)
754 exclude.read_text(...) OSError, and UnicodeDecodeError on an exclude file that is not UTF-8
760 exclude.write_text(...) OSError, and UnicodeEncodeError on a pattern carrying a surrogate

Why the two arms cannot merge

OSError means the opposite thing on each side of the git call. In the subprocess arm it is "no git
to query" — an expected skip that must stay silent, since callers hand this plain non-repo
directories routinely. In the tail it is a filesystem fault worth surfacing. Same type, opposite
meaning, distinguishable only by location. So the tail gets its own guard catching exactly
(OSError, RuntimeError, UnicodeDecodeError) — explicit rather than ValueError-broad, which would
swallow programming errors ("typed escalation over bare except", AGENTS.md). The helper returns
str | None: None for success and for the expected skip, a reason string for a degradation.

Swallowing alone would have been a regression

The exclude is what "shields [the provisioned tool files] even when a project doesn't" gitignore
them. A silently swallowed failure means the unit's git add -A commits .claude/, .mcp.json and
_bmad/custom/ into the story merge — a loud crash traded for silent repo pollution. So the
surfacing lands in the same PR:

  • provision_worktree gains a keyword-only on_degraded: Callable[[str], None] | None = None. The
    list[str] return contract is untouched, so the lazy install.__getattr__ re-export and ~20
    existing callers/tests are unaffected.
  • run_isolated wires it to journal.append("worktree-exclude-degraded", story_key=..., error=msg),
    mirroring the on_teardown_degraded lambda a few lines below.

Journal-only, no gates.notify: the precedent worktree-teardown-degraded (housekeeping failed, run
continues) is journal-only, while worktree-open-failed gets notify because the operator must act
now. Here the run finishes normally and the harm is conditional and greppable.

Correcting the issue's framing

The .resolve() branch is the plain-checkout case, not the linked-worktree case. Verified in a
scratch repo against git 2.55:

git -C <plain checkout>  rev-parse --git-common-dir  ->  .git                (relative)
git -C <linked worktree> rev-parse --git-common-dir  ->  /abs/path/repo/.git (absolute)

A linked worktree gets an absolute common dir and never reaches the is_absolute() fall-through. The
symlink-loop test therefore drives the helper against the main checkout — a linked worktree could not
exercise that line at all.

The issue's caller list is stale. operatoractions._exclude_from_git was deleted by #356, which
made the park record deliberately committed. There is exactly one live caller left —
worktree_flow.provision_worktree at worktree_flow.py:287, reached only from
WorktreeFlow.run_isolated — and this covers it. The engine's park-path containment from #355 is
untouched by this change, as is engine._write_park_record's own except (OSError, RuntimeError),
whose justification #356 already rewrote to "insurance".

Tests

Seven helper/provision tests in tests/test_install.py, one engine-wiring test in
tests/test_engine_worktree.py. Faults are injected via name-filtered monkeypatch (the
test_engine.py:1553 pattern) rather than built for real: 3.13+ resolves a symlink loop without
raising, so a hand-built loop would false-green on the newer legs. The UnicodeDecodeError test uses
real undecodable bytes, since that fault reproduces everywhere, and asserts the legacy bytes survive
untouched.

Every degrade and negative assertion carries its ablation in the docstring, and all six were run:

Ablation Fails
drop RuntimeError from the tail tuple symlink-loop test
drop OSError from the tail tuple write-fault test + on_degraded forwarding test
drop UnicodeDecodeError from the tail tuple undecodable-exclude test
inverse: make the subprocess arm return a reason both expected-skip tests
delete on_degraded(reason) in provision_worktree forwarding test
delete the on_degraded= lambda in run_isolated journal-wiring test

The two expected-skip tests are negative assertions, so they needed the inverse ablation — a gate
deletion cannot fail them. The engine test's worktree_seed setup was checked the same way: without
it, provision_worktree short-circuits before the exclude step and the wiring under test is never
reached.

The five existing exclude happy-path tests are green unmodified.

Follow-up commit: the codec fault has a write direction too

Review triage turned up one real gap in the first commit. The tail's guard named
UnicodeDecodeError, which is only the READ half. write_text raises the sibling
UnicodeEncodeError, and the two share no subclass but UnicodeError — so the best-effort
contract still leaked:

_worktree_local_exclude(repo, ["/vendor/weird-\udcff-name"])
-> UnicodeEncodeError: 'utf-8' codec can't encode character '\udcff': surrogates not allowed

Reachable rather than theoretical: provision_worktree derives one pattern per seed_globs
match through rel.as_posix(), so the pattern text comes from a real filename — a repo file
whose name is not valid UTF-8 arrives surrogate-escaped and cannot be written back as UTF-8.

Widened to UnicodeError: it covers both directions and stays far short of the
ValueError-broad catch the original rationale rejected, so a programming error still escapes.
The new test writes a literal "\udcff" rather than os.fsdecode(b"...\xff...") — fsdecode
decodes with surrogatepass on Windows, which rejects a lone invalid byte and would have raised
in the test's own setup, reddening only the Windows legs.

Ablation Fails
narrow the tuple back to UnicodeDecodeError the new encode test alone
drop the codec member entirely the encode test and the existing decode test

Each half is independently pinned.

Review outcome

Three findings raised, one design objection declined.

  • Applied: the tail's codec guard covered only the read half (UnicodeDecodeError); write_text
    raises UnicodeEncodeError and the two share no subclass but UnicodeError. Reproduced end-to-end
    through seed_globs. Widened, tested, ablated both directions.
  • Applied: a stale ablation note — after the widening, the decode test still told a reader to drop
    a tuple member that no longer existed. Corrected to the two ablations actually run.
  • Declined: a proposal to raise a typed error and pause/escalate the unit instead of journaling.
    It inverts this repo's severity ladder — failing to mount a worktree at all defers rather than
    escalates ("Defer this unit rather than crash the whole run", worktree_flow.py), and the exclude is
    a second line of defense behind the project's own .gitignore. Escalating would also preserve the
    exact bug install: _worktree_local_exclude does unguarded filesystem work outside its only try #359 filed: the run still dies over a best-effort shielding step. Reasoning is in the
    thread.

Two pre-existing hazards in the same helper were verified but deliberately left out of this change,
filed as #374 (the git-query arm decodes stdout strictly, so a repo path with bytes invalid in the
locale encoding still escapes) and #375 (the exclude write is not atomic, so a short write truncates
the operator's shared exclude while the reason string reports that nothing happened). Both want a
decision this PR's scope does not contain — where filesystem paths get decoded, and whether the write
becomes atomic.

Verification

uv run pytest -q -n auto — 3693 passed, 24 skipped. uvx pyright@1.1.411 — 0 errors. trunk fmt +
full trunk check — no issues.

Summary by CodeRabbit

  • Bug Fixes
    • Worktree setup now handles Git exclude-file and filesystem failures gracefully instead of stopping runs.
    • Non-Git directories and unavailable Git metadata are safely skipped.
    • Exclude-file updates preserve existing formatting and avoid duplicate entries.
    • When exclusion is degraded, the run continues and records a journal entry while still allowing successful completion and merge.

`_worktree_local_exclude` documented itself as best-effort but wrapped only
its `git rev-parse --git-common-dir` call. Everything after it — resolve,
mkdir, read_text, write_text — propagated out of a function whose contract
says it never does, crashing the run over housekeeping.

The two arms cannot merge, because OSError means the opposite thing in each:
in the subprocess arm it is "no git to query", an expected skip callers hit
routinely on plain temp dirs; in the tail it is a real filesystem fault worth
surfacing. So the tail gets its own guard catching exactly
(OSError, RuntimeError, UnicodeDecodeError) — RuntimeError because a symlink
loop under `.resolve()` raises that, not OSError, on the 3.11/3.12 legs, and
UnicodeDecodeError (a ValueError subclass) because an exclude file that is not
UTF-8 breaks `read_text`. The helper returns `str | None`: None for success
AND for the expected skip, a reason string for a degradation.

Swallowing alone would be a regression, so the surfacing lands with it:
`provision_worktree` takes a keyword-only `on_degraded` (return contract
unchanged) and `run_isolated` wires it to a `worktree-exclude-degraded`
journal entry, mirroring `on_teardown_degraded`. Journal-only, like the
teardown precedent — the run is unharmed and the operator has nothing to do
right now. Without it a lost exclude turns a loud crash into the unit's
`git add -A` silently committing the provisioned skill trees and tool configs
into the story's merge.

Corrects the issue's framing of the resolve branch: it is the PLAIN-CHECKOUT
case, not the linked-worktree one. Verified against git 2.55 in a scratch
repo — `git -C <plain checkout> rev-parse --git-common-dir` prints a relative
`.git` (so the `is_absolute()` guard falls through to `.resolve()`), while the
same command in a linked worktree prints the main repo's ABSOLUTE `.git` and
never reaches it. The degrade test therefore drives the helper against the
main checkout; a linked worktree could not exercise that line.

Faults in the tests are injected by name-filtered monkeypatch rather than
built for real: 3.13+ resolves a symlink loop without raising, so a hand-built
loop would false-green on this box and on the newer CI legs. The undecodable
-exclude test uses real bytes, since that fault reproduces everywhere. Every
degrade and negative assertion carries its ablation in the docstring; all six
were run (tests 4 and 7 needed the inverse ablation — making the expected-skip
arm return a reason).
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Worktree-local git exclude handling now distinguishes expected git-query skips from filesystem failures, returns degradation reasons, and journals those reasons during isolated runs. Provisioning continues, while tests cover failure, success, callback, and end-to-end journal behavior.

Changes

Worktree exclude resilience

Layer / File(s) Summary
Best-effort exclude helper
src/bmad_loop/install.py, CHANGELOG.md
_worktree_local_exclude resolves the common git directory, updates deduplicated exclude patterns, skips expected git-query failures, and returns reasons for later filesystem or parsing failures.
Provisioning degradation reporting
src/bmad_loop/worktree_flow.py
provision_worktree forwards degradation reasons through on_degraded; isolated runs journal worktree-exclude-degraded with the current story key.
Failure and integration validation
tests/test_install.py, tests/test_engine_worktree.py
Tests cover filesystem failures, non-git skips, successful exclude updates, callback behavior, continued provisioning, and isolated-run journaling.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WorktreeFlow
  participant provision_worktree
  participant _worktree_local_exclude
  participant Journal
  WorktreeFlow->>provision_worktree: provision isolated worktree
  provision_worktree->>_worktree_local_exclude: update local git exclude
  _worktree_local_exclude-->>provision_worktree: return degradation reason
  provision_worktree->>WorktreeFlow: invoke on_degraded(reason)
  WorktreeFlow->>Journal: append worktree-exclude-degraded
  WorktreeFlow-->>WorktreeFlow: continue provisioning and merge
Loading

Possibly related PRs

Suggested reviewers: dracic, polloinfilzato

Poem

I’m a rabbit guarding .git tonight,
Excludes may fail, but runs stay light.
A reason hops into the journal’s line,
While worktrees merge and all is fine.
Thump, thump—degradation’s clear!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR covers the requested guards, preserves expected skips, and adds provisioning-side degradation reporting for #359.
Out of Scope Changes check ✅ Passed The worktree callback and journal wiring are within the issue’s requested caller-side degradation handling, not unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: guarding the worktree exclude filesystem tail.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/359-worktree-exclude-best-effort

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR guards the filesystem tail of _worktree_local_exclude in install.py, which previously only wrapped the git rev-parse subprocess call in a try/except while leaving resolve, mkdir, read, and write operations unguarded — meaning RuntimeError, OSError, and UnicodeDecodeError/UnicodeEncodeError could escape a function documented as best-effort.

  • install._worktree_local_exclude now returns str | NoneNone for both the expected skip (git unqueryable) and success; a reason string for filesystem degradations — with a second try/except (OSError, RuntimeError, UnicodeError) block covering the filesystem tail. UnicodeError is used rather than UnicodeDecodeError to cover the symmetric write_text encode failure that was found during review.
  • provision_worktree gains a keyword-only on_degraded: Callable[[str], None] | None = None parameter, leaving the list[str] return type and all ~20 existing callers unaffected, and forwards any degrade reason to the callback rather than swallowing it.
  • WorktreeFlow.run_isolated wires on_degraded to a journal append of worktree-exclude-degraded, mirroring the existing worktree-teardown-degraded precedent — journal-only because the run still finishes and any harm is conditional and greppable.

Confidence Score: 5/5

Safe to merge — the change converts a function that could crash the run into a properly guarded best-effort helper, with the degrade surfaced to the journal rather than swallowed.

Both arms of _worktree_local_exclude are correctly guarded and independently tested with ablation notes. The UnicodeError catch covers both codec directions precisely. The on_degraded callback is keyword-only with a None default leaving all existing callers unaffected. The engine-wiring test confirms the degrade journal entry lands and the unit still merges.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/bmad_loop/install.py Splits _worktree_local_exclude into two guarded arms; return type widened to str
src/bmad_loop/worktree_flow.py Adds keyword-only on_degraded to provision_worktree (existing callers unaffected); run_isolated wires it to a journal append matching the teardown-degraded precedent
tests/test_install.py Seven new tests covering symlink-loop RuntimeError, write-fault OSError, undecodable exclude UnicodeDecodeError, unencodable pattern UnicodeEncodeError, non-git expected skip, happy-path, and on_degraded forwarding — all with ablation notes
tests/test_engine_worktree.py Engine-wiring test patches _worktree_local_exclude at the worktree_flow binding and verifies a worktree-exclude-degraded journal entry is written while the unit still merges
CHANGELOG.md Accurate changelog entry describing the two-arm guard, the codec fix, and the journal surfacing

Reviews (4): Last reviewed commit: "test(install): correct a stale ablation ..." | Re-trigger Greptile

@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 55 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bmad_loop/install.py`:
- Around line 777-779: Update src/bmad_loop/install.py lines 777-779 to raise
the established typed degradation error for post-query exclude-write failures
instead of returning a string; update src/bmad_loop/worktree_flow.py lines
293-295 to forward that typed failure after optional reporting, and lines
486-488 to journal it before pausing/escalating the unit so no drive or
integration proceeds. Update tests/test_install.py lines 1465-1542 and 1558-1584
to expect the typed failure and verify reporting plus escalation,
tests/test_engine_worktree.py lines 1549-1553 to assert the unit is not merged,
and CHANGELOG.md lines 263-270 to document the escalation behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d730a39e-b08c-438e-9330-03b76cdf5806

📥 Commits

Reviewing files that changed from the base of the PR and between 8b6e9e0 and 8fd002a.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • src/bmad_loop/install.py
  • src/bmad_loop/worktree_flow.py
  • tests/test_engine_worktree.py
  • tests/test_install.py

Comment thread src/bmad_loop/install.py Outdated
@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Verified against the code and rejecting this one — it is a severity-policy disagreement rather than a defect, and the repo's own escalation ladder points the other way.

1. It would invert the repo's severity ordering. Failing to mount a worktree at all is strictly more consequential than failing to write its backup shield, and that case does not escalate: worktree_flow.py:448-459 defers the unit, journals worktree-open-failed, notifies, and the comment states the rule outright — "Defer this unit rather than crash the whole run." Pausing/escalating on a failed info/exclude write would make the backup layer louder than the primary failure it backs up.

2. The exclude is a second line of defense, not the only one. Per provision_worktree's docstring (worktree_flow.py:113-114), the skill trees, hook config and seeded configs "all live in dirs projects gitignore — but the exclude shields them even when a project doesn't." The pollution you describe requires the exclude write to fail and the project to omit its tool dirs from .gitignore. That conditional harm is exactly why this is journaled and greppable rather than run-stopping.

3. Escalation is the opposite of what #359 asked for. The issue is that _worktree_local_exclude advertised "best-effort" while its filesystem tail could take down the run (pre-PR the fault propagated through run_isolated to engine.py:1133-1138, which wraps it in no try). Converting that into a typed pause preserves the bug the issue filed — the run still dies over a shielding step — just with better prose.

4. The doctrine line is already satisfied, and the "repair write" half doesn't reach here. "Typed escalation over bare except" is met: the tail catches an explicit (OSError, RuntimeError, UnicodeDecodeError), deliberately narrow so programming errors still surface. The "repair writes must raise" half is the spec/ledger-read doctrine from #113 (anchor comment at devcontract._read_text_or_empty), which governs writes that repair orchestrator statereset_spec_status, set_frontmatter_status, mark_done. A provisioning shield is not in that category.

5. There is a closer precedent inside the same function. A worktree_seed entry that silently no-ops leaves the worktree without its MCP/CLI config — arguably worse for the session that follows — and it is reported via the skipped return and journaled as worktree-seed-skipped while the run continues (worktree_flow.py:490-498).

The kernel of the concern is real and the PR does act on it: a swallowed fault would trade a loud crash for silent repo pollution, which is why the surfacing (on_degradedworktree-exclude-degraded, carrying story_key + error) had to ship in the same PR rather than later. Journal-only over gates.notify was chosen deliberately against the two precedents above; the alternatives (callback-on-helper, bool return, notify, raise) were considered and rejected before implementation.

Leaving the design as-is.

The tail's codec guard named only `UnicodeDecodeError`, which covers the
READ half — an exclude file that is not UTF-8. The WRITE half raises the
sibling `UnicodeEncodeError`, and the two share no subclass but
`UnicodeError`, so the best-effort contract still leaked:

    _worktree_local_exclude(repo, ["/vendor/weird-\udcff-name"])
    -> UnicodeEncodeError: surrogates not allowed

Reachable, not theoretical. `provision_worktree` derives one pattern per
`seed_globs` match through `rel.as_posix()`, so the pattern text comes
from a real filename; a repo file whose name is not valid UTF-8 arrives
surrogate-escaped and cannot be written back out as UTF-8.

Widened to `UnicodeError`, which covers both directions and stays far
short of the `ValueError`-broad catch the original rationale rejected —
a programming error still escapes.

The new test uses a literal "\udcff" rather than
`os.fsdecode(b"...\xff...")`: fsdecode decodes with surrogatepass on
Windows, which rejects a lone invalid byte and would have raised in the
test's own setup, reddening only the Windows legs.

Ablations run: narrowing the tuple back to `UnicodeDecodeError` fails the
new encode test alone; dropping the codec member entirely fails both the
encode and the existing decode test, so each half is independently
pinned.
The #359 guard covers the filesystem tail in both codec directions, but
the git-query arm above it still decodes stdout strictly, so a repo path
with bytes invalid in the locale encoding raises UnicodeDecodeError —
neither arm's type. Verified with a stub git emitting byte 0xff.

Left out of the fix on purpose: the minimal repair (capture bytes, then
os.fsdecode) makes that path work, but the surrogate-bearing string then
flows into the degrade reason and on into journal.append, whose JSON
write is itself UTF-8. Deciding where filesystem paths get decoded is
program-wide, so it gets its own change; #374 carries it.

Docstring only — no behavior change.
@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Widening the tail's tuple to `UnicodeError` left the undecodable-exclude
test telling a reader to "drop `UnicodeDecodeError` from the tail's
except tuple" — a member that is no longer there, so the instruction
cannot be followed. Caught in review.

Replaced with the two ablations actually run: dropping `UnicodeError`
fails this test, while narrowing it to `UnicodeDecodeError` does not —
that one belongs to the encode sibling. Stating both is what shows the
codec member is independently pinned from each side rather than by one
test twice.

Comment only — no behavior or assertion change.
@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the stale ablation note — fixed in 6f19355.

Widening the tail's tuple to UnicodeError left test_worktree_local_exclude_degrades_on_undecodable_exclude instructing a reader to "drop UnicodeDecodeError from the tail's except tuple", which is no longer a member, so the ablation could not be performed as written. In this repo an ablation note is the durable record that a negative assertion actually discriminates, so a stale one is worth more than cosmetic.

Replaced with the two ablations I re-ran to confirm the corrected wording:

ablation decode test encode test
drop UnicodeError entirely fails fails
narrow to UnicodeDecodeError passes fails

Stating both is the point — it shows the codec member is pinned independently from each side rather than by one test twice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

install: _worktree_local_exclude does unguarded filesystem work outside its only try

1 participant