Skip to content

fix(install): close the exclude helper's two residual holes (#374, #375) - #376

Merged
pbean merged 6 commits into
mainfrom
fix/374-375-exclude-residual-holes
Jul 29, 2026
Merged

fix(install): close the exclude helper's two residual holes (#374, #375)#376
pbean merged 6 commits into
mainfrom
fix/374-375-exclude-residual-holes

Conversation

@pbean

@pbean pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #374. Closes #375.

Follow-up to #359 (PR #373), which guarded _worktree_local_exclude's filesystem tail but left two
holes the review surfaced. Both live in the same helper and both falsify the same best-effort
contract, so they ship together rather than as two PRs racing on adjacent lines.

#374 — the git-query arm decoded strictly

subprocess.run(..., text=True) decodes stdout with the locale codec at errors='strict'.
UnicodeDecodeError is neither a SubprocessError nor an OSError, so it escaped the one arm whose
entire contract is "skip silently". POSIX filenames are bytes, so a repo path carrying bytes
invalid in the locale encoding reaches this on an ordinary box:

ESCAPED from arm 1: UnicodeDecodeError -> 'utf-8' codec can't decode byte 0xff in position 10

Stdout is now captured as bytes and decoded with os.fsdecode inside the guarded tail. Two
reasons for that placement: fsdecode uses surrogateescape on POSIX, so a non-UTF-8 path round-trips
back to the filesystem instead of faulting at all; and it can still raise on Windows, where
utf-8/surrogatepass rejects a lone invalid byte — so it belongs where UnicodeError is already
caught.

#375 — the write was not atomic

The update is a read-modify-rewrite, and Path.write_text opens "w", which truncates before
writing. prefix carries the operator's own excludes, so a fault partway through left the file cut
mid-content while the degrade reason still said "could not update":

before:   b"# OPERATOR'S OWN\n/secret-local\n"
after:    b"# OPERAT"                            <- ablation output, verbatim

Three consequences: the reason misdescribes the state; the surviving tail parses as a valid git
pattern
and is a prefix of the intended one, so a cut one character in leaves the last line as /,
excluding the entire worktree — the exact state worktree_flow.py already guards against for
hookless profiles; and the blast radius is the main repo, since a linked worktree's
--git-common-dir points at the main .git.

Now written to a scratch file and moved into place with atomic_replace, with the scratch file
removed on the failure path. The exclude is either fully updated or untouched.

One test was replaced after failing its own ablation

The first version of the #374 test monkeypatched subprocess.run to return undecodable bytes. It
passed — and passed identically with text=True restored, because handing the code bytes
directly never runs the stdlib's decoding at all. It could not see the bug it was written for. Driving
a real stub git on PATH does fail the ablation. It is POSIX-only and skipped on Windows, which
is correct rather than a dodge: Windows paths are UTF-16, so git cannot emit an undecodable one.

The #375 test injects at Path.open, not Path.write_text — patching write_text means the file is
never opened, so the truncation cannot happen and the test would pass against the very bug it exists
to catch. That is the same reason the two pre-existing write-fault tests needed their name filter
widened: the write now lands on the scratch file, and a filter pinned to "exclude" alone would stop
injecting anything and pass vacuously.

Ablation Fails
restore text=True on the subprocess the stub-git test
write directly to exclude instead of tmp + atomic_replace the short-write test (file returns truncated)
(rejected test shape) patch subprocess.run instead of stubbing git nothing — which is why it was replaced

Verification

uv run pytest -q -n auto — 3695 passed, 24 skipped (run three times; one unrelated Textual modal
test, test_decision_modal_scrolls_when_content_long, flaked once under parallel load and passes
consistently in isolation and on main). uvx pyright@1.1.411 — 0 errors. trunk fmt + full
trunk check — no issues.

Summary by CodeRabbit

  • Bug Fixes
    • Worktree exclude updates are now written atomically, preventing partial/truncated exclude files and preserving existing content.
    • Exclude update failures no longer leave behind scratch/temp artifacts.
    • Worktree setup now handles repositories with non-UTF-8 paths without crashing, degrading gracefully when needed.
  • Tests
    • Added failure-injection and coverage for partial-write scenarios and undecodable git output, including validation that exclude content remains byte-identical.

Follow-up filed, not fixed here: #381atomic_write_text makes each write all-or-nothing but does not serialize the read-modify-write around it, so two runs provisioning against one repo interleave into a lost update. Pre-existing, reproduced in the issue.

Follow-up to #359, which guarded the filesystem tail but left two holes
the review surfaced. Both live in `_worktree_local_exclude` and both
falsify the same best-effort contract, so they ship together.

#374 — the git-query arm decoded stdout with `text=True`, errors=strict.
POSIX filenames are bytes, so a repo path carrying bytes invalid in the
locale encoding raised UnicodeDecodeError out of the one arm whose entire
contract is "skip silently", and that type is in neither arm's tuple.
Stdout is now captured as bytes and decoded with `os.fsdecode` inside the
guarded tail: such a path round-trips back to the filesystem instead of
faulting, and the decode sits in the tail because fsdecode CAN still
raise on Windows (utf-8/surrogatepass rejects a lone invalid byte).

#375 — the update is a read-modify-REWRITE and `write_text` opens "w",
truncating before it writes, so a short write left the operator's own
excludes cut mid-content while the reason still said "could not update".
The surviving tail parses as a valid git pattern and is a prefix of the
intended one: cut one character in and the last line is `/`, which
excludes the entire worktree — the exact state worktree_flow guards
against for hookless profiles. Blast radius is the MAIN repo, since a
linked worktree's --git-common-dir points at the main .git. Now written
to a scratch file and moved into place via atomic_replace, with the
scratch file removed on the failure path.

Ablations run for both, and one test was REPLACED after failing its own:
a version that monkeypatched `subprocess.run` passed identically with
`text=True` restored, because handing the code bytes directly never runs
the stdlib's decoding. Driving a real stub `git` on PATH does fail the
ablation. POSIX-only and skipped on Windows, correctly — Windows paths
are UTF-16, so git cannot emit an undecodable one.

The #375 test injects at `Path.open`, not `Path.write_text`: patching
write_text means the file is never opened, so the truncation cannot
happen and the test would pass against the very bug it exists to catch.
That is also why the two pre-existing write-fault tests needed their name
filter widened — the write now lands on the scratch file.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pbean, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ee44232b-b035-4e71-8de4-a0a57a27e97e

📥 Commits

Reviewing files that changed from the base of the PR and between 77b2df1 and 399d2de.

📒 Files selected for processing (2)
  • src/bmad_loop/install.py
  • tests/test_install.py

Walkthrough

The worktree-local exclude helper now decodes Git output using filesystem-safe handling and updates exclude files through temporary-file replacement. Tests cover undecodable paths, partial writes, cleanup, and provisioning degradation reporting.

Changes

Worktree exclude handling

Layer / File(s) Summary
Git common-dir query resilience
src/bmad_loop/install.py, tests/test_install.py, CHANGELOG.md
The Git common-dir query captures raw bytes and decodes with os.fsdecode; POSIX coverage verifies undecodable output degrades without raising.
Atomic exclude-file update
src/bmad_loop/install.py, tests/test_install.py
Exclude contents are written atomically, with short-write tests preserving the original file and provisioning fault injection covering write failures.

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

Possibly related issues

Possibly related PRs

Suggested reviewers: polloinfilzato, dracic

Poem

I’m a rabbit with bytes in my burrow tonight,
Swapping fresh excludes in one atomic flight.
No half-written paths, no scraps left behind,
Even odd Git names stay safely defined.
Hop, hop—the worktree is tidy and bright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two install bug fixes in _worktree_local_exclude.
Linked Issues check ✅ Passed The changes address both #374 and #375 by handling undecodable git output and making exclude writes atomic.
Out of Scope Changes check ✅ Passed The changelog, code, and tests stay focused on the two linked exclude-helper fixes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/374-375-exclude-residual-holes

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 closes two holes left in _worktree_local_exclude after #359: a UnicodeDecodeError that escaped both exception arms when the repo path contained bytes invalid in the locale encoding (text=True in subprocess.run), and a truncation hazard where write_text's \"w\" mode could leave the operator's exclude file cut mid-content on an ENOSPC/EIO fault, silently producing a / pattern that excludes the entire worktree from the main repo.

Confidence Score: 5/5

Safe to merge — both fixes are narrowly scoped to the one helper they close holes in, and the exception-handling contract of the two-arm function is preserved exactly.

The bytes-capture change moves os.fsdecode to the only place where a decode failure is already treated as a degrade. The atomic-write change is a drop-in via an existing, well-tested platform helper that handles temp-name uniqueness, fsync, mode preservation, and cleanup on failure. The two new regression tests each document their own ablation outcome. No unrelated paths are touched.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/bmad_loop/install.py Captures subprocess stdout as bytes and decodes with os.fsdecode inside the guarded tail; replaces write_text with atomic_write_text for the exclude rewrite. Both changes are correctly placed and exception handling is consistent with the existing two-arm contract.
tests/test_install.py Two pre-existing write-fault tests correctly re-patched at the module-level atomic_write_text binding. Two new regression tests added: a real stub-git-on-PATH approach for #374 and an io.open-level ShortWriter for #375, both with documented ablation proofs.
CHANGELOG.md Two new entries prepended to the current release block, accurately describing the short-write atomicity fix and the non-UTF-8 path decode fix.

Reviews (3): Last reviewed commit: "docs(install): stop the exclude write co..." | Re-trigger Greptile

@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.

@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: 2

🧹 Nitpick comments (1)
src/bmad_loop/install.py (1)

790-805: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use a process-scoped scratch name for shared .git/info/exclude.

Linked worktickets share --git-common-dir, and the current fixed name depends on --git-common-dir, which can race if multiple story runs provision worktrees against the same repo concurrently, causing partial or replaced exclude writes.

🔒 Suggested fix: give the scratch file a unique name
-        tmp = exclude.with_name(f"{exclude.name}.bmad-loop.tmp")
+        tmp = exclude.with_name(f"{exclude.name}.bmad-loop.{os.getpid()}.{uuid.uuid4().hex}.tmp")
🤖 Prompt for 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.

In `@src/bmad_loop/install.py` around lines 790 - 805, Update the temporary path
creation in the exclude update flow around atomic_replace to use a
process-scoped unique scratch filename rather than the fixed “.bmad-loop.tmp”
name. Preserve the existing write-then-atomic-replace behavior and cleanup on
failure, while ensuring concurrent story runs sharing the same git common
directory cannot reuse or overwrite the same scratch file.
🤖 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 765-769: Update the git invocation in the surrounding install flow
to use verify.py’s _run_git wrapper, extending it with a raw-bytes option/helper
if the caller must retain bytes. Preserve the existing command, check-on-failure
behavior, and bytes-based processing; only keep the direct subprocess.run call
if you document a concise, explicit rationale for the exception.

In `@tests/test_install.py`:
- Around line 1645-1651: Update the git stub setup in the test to emit a path
derived from tmp_path rather than the hardcoded /tmp/repo-... location. Keep the
invalid UTF-8 byte in the emitted path, and ensure the resulting path remains
within tmp_path so _worktree_local_exclude creates and writes only inside
pytest’s managed sandbox.

---

Nitpick comments:
In `@src/bmad_loop/install.py`:
- Around line 790-805: Update the temporary path creation in the exclude update
flow around atomic_replace to use a process-scoped unique scratch filename
rather than the fixed “.bmad-loop.tmp” name. Preserve the existing
write-then-atomic-replace behavior and cleanup on failure, while ensuring
concurrent story runs sharing the same git common directory cannot reuse or
overwrite the same scratch file.
🪄 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: e923defa-e527-4d6a-bd49-e90331be986f

📥 Commits

Reviewing files that changed from the base of the PR and between 87a4b5e and 2eb8f62.

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

Comment thread src/bmad_loop/install.py
Comment thread tests/test_install.py
Review of the previous commit: hand-rolling `tmp.write_text` +
`atomic_replace` with a FIXED sibling name reintroduces the hazard the
helper's own docstring calls out — "no fixed `.tmp` sibling for a
concurrent writer of the same file to collide with".

That collision is reachable here specifically. Every linked worktree of
a repo resolves `--git-common-dir` to the SAME main `.git`, so two runs
provisioning against one repo both target
`<main>/.git/info/exclude.bmad-loop.tmp`.

atomic_write_text also fsyncs before the replace and carries the mode
across, both of which a bare replace resets — it swaps a new inode in.

Test seams had to move with it, and both moves matter:

- the two write-fault tests patched `Path.write_text`; the write now goes
  through `os.fdopen` on an mkstemp fd, so that patch stops firing and
  the tests would pass VACUOUSLY. Patched at install's own
  `atomic_write_text` binding instead.
- the short-write test patched `Path.open` for the same reason. Retargeted
  to `io.open`, the one seam BOTH shapes share — the fix reaches it via
  os.fdopen, the ablation via Path.open — so the ablation keeps biting.
- the leftover-scratch-file assertion named a literal filename, which is
  vacuous once the temp name is random. It now asserts the directory
  contains nothing but `exclude`.

All three ablations re-run after the seam moves: direct write_text fails
the short-write test, dropping OSError fails both write-fault tests, and
restoring text=True fails the stub-git test.
Caught in review. The stub emitted a hardcoded
`/tmp/repo-\377-name/.git`, and the helper mkdir(parents=True)s whatever
common dir it is handed — so every successful run created real
directories in the SHARED system /tmp, outside pytest's tmp_path and its
cleanup. Confirmed on this box: /tmp/repo-<0xff>-name existed.

The stub now builds its path from tmp_path via os.fsencode, keeping the
undecodable byte while staying in the sandbox, and the test asserts
nothing was created outside it. Ablation re-run against the new stub:
restoring text=True still fails it.

Also documents why this call stands outside verify._run_git, the
chokepoint AGENTS.md otherwise requires: nothing structural forbids it,
but _run_git returns CompletedProcess[str] — the strict decode this call
exists to avoid — and raises GitError rather than the (SubprocessError,
OSError) this arm treats as an expected skip. Adopting it needs a bytes
mode on the chokepoint, filed as #377.
@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

All three addressed — two were real, and the /tmp one was a genuine catch. Thanks.

1. Fixed scratch name racing on the shared common dir — valid, already fixed in 99ab745. You reviewed the commit before it. The fix is not a hand-rolled unique name but platform_util.atomic_write_text, whose docstring names this exact hazard ("no fixed .tmp sibling for a concurrent writer of the same file to collide with") and which also fsyncs before the replace and carries the mode over — both of which a bare os.replace resets, since it swaps in a new inode.

Moving to it broke three test seams in a way worth recording, because each would have failed silently: the helper writes via os.fdopen on an mkstemp fd, so the two Path.write_text patches and the Path.open patch all stopped firing and the tests passed vacuously. They now patch install.atomic_write_text and io.open respectively — io.open being the one seam both shapes share, so the ablation keeps biting. The leftover-scratch assertion also named a literal filename, which is meaningless once the name is random; it now asserts the directory contains nothing but exclude.

2. Stub git leaking real directories into shared /tmp — correct, fixed in 0066682. Confirmed on this box before fixing: /tmp/repo-<0xff>-name existed, created by the test. _worktree_local_exclude does mkdir(parents=True) on whatever common dir it is handed, so the hardcoded path escaped tmp_path entirely. The stub now builds its path from tmp_path via os.fsencode, keeping the undecodable byte inside the sandbox, and the test asserts nothing was created outside it. The ablation was re-run against the new stub: restoring text=True still fails it.

3. Route through _run_git — declined here, filed instead (#377). You are right that this bypasses the chokepoint, and right that nothing structural forbids it: neither module imports the other. But _run_git returns CompletedProcess[str], which is precisely the strict decode this PR exists to remove, and it raises GitError rather than the (SubprocessError, OSError) this arm treats as an expected skip. Adopting it would reintroduce the bug.

Taking your second option — an explicit exception with the rationale in the code, including the real cost (this call has no timeout). The bytes mode the chokepoint would need is #377, which also covers a bigger instance of the same defect: _run_git's own tuple omits UnicodeDecodeError, so it escapes untyped past all ~50 except GitError guards. Reproduced on -z output, git diff content, and worktree list --porcelain.

@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@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 21 minutes.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0066682eea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bmad_loop/install.py
# common dir, so a fixed `.tmp` sibling is a collision between two runs
# provisioning against one repo. It also fsyncs before the replace and
# carries the mode over, which a bare replace resets.
atomic_write_text(exclude, prefix + "\n".join(new) + "\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve shared access when creating the exclude file

When .git/info/exclude does not yet exist, atomic_write_text creates it with mkstemp's fixed 0600 mode, whereas the previous Path.write_text honored the process umask (normally producing 0644 or 0664). In a shared checkout or a repository later accessed under another UID, Git cannot read this common exclude file, ignores its shielding patterns, and can consequently stage the provisioned tool directories during git add -A; use creation permissions compatible with the prior behavior while retaining the atomic replacement.

Useful? React with 👍 / 👎.

Comment thread tests/test_install.py Outdated

assert reason is None or "could not update" in reason
# nothing was created outside the sandbox
assert not list(Path("/tmp").glob("repo-*-name")) or sys.platform == "win32"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the sandbox assertion scoped to this test

On POSIX, this assertion fails whenever any unrelated process or an earlier test run has left a matching /tmp/repo-*-name directory, even though this invocation correctly created everything under tmp_path. Conversely, when pytest uses a temporary root outside /tmp, it does not prove that nothing was created beside that root. Compare a before/after snapshot or assert only paths attributable to this stub so the test cannot fail because of ambient system state.

Useful? React with 👍 / 👎.

pbean added 2 commits July 28, 2026 23:23
The test drove a real stub `git` on PATH, which is the right shape, but
three details let it pass without testing anything.

- It asserted only `reason is None or "could not update" in reason`, which
  the git-unqueryable arm satisfies by returning None. Whenever the stub
  cannot exec — a noexec temp dir, no /bin/sh, a dropped exec bit — the
  OSError was swallowed as an expected skip and the test went green having
  run nothing, taking its `text=True` ablation with it. Measured: at mode
  0644 every assertion still passed. Now pins that the tail actually ran.

- The leak check globbed shared /tmp, which is non-recursive while tmp_path
  sits three levels down, so it could not see this test's own output — but
  it could fail for anyone whose box still carried litter from the
  hardcoded-path version. The containment pin replaces it.

- The path was interpolated into the shell script unquoted, so a temp root
  with whitespace word-split into a second printf argument; `printf '%s\n'`
  repeats its format, and the helper then created a directory with an
  embedded newline beside the basetemp pytest reaps. Measured with a spaced
  --basetemp: it leaked and still passed. Now single-quoted.

Also drops `prepend=False` from the PATH setenv: pytest's parameter takes a
separator string, and only a falsy value made it a no-op rather than a
TypeError.

Ablations re-run: text=True restored fails; a non-executable stub fails; the
unquoted interpolation under a spaced --basetemp fails.
Three corrections to comments added by this PR, no behavior change.

- The mode is carried over only when the exclude already exists;
  atomic_write_text creates a new one at its deliberate 0600 rather than the
  umask default, as its own docstring states.

- Atomicity per write is not isolation across the read/write pair. The
  comment reasoned about two runs provisioning against one repo, which
  invited the reading that concurrency was handled; the lost update that
  remains is #381, reproduced there.

- The Windows note read as contradicting the regression test's POSIX-only
  skip. Marked as defensive placement rather than a path under test.
@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Triaged on 0066682, fixed in 28ae720 + 77b2df1.

@coderabbitai — your last review read stale code. Its own review-info block says it covered "between 87a4b5e and 2eb8f62", and it posted at 05:34:53Z, before 99ab745 (05:39:37Z) and 0066682 (05:44:27Z) existed. So all three findings were against the first commit:

  • Route through _run_git — answered above and already in the code at install.py:766-773, as promised.
  • Fixed .tmp scratch name (nitpick) — superseded by 99ab745, which moved to atomic_write_text.
  • Stub git leaking into /tmp — you marked this addressed yourself in 0066682.

The two full review invocations both replied "Full review finished" while rate-limited, which is indistinguishable from a clean review. Flagging that because a stale review that reports success is worse than no review.

@greptile-apps — the prepend=False note was right, and worth more than its severity. MonkeyPatch.setenv(name, value, prepend: str | None = None) consumes it as if prepend and name in os.environ, so False is falsy and behaves exactly like omitting it. No behavioral effect, but the parameter takes a separator string, and anyone "correcting" it to prepend=True gets value + True + os.environ[...]TypeError. Dropped, and the replaced comment records why PATH is replaced rather than prepended: the stub has to be the only resolvable git, or the box's real one answers first with a decodable path.

Pulling that thread found three more holes in the same test, all measured rather than reasoned:

  1. It passed vacuously whenever the stub could not exec. The only assertion was reason is None or "could not update" in reason — which the git-unqueryable arm satisfies by returning None. On a noexec temp dir (ordinary CI hardening), with no /bin/sh, or on a filesystem that drops the exec bit, subprocess.run raises OSError, the first arm swallows it as the expected skip it is, and the test goes green having exercised nothing — taking the text=True ablation with it. Measured: at mode 0644 every assertion still passed. It now pins that the tail ran and the pattern landed on the decoded path.

  2. The leak check was aimed at the wrong place. Path("/tmp").glob("repo-*-name") is non-recursive and tmp_path sits three levels down, so it could never see this test's own output — while it could fail for anyone whose box still carried litter from the hardcoded-path version. Replaced by the containment pin above, which catches a revert to a hardcoded path directly.

  3. The path was interpolated into the shell unquoted, which reopened the /tmp leak through a different door. A temp root carrying whitespace (TMPDIR, --basetemp, a username with a space) word-splits into a second printf argument, and printf '%s\n' repeats its format per argument — so the helper is handed a path with an embedded newline and mkdir(parents=True)s it as a sibling of the basetemp pytest reaps. Measured with --basetemp='.../my user': it created my\nuser/... outside the sandbox and the test still passed. Now single-quoted with the standard '\'' escape.

All three ablations re-run against the final shape: text=True restored fails; a non-executable stub fails; the unquoted interpolation under a spaced --basetemp fails.

One production finding, filed rather than fixed: #381. atomic_write_text makes each write all-or-nothing; it does not serialize the read-modify-write around it. Every linked worktree of a repo resolves to the same --git-common-dir, so two runs provisioning against one repo interleave into a clean lost update — reproduced with two worktrees:

wt1 returned: None
/from-wt1 present: True
/from-wt2 present: False   <-- lost

Both calls returned None. That is exactly what the helper's docstring says silence must not cause ("without the exclude the unit's git add -A commits the tool files this provisioning just wrote"). Pre-existing — write_text lost the update the same way — and not covered by #379, which is scoped to truncation. 77b2df1 corrects the comment that invited the reading that concurrency was handled, along with two other overclaims: the mode is carried only when the exclude already exists (a new one gets the helper's deliberate 0600), and the Windows note now reads as defensive placement rather than contradicting the test's POSIX-only skip.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@pbean You’re right—the prior review was stale and should not have been presented as a completed review of the then-current PR. Thank you for the precise commit/timestamp evidence and for hardening the tests with measured ablations.

I also agree with the scoping: atomic_write_text resolves the scratch-name, partial-write, fsync, and existing-mode concerns, while the shared-common-dir read-modify-write lost-update race is a distinct issue now appropriately tracked in #381.

🐇💭


✏️ Learnings added
Learnt from: pbean
URL: https://github.com/bmad-code-org/bmad-loop/pull/376

Timestamp: 2026-07-29T06:24:17.073Z
Learning: In `src/bmad_loop/install.py`, `_worktree_local_exclude` intentionally bypasses `_run_git` for its `git rev-parse --git-common-dir` query: it must capture raw bytes and decode with `os.fsdecode` inside its best-effort error boundary. `_run_git` currently returns text-decoded `CompletedProcess[str]` and raises `GitError`, so routing this query through it would reintroduce undecodable-path failure behavior until the broader bytes-mode work tracked in GitHub issue `#377` is completed.

Learnt from: pbean
URL: https://github.com/bmad-code-org/bmad-loop/pull/376

Timestamp: 2026-07-29T06:24:17.073Z
Learning: For the POSIX undecodable-Git-output test in `tests/test_install.py`, the stub Git executable must be proven to execute and its emitted common-dir path must be constructed from `tmp_path` using filesystem-byte encoding and safely shell-quoted. Replacing `PATH` rather than prepending is intentional so a host Git binary cannot satisfy the call; assertions must verify the exclude pattern reached the decoded path, preventing an `OSError` from a non-executable stub from passing through the helper’s deliberate silent-degradation path.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

All acknowledged — and the follow-up work here is more thorough than the original fixes.

On the stale review: the timestamp evidence is clear. Both "Full review finished" replies were rate-limited non-reviews. That failure mode — a silent success from a tool that didn't run — is genuinely hard to defend against from the reviewer's side. Thanks for flagging it precisely.

On prepend=False: glad the note was useful. The str | None typing makes the False path especially treacherous because it looks like an intentional "don't prepend" flag rather than a falsy-string hazard — and the TypeError on a "correction" to prepend=True is exactly the kind of thing that surfaces three refactors later with no obvious connection to the original change.

On the three measured holes in the stub-git test: each of these is the right kind of finding — not "this looks wrong" but "I measured this and it passed when it shouldn't." The vacuous-pass-on-noexec is particularly sharp: noexec temp dirs are standard CI hardening, so the test would have been reliably hollow in the environments where it most needed to work. The single-quoted shell interpolation and the non-recursive glob are the same quality. All three now have ablations that confirm they bite.

On #381: correct scoping. atomic_write_text makes the write all-or-nothing; it can't serialize the read-modify-write without a lock or a CRDT-like append-only format. Filing it separately rather than folding a larger fix into a narrow PR is the right call, and 77b2df1 correcting the comment that invited the "concurrency handled" reading removes the false assurance cleanly.

The current shape of both install.py and the test suite looks correct. The io.open seam for the short-write test is the right intercept point — it fires on both os.fdopen(mkstemp_fd) (the fix path) and Path.open("w") (the ablation path), so it can actually distinguish them.

LGTM on the current HEAD.

@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 2 minutes.

@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.

atomic_write_text carries a mode over only when the target already exists;
one it creates from nothing gets mkstemp's private 0600, where the
write_text it replaced honored the umask. So moving to the helper silently
narrowed a freshly created .git/info/exclude from 0644 to 0600.

That matters at this call site specifically. An exclude git cannot read is
one git silently ignores — measured: it warns "unable to access", exits 0,
and `git add -A` stages the files the exclude was written to shield. That is
the harm the helper's docstring says silence must not cause, reached through
a write that SUCCEEDED, so not even a degrade reason fires. Reachable
wherever the common dir is read under another UID (core.sharedRepository, a
shared checkout) — atomic_write_text's own docstring warns that callers of
genuinely shared state need more than it alone.

Creates the file the way write_text did, under the umask, before handing it
to the helper, which then carries that mode across the replace. An empty
exclude is equivalent to an absent one for git, so a fault after the touch
leaves no more damage than the missing file it replaced.

Found by Codex review. The regression test pins the created mode against a
measured write_text probe rather than a hardcoded 0644, and pins the umask
at 0022, because at a box's own 0077 the ablated code also produces 0600 and
the ablation would not bite.

@greptile-apps greptile-apps 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.

pbean has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@codex — both correct. The first one I had already been handed and had triaged wrong; your framing is what makes it a defect rather than a docs nit.

1. Creation permissions — real regression, fixed in 399d2de. An earlier review pass raised the 0600 change and I closed it as documentation-only, on the grounds that atomic_write_text's docstring already declares the choice deliberate ("there is no prior mode to carry over, and the restrictive choice is the safe one"). That reasoning was about the generic helper, not this call site. You went to the consequence here, which is where it turns:

$ chmod 0000 .git/info/exclude && git status --porcelain
warning: unable to access '.git/info/exclude': Permission denied
?? tooldir/
$ git add -A && git diff --cached --name-only
tooldir/tool.txt          # exit 0

git does not fail on an unreadable exclude — it warns, exits 0, and stages the files the exclude existed to shield. So the narrowed mode reproduces the exact harm the helper's docstring says its silence must not cause, and reaches it through a write that succeeded, meaning not even the degrade-reason channel fires. The same docstring I had leaned on says the opposite of what I took from it two paragraphs on: "Callers writing genuinely shared, multi-user state need more than this helper." This caller is that.

Fixed by creating the file the way the replaced write_text did — O_CREAT under the umask — before handing it to the helper, which then carries that mode across the replace. End-to-end: mode 0644, git status clean, no warning. An empty exclude is equivalent to an absent one for git, so a fault after the touch leaves no more damage than the missing file it replaced.

The regression test pins against a measured write_text probe rather than a hardcoded 0644, and pins the umask at 0022 — at a box's own 0077 the ablated code also lands 0600 and the ablation would not bite. Verified: with the touch() removed it fails, reporting 0o600.

2. Sandbox assertion scoped to ambient state — already fixed in 28ae720, and your two failure directions are exactly right. It was worse than order-dependent: Path("/tmp").glob(...) is non-recursive while tmp_path sits three levels down, so it could never observe this test's own output in the first place, while it could fail for anyone whose box still carried litter from the hardcoded-path version of this test. Replaced with the positive containment assertion you describe — asserting the path attributable to the stub — which also catches a revert to a hardcoded path directly and depends on no ambient state.

That same commit closed two more in the same test: it passed vacuously whenever the stub could not exec (the only assertion was satisfied by the git-unqueryable arm's None, so a noexec temp dir made the test unfailable and its text=True ablation evaporate — measured at mode 0644), and the stub's path was interpolated into the shell unquoted, so a temp root with whitespace word-split into a second printf argument and leaked a newline-named directory beside the basetemp pytest reaps, while still passing.

Ablations re-run against the final shape: text=True restored fails; a non-executable stub fails; the unquoted interpolation under a spaced --basetemp fails; the mode fix removed fails.

@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@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 47 minutes.

@pbean

pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 399d2de5f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

Labels

None yet

Projects

None yet

1 participant