fix(source-control): harden prune_babysit_worktrees.py against orphaned state - #1331
Conversation
…ed state A worktree directory left behind by a lock-blocked `git worktree remove` (administrative record dropped, directory surviving -- most commonly a Windows file lock) made every subsequent prune run error `fatal: not a git repository` on that entry instead of self-healing, and the lock-blocked removal itself left the residual directory with no signal. `git_status` failures now distinguish "no longer a valid git repository" from every other failure: an orphaned entry drops its stale worker-lease record and removes the residual directory only when empty and root-contained (never touching an orphan's contents, since git never confirmed it safe to discard), reported via a new `orphan_dropped` row action rather than flipping the run's exit code. `remove_worktree` now verifies the directory actually left disk after a successful removal and reports a still-locked directory via `residual_directory` plus a stderr warning instead of leaving a silent orphan. Closes #816 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 20s —— View job PR Review
SummaryThe logic is sound and the defensive programming is well-considered. The two-tier approach — unconditional Issues1. Non-empty orphan silently exits 0 with no stderr signal — medium
When the orphan directory has content, # after row["action"] = "orphan_dropped"
if not row.get("directory_removed"):
print(
"WARNING: orphaned worktree directory is non-empty and was not removed "
f"for {worktree.key}: {worktree.path}",
file=sys.stderr,
)2.
Whether this is safe depends on # `state_lock` holds a sidecar lock, not an exclusive handle on lease_path
# itself, so unlinking lease_path while the lock is held is safe on Windows.
lease_path.unlink(missing_ok=True)3.
return NOT_A_GIT_REPO_MARKER in str(exc).lower()If git runs under a non-English Minor notes
The function returns End-to-end coverage for non-empty orphan — informational only
Everything else looks good
|
|
Claude finished @kyle-sexton's task in 3m 40s —— View job Security Review
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d98b233c18
ℹ️ 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".
# Conflicts: # plugins/source-control/.claude-plugin/plugin.json # plugins/source-control/CHANGELOG.md
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21019fe414
ℹ️ 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".
…cleanup The documented scoped cleanup (`--pr … --lease-token … --apply`) runs while the worker still holds its lease and releases it in the next orchestration step, but `drop_orphaned_worktree` unlinked the lease record unconditionally. When the orphan belonged to the caller, the matching token let control reach that unlink and deleted the caller's own live lease, so the following `release` failed with `lease does not exist` and ownership was dropped early. `preserve_lease` now scopes the drop to leases that are actually stale — no live hold, or a hold that expired. The `main` call site derives it from the same active-lease probe that authorized the run.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ddb9c4ab65
ℹ️ 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".
# Conflicts: # plugins/source-control/.claude-plugin/plugin.json # plugins/source-control/CHANGELOG.md
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bfa0d490c4
ℹ️ 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".
…nished cleanup Two gaps in the orphan path, both leaving a worktree directory stranded at its deterministic one-per-PR path where a replacement can never be created. `git -C <path>` runs as if git had started in that directory, so upward discovery answers from an ancestor checkout whenever the worktree root sits inside one. `git status` then succeeds while saying nothing about the candidate, the entry reads as healthy, and an open PR sticks as `keep_open` while a closed one errors in `git worktree remove`. `is_orphaned_entry` now compares `rev-parse --show-toplevel` against the candidate path, so an ancestor's answer is an orphan just as surely as `fatal: not a git repository`. A non-empty orphan is deliberately never force-deleted, but the row still reported `dropped: true` and exited zero. It now reports `dropped: false` with `residual_directory: true` and a stderr warning, reusing the vocabulary `remove_worktree` already uses for its own surviving directory.
# Conflicts: # plugins/source-control/.claude-plugin/plugin.json # plugins/source-control/CHANGELOG.md
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 261f34e479
ℹ️ 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".
# Conflicts: # plugins/source-control/.claude-plugin/plugin.json # plugins/source-control/CHANGELOG.md # plugins/source-control/skills/babysit-prs/scripts/prune_babysit_worktrees.py # plugins/source-control/skills/babysit-prs/scripts/tests/test_prune_babysit_worktrees.py
A removed orphan directory left the owning repository's worktree record in place, so the deterministic path still rejected `git worktree add` as "missing but already registered" while the report claimed a self-heal. The record path is read off the entry's own gitdir pointer and pruned there; where it cannot be known, the state is reported rather than assumed. Orphan detection also matched git's English "not a git repository" text, so on a localized machine every orphan surfaced as an unrelated error instead. The probe now pins LC_ALL=C through a new env_overrides seam parameter.
|
Claude finished @kyle-sexton's task in 3m 49s —— View job PR Review
SummaryThis PR has evolved through several review cycles (initial Claude + security review, Codex P2 rounds) and has addressed its major findings well. The final shape is sound. Three items remain that are worth addressing before merge; one informational note below those. Issues1. Vacuous
The test constructs # Remove this line — lease_path was never written and orphan_registration_state
# doesn't touch leases; this assertion is always True:
self.assertFalse(lease_path.exists())2.
The original Claude review flagged this: # state_lock holds a sidecar file, not an exclusive handle on lease_path itself,
# so unlinking lease_path while the lock is held is safe on Windows.
lease_path.unlink(missing_ok=True)If 3.
Minor notesType-b orphan +
Everything else looks good
|
|
Claude finished @kyle-sexton's task in 3m 34s —— View job Security Review
FindingsIMPORTANT —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dff89b3443
ℹ️ 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".
`repositorys` tripped the spell-check gate.
|
Claude finished @kyle-sexton's task in 6m 38s —— View job PR Review
SummaryThis PR has gone through thorough review-and-fix cycles, and most major issues have been resolved well. The two-tier deletion strategy, Issues1.
The prior security review flagged this as HIGH. The Important clarification on the prior review's suggested fix: The bounded blast radius (
2. Vacuous
# Remove — lease_path was never written, so this is always True:
self.assertFalse(lease_path.exists())3.
never_registered = worktree_toplevel(worktree.path) is not None
It is incorrect when: the babysit root sits inside another git checkout, a previously-registered linked worktree loses its The conservative fix: treat ancestor-answers-but-no-pointer as ancestor_toplevel = worktree_toplevel(worktree.path)
# `never_registered` only when an ancestor answers AND the pointer is
# still readable — a path that had a pointer (and so was registered)
# but lost it is indistinguishable from one that was always bare;
# report it as "unresolved" rather than silently skipping.
never_registered = ancestor_toplevel is not None and registered_repo is not NoneThis scenario requires the babysit root to live inside another git checkout (discouraged by design) and a worktree to lose its 4. Missing sidecar comment before
# state_lock holds a sidecar file, not an exclusive handle on lease_path
# itself, so unlinking lease_path while the lock is held is safe on Windows.
lease_path.unlink(missing_ok=True)If Minor notes
The exception message is Everything else looks good
|
|
Claude finished @kyle-sexton's task in 2m 58s —— View job Security Review
FindingsIMPORTANT —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: acf9eeeb35
ℹ️ 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".
…achable Three defects in the previous commit's orphan handling: The emptiness guard counted a dangling .git gitfile as user work, so the one orphan whose owner is knowable never got removed and never reached the prune. A sole .git file is now unlinked; a .git directory is still never touched. An ancestor checkout answering for the path did not prove the path was never registered -- a nested linked worktree resolves to its ancestor once the pointer is lost, while the record survives. That case is unresolved now. A malformed pointer fails with "invalid gitfile format", not the missing-repository wording, so it re-raised as a hard error forever instead of healing. Marker set widened against git's real C-locale output.
# Conflicts: # plugins/source-control/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 3m 55s —— View job PR Review
SummaryThis PR has gone through extensive review-and-fix cycles (Claude + security + Codex P2, multiple iterations). The code has improved substantially with each round. The two-tier deletion strategy, Two issues remain open from prior rounds; one is confirmed safe with a missing documentation note. Issues1.
This was flagged in both security review rounds (HIGH, then IMPORTANT) and remains open in HEAD. The prior review's suggested def prune_repo_worktree_records(repo: Path) -> str:
if not (repo / ".git").is_dir():
return "failed"
try:
run(["git", "-C", str(repo), "worktree", "prune"])
except (RuntimeError, OSError):
return "failed"
return "pruned"A stronger guard — pass the worktree name through and require 2.
This was flagged in three consecutive review rounds and is confirmed safe: # state_lock holds lease_path.with_suffix(".lock"), not an exclusive handle
# on lease_path itself, so unlinking the .json while the .lock is held is
# safe on Windows.
lease_path.unlink(missing_ok=True)Minor notes
def is_missing_repo_error(exc: Exception) -> bool:
msg = str(exc)
idx = msg.find(" failed: ")
detail = msg[idx + len(" failed: "):] if idx != -1 else msg
return any(marker in detail.lower() for marker in NOT_A_WORKTREE_MARKERS)Confirmed resolved since the last review roundThe following issues from earlier rounds are confirmed fixed in HEAD and no longer require action:
|
|
Claude finished @kyle-sexton's task in 3m 50s —— View job Security Review
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7d381ba79b
ℹ️ 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".
…tration Restore the gitfile when the follow-up rmdir fails -- it is the only record of the owning repository, so dropping it on a lock made a retryable failure permanent. Derive the repository from the record's own worktrees/<name> structure so a bare-clone hub, whose common directory is hub.git and has no .git ancestor, is recoverable rather than silently unresolved. Verify the prune by re-reading `worktree list` instead of trusting the exit status: git keeps a locked record and still exits 0, so a locked orphan reported a repair that had not happened. Paths are compared resolved, since git prints POSIX separators and long names while the caller may hold a Windows 8.3 short name for the same directory.
|
Claude finished @kyle-sexton's task in 5m 14s —— View job PR Review
SummaryThe three items Codex P2 flagged on the last round are all correctly addressed in One security issue persists across every prior round and is still unresolved at HEAD. One documentation gap has been carried through four rounds and is now confirmed safe but still undocumented. Issues1.
run(["git", "-C", str(repo), "worktree", "prune"])
run(["git", "-C", str(repo), "worktree", "list", "--porcelain"])
The def prune_repo_worktree_records(repo: Path, worktree_path: Path) -> str:
# Validate the specific record exists before pruning: a crafted .git
# gitfile naming an unrelated repo would have no record for this worktree.
git_dir = repo / ".git" if (repo / ".git").is_dir() else repo
if not (git_dir / "worktrees" / worktree_path.name).is_dir():
return "failed"
try:
run(["git", "-C", str(repo), "worktree", "prune"])
...2. Missing sidecar comment before
# state_lock holds lease_path.with_suffix(".lock"), not an exclusive handle on
# lease_path itself — unlinking the .json while the .lock is held is safe on Windows.
lease_path.unlink(missing_ok=True)Minor notes
The Confirmed fixed in
|
|
Claude finished @kyle-sexton's task in 3m 27s —— View job Security Review
FindingsIMPORTANT —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a3276eeab
ℹ️ 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".
`git worktree prune` takes no path and drops every prunable record in the repository, so a scoped `--pr <one PR> --apply` orphan self-heal also discarded the administrative record of any unrelated worktree whose directory happened to be missing at that moment -- an unmounted share, a removable drive, a checkout mid-restore -- despite it being outside the requested scope. Reproduced on git 2.55.0.windows.3: register two worktrees, delete both directories, prune on behalf of one, and both records vanish. `prune_repo_worktree_records` now clears the record with `git worktree remove <path>`, which names its one target and behaves identically from a standard clone and from a bare hub. The deliberate consequence is that unrelated stale records are no longer swept up as a side effect: clearing those stays `git worktree prune`'s job, run by the operator or by `git gc`, not a decision a single-PR cleanup makes. The existing verify-by-`worktree list` rule is what keeps the swap honest in both directions, since `remove` exits nonzero both for a locked record (correctly `failed`) and for a record that is already gone (correctly `pruned`). `--force` is never passed, and -- because `remove`, unlike `prune`, deletes a worktree's contents -- a still-present directory now returns `skipped` instead of being handed to git, enforcing the caller's `directory_removed` precondition here rather than assuming it. Four regression tests: the sibling record survives a targeted clear (standard clone and bare hub, both verified failing against the repo-wide prune), an already-absent record reads `pruned`, and a surviving directory with uncommitted content is returned `skipped` untouched. Refs #816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Warning Automated review did not complete — this is an infrastructure failure, not a review verdict. Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."
Re-running the job, or pushing a new commit, will retry the review. |
|
Warning Automated security review did not complete — this is an infrastructure failure, not a review verdict. Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."
Re-running the job, or pushing a new commit, will retry the review. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 77ebb64c99
ℹ️ 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".
No linked issue ## Summary Four stranded P2 review findings, each filed by `chatgpt-codex-connector` on a merged `source-control` PR and left unresolved, verdicted REAL by an independent auditor at `main`. All four are discharged here. A fifth thread on the same sweep (`babysit-prs/reference/safety.md:457`, from #1264) was verdicted ALREADY-FIXED by `72ee66e0` and carries **no change** in this PR. Filed line anchors are stale on several of these threads; every fix was made against the complaint text, not the anchor. ## Fix ### `exec-bit-check.sh` keys its candidate set on a new index *entry* (#1590) `git diff --cached --name-status` reports the same staged file as `A <path>` with rename/copy detection off and as `R<score> <old> <new>` / `C<score> <src> <dst>` with it on. The script read and discarded both pair forms, so whether a newly added shebang file staged `100644` got caught was a function of the consumer's `diff.renames` setting rather than of the staged content. A pair destination is now a candidate when its **source was `100755`** — the mode pairing that means the bit was *dropped*. The scan reads `git diff --cached --raw` rather than `--name-status` for exactly this reason: only the raw record (`:<srcmode> <dstmode> <srcsha> <dstsha> <status>`) carries the source mode. The existing `100644`-plus-shebang filter still does the rest. ### `prune_babysit_worktrees.py` restores the gitfile on every surviving path (#1331) Restoration was keyed on `rmdir` raising. Two other paths leave the directory standing: the rescan after the unlink can itself raise, and a file appearing between the unlink and the rmdir skips the removal *without raising at all*. Either way the directory outlived the only record of its owning repository, turning a retryable failure into a permanent `unresolved`. Restoration is now keyed on whether the removal actually happened (a `removed` flag, not a second `exists()` probe — a probe that transiently failed would skip the restore precisely when the directory survives), and the `Path.exists()` probe runs inside the guarded write. ### Two defects this PR's own first pass introduced, caught in review and fixed here Both were filed by `chatgpt-codex-connector` on this PR, both reproduced before fixing, both real. - **A source-mode-blind candidate set** (`exec-bit-check.sh`). Widening to every `R*`/`C*` destination reported a shebang file that is *deliberately* non-executable — a sourced library, a template — merely for being renamed, and `--fix` flipped it to `100755`. Nothing dropped a bit; the file is already tracked, outside the newly-added-only scope. Reproduced: a committed `100644` shebang plus `git mv` gives `:100644 100644 … R100`, and the pre-fix script listed the destination. Hence the source-mode gate above. - **An unguarded existence probe** (`prune_babysit_worktrees.py`). `pointer.exists()` sat in the `finally` *outside* the try guarding the write. `Path.exists()` re-raises an `OSError` whose errno is outside the ignored not-found family, so a permission denial on the very directory the block exists to rescue escaped the `finally` — replacing the original exception and leaving the pointer deleted, the exact loss the block prevents. On `main` this was contained because the probe sat inside an `except OSError` handler; moving it to `finally` uncontained it. The probe is now inside the guard. ### The conflict orchestrator runs base → head → push, in that order (#1355, two threads) Both threads edit the same push-contract bullet, so they land together. - `safety.md` requires the head check immediately before every push, but the base re-fetch — a network round trip — sat between that check and the push, re-opening the exact window the check closes. The contract is now a three-step numbered list with nothing between step 2 (head) and step 3 (push). - Both orchestrator head checks now spell `GH_REPO=<owner>/<repo>`. The bare `gh pr view <N>` had no target: the orchestrator's cwd is whatever the fleet run started from, never reliably the target repository. Deliberately **not** changed: the bare `gh pr view --json headRefOid` at `orchestration.md:513`. That one is in the **Conflict-Worker Contract**, whose cwd *is* the assigned worktree, and the worker contract's own rule offers `cd`-into-the-worktree and `GH_REPO` as alternatives. The finding scopes itself to "both orchestrator head checks". ### The `VALID (defer)` grounding rule states its no-tracker branch (#1633) **Narrower than filed.** The finding claims the missing branch "permanently blocks `full` mode"; it does not — `pull-request/SKILL.md` §Adapting to your environment and a `VALID (fix now)` reclassification both already escape it. The real defect is the *unstated branch*: the rule mandated filing a tracker item before the D5 reply and said nothing about the consumer with no tracker, even though the same skill documents a tracker as optional. That branch is now stated. The CHANGELOG entry was rewritten to the narrower framing rather than restating the overstated claim. **Surface scope, stated explicitly.** The branch is added to the three surfaces that state the *filing mandate*: the canonical `reference/review-discipline.md` §3 clause and its `pull-request/SKILL.md` and `pull-request/reference/monitor.md` restatements. `babysit-prs/reference/independent-resolution.md` also carries the `D4.6-deferral-grounding` tag and is deliberately left alone — it states what an already-chosen `VALID (defer)` must *show* (eligibility), not an instruction to file, so it has no dead end to branch out of. One rule, both directions: mandate ⇒ branch, eligibility criterion ⇒ no branch. `monitor.md` is **not** forced by `check-contract-clause-coverage.py` — verified by reverting only that file's hunk and re-running the gate, which still passes. It is included on the merits above. ## Verification Every fix was confirmed to stop the complaint reproducing, each with a pre-fix control proving the fixture discriminates. **#1590** — `git version 2.54.0.windows.1`. Same fixture, three configurations: | fixture | raw record | `origin/main` `--list` | this branch `--list` | | --- | --- | --- | --- | | copy, **control** (`diff.renames` unset) | `A dest.sh` | reported | reported | | copy, `diff.renames=copies` | `:100755 100644 … C095 src.sh dest.sh` | *(nothing)* | `dest.sh` | | rename, **default** config | `:100755 100644 … R100 src.sh moved.sh` | *(nothing)* | `moved.sh` | | rename off a **non-exec** source | `:100644 100644 … R100 lib.sh lib-moved.sh` | *(nothing)* | *(nothing)* | The control row is the discriminator: the identical `cp` produces `A` with copy detection off and `C095` with it on, and the destination stages `100644` in both. The last row is the regression guard — nothing dropped a bit there, so nothing is reported on either tree. Running this branch's `exec-bit-check.test.sh` against `origin/main`'s script fails exactly cases 50 and 52; against the intermediate source-mode-blind version it fails exactly case 59; against this branch, **59 cases, 0 failures**. Fixture-assertion cases 57/58 pass on every tree, which is what makes 59 a real discriminator rather than a broken fixture. Note for reviewers: the sibling `--fix -- <dest>` cases pass on *both* trees and are not discriminating — a pathspec naming only the destination breaks the rename pairing back to `A`. The defect is in unscoped detection (`--list` / `--probe` / `--fix --all`), which is what cases 50/52 cover. Case 56 is the other negative half: an ordinary rename that *kept* `100755`. **#1331** — both new tests run against `origin/main`'s module (branch tests, old code) **FAIL** on `assertTrue(pointer.is_file())`. The third test (the raising probe) **ERRORs** with an escaped `PermissionError` against the intermediate version, while its sibling passes there — the control that shows the new fixture targets the new defect. Against this branch the full suite is **45 tests, OK**. **#1355** — prose. Control: `origin/main`'s bullet textually places the base re-fetch after the head check and before the push ("Revalidate the base side in the same breath"). Current: numbered 1-Base / 2-Head / 3-Push with nothing between 2 and 3, and `GH_REPO=` on both orchestrator head checks (`:615`, `:681`). **#1633** — prose. Control: `origin/main` states the filing mandate on all three surfaces with no no-tracker branch. The cited escape hatch (`SKILL.md` §Adapting to your environment, line 36) was read and does say a work-item tracker is optional and that its absence must never block a phase. **Gates run locally from the worktree root, all green:** - `python scripts/check-contract-clause-coverage.py` — passed (4 canonical, 14 tagged restatements, 16 pointing surfaces) - `scripts/check-changelog-parity.sh` `--check` / `--check-order` / `--check-bump origin/main` - `scripts/check-contract-slice-prune.sh` `--check` / `--check-diff origin/main` - `scripts/check-shell-portability.sh origin/main`, `scripts/check-skill-portability.sh origin/main` - `scripts/check-changed-skills.sh origin/main` - `scripts/validate-plugins.sh` - `markdownlint-cli2` over all six changed markdown files — 0 errors - `shellcheck -x` over both changed shell files — clean - `scripts/run-ruff.sh check plugins/source-control` — all checks passed - all nine affected `source-control` suites from `scripts/affected-tests.sh origin/main`, plus the two suites the changed scripts own (`exec-bit-check.test.sh` 59/59, `test_prune_babysit_worktrees.py` 45/45) The prune test file's diff is **purely additive** vs `origin/main` — an editor format-on-write pass had rewrapped three untouched regions, and that drift was stripped so every hunk maps to a finding. `plugins/source-control/skills/babysit-loop/SKILL.md` is untouched and stays at 499 lines. Version `0.48.0` → `0.49.3`, renumbered above `main`'s current `0.49.2` after the merge. ## Related Refs #1590, #1331, #1355, #1633 — the merged PRs carrying the four review threads. Refs #1264 — the fifth thread on this sweep, verdicted ALREADY-FIXED (`72ee66e0`); no change here. Refs #1939 — owns the defects in `babysit_resolve_thread.py`, deliberately untouched by this PR. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

This was generated by AI during work-loop execution.
Closes #816
Summary
prune_babysit_worktrees.pyhad two related robustness gaps, both observed at queue-start prune(#657, sweep-converted into #816, operator-ratified 2026-07-23):
git worktree remove-- git's administrativerecord dropped from
.git/worktrees/, the directory itself surviving on disk, most commonly aWindows file lock -- made every subsequent prune run error
fatal: not a git repositoryon thatentry instead of self-healing.
and no cleanup attempt.
git_statusfailures now go throughis_missing_repo_error, which distinguishes "this path is nolonger a valid git repository" from every other git failure (permission errors,
ghnetworkissues, etc. still surface as real errors). An orphaned entry is self-healed by
drop_orphaned_worktree: it drops the stale worker-lease record for that key (by the time thisruns,
main's active-lease check has already established it isn't a live/unexpired hold -- thelease-only case with no matching directory stays
manage_babysit_lease.py reap's job, unchanged)and removes the residual directory only when it is empty and root-contained
(
remove_empty_orphan_directory) -- an orphan's.gitpointer could be corrupted or gone while realuncommitted work still sits there, so a non-empty orphan is reported, never force-deleted. This is
reported via a new
orphan_droppedrow action, and does not flip the run's exit code, so thesame orphan stops re-erroring every cycle.
remove_worktreenow verifies the directory actually left disk after a successfulgit worktree remove(attempt_directory_removal-- safe to fullyrmtreehere, since git alreadyconfirmed the directory was removable) and reports a still-locked directory via
residual_directoryin the JSON row plus a stderr
WARNING, instead of leaving a silent orphan for the next run tostumble over (which the
orphan_droppedpath above then self-heals).source-control0.26.2→0.26.3with the matching CHANGELOG entry.Test plan
Every command below was run from the branch worktree; all passed.
python -m pytest tests/inplugins/source-control/skills/babysit-prs/scripts/-- 364 passed,58 subtests passed (was 358 before this change; 6 new test classes covering the orphan-detection,
directory-removal, and self-heal paths, plus 2 new cases added to the existing
RemoveWorktreeIsHermeticclass).MissingRepoErrorDetectionTests--is_missing_repo_errorfires on a realgit statusfailureagainst a plain (non-git) directory, and not on an unrelated failure string.
AttemptDirectoryRemovalTests-- already-gone path reportsTrue; a surviving directory (withcontent) is fully removed; a removal blocked by a patched
shutil.rmtreeOSError (simulating theWindows-lock scenario portably, since CI runs on
ubuntu-24.04) reportsFalsewithout raising.RemoveEmptyOrphanDirectoryTests-- removes an empty directory under root; leaves a non-emptydirectory's content untouched; refuses a directory outside root (defense in depth, matching
remove_worktree's own containment guard).DropOrphanedWorktreeTests-- drops the lease record and removes an empty orphan directory; is ano-op when no lease record exists; never deletes content from a non-empty orphan directory;
refuses to remove an orphan directory outside root.
RemoveWorktreeIsHermetic(extended) -- a clean removal reportsresidual_directory: False; acleanup that stays blocked (via a patched
attempt_directory_removal, since a realgit worktree removealready clears the directory in this hermetic test environment) reportsresidual_directory: True.MainSelfHealsAnOrphanedWorktreeEntry-- end-to-endmain()run against an orphaned directoryplus its stale lease record: exit code
0,orphan_droppedaction, both the directory and thelease record gone.
python -m py_compile prune_babysit_worktrees.py tests/test_prune_babysit_worktrees.py-- exit 0.An advisor review (this repo's own reviewer-in-the-loop discipline) caught two real gaps in the
first pass before this PR was opened: (1) an unconditional
rmtreeon the orphan path would havedestroyed uncommitted work in a directory git never confirmed removable -- the issue text says
empty directory, and I'd drifted from that while implementing; fixed by adding
remove_empty_orphan_directory, gated on emptiness and root-containment, distinct from theunconditional
attempt_directory_removalused only after a git-confirmed removal. (2) whether alease-only orphan (no matching directory) needed its own sweep in this script -- confirmed via
grepthatmanage_babysit_lease.py reapalready handles that case independently and already runsadjacent to this script at queue start (
SKILL.mdrunbook step 3), so no parallel mechanism wasadded here.
Related
work-class: scoped(C3) 2026-07-23.fix(babysit-prs): casefold owner/repo/login compares in babysit scripts) also bumpssource-control0.26.2→0.26.3. Whichever of these two merges secondwill conflict on the manifest version line and the CHANGELOG insert point -- resolve by rebase,
not force-merge.
🤖 Generated with Claude Code