Playbook: Worktree-remove fall-through guard
Created: 2026-08-09 Issue: #6006 (found during #5766 delivery — no damage occurred)
A leftover, unregistered worktree directory silently makes every git/file command run inside it operate on the parent repository (the orchestrator’s current branch) instead of erroring. This defeats the isolation worktrees exist to provide. This playbook explains the failure mode and the two complementary guards that close it.
The failure mode
After git worktree remove --force tmp/wt-5992 was run on Windows, the directory
tmp/wt-5992 was left behind on disk as an empty, unregistered directory:
- it is not in
git worktree list, - there is no
.gitfile/dir inside it, - but the directory itself still exists.
A later agent assigned to work in that path found that any git/file command run from there silently falls through to the main repo checkout two directories up (whatever branch is currently checked out there), rather than erroring “not a git repository”. The agent caught it before writing (it used absolute paths), but the failure mode is dangerous: an agent that trusts its assigned worktree path and does not independently verify could commit/push to the orchestrator’s current branch instead of its own isolated worktree branch.
Root cause
git worktree remove does two independent things:
- Unregisters the worktree — removes the admin entry under
.git/worktrees/<name>. - Best-effort deletes the checkout directory tree.
On Windows, step 1 essentially always succeeds. Step 2 can silently fail when any process holds
an open handle on a file inside the tree — a VSCode/Pylance file-watcher, an antivirus scan, or a
lingering node/git process. The administrative unregistration is committed regardless, so you
are left with an unregistered directory that git no longer knows about.
Why running git from inside it falls through: git discovers its repository by walking up the
directory tree from the cwd looking for a .git. The leftover dir has none (step 1 removed the
.git gitfile that used to point back to .git/worktrees/<name>), so git keeps walking up —
tmp/wt-5992 → tmp → repo root — and finds the parent repo’s .git. git then operates on
that parent checkout, on whatever branch it currently has checked out. This is git’s documented
repo-discovery behaviour (GIT_CEILING_DIRECTORIES, git rev-parse --show-toplevel), not a git
bug — but combined with a leftover dir it silently breaks isolation.
This is a Windows-specific trigger (file-lock preventing full directory deletion), but the fall-through consequence is cross-platform: any leftover unregistered dir under a repo behaves this way on Linux too.
Fix (a) — guarantee the directory is gone, not just unregistered
Always follow git worktree remove with an explicit recursive delete of the same path you just
removed, then prune the admin records. Never trust git worktree remove alone to have deleted the
directory.
Bash / Linux:
WT="tmp/wt-{issue}" # the exact path passed to git worktree remove
git worktree remove --force "$WT"
rm -rf "$WT" # guarantee removal even if git left the dir behind
git worktree prune -vPowerShell / Windows:
$WT = "tmp\wt-{issue}" # the exact path passed to git worktree remove
git worktree remove --force $WT
# If a file handle blocked deletion, git leaves the dir — force it now:
Remove-Item -LiteralPath $WT -Recurse -Force -ErrorAction SilentlyContinue
git worktree prune -vOnly ever pass the exact $WT variable already handed to git worktree remove — never a bare or
re-derived path — so a typo can’t point rm -rf / Remove-Item at anything else. If Remove-Item
still fails because a handle is held, close the editor/watcher on that path and retry; the
git worktree prune above keeps the admin records consistent in the meantime.
Fix (b) — pre-write fall-through guard (standing rule)
Independent of root cause: before any write or commit, a worktree-scoped agent must assert that
its working directory really resolves to the worktree it was assigned — not a parent repo it fell
through to. git rev-parse --show-toplevel is the canonical check and is cheap enough to run
unconditionally.
Bash / Linux:
EXPECTED_WT="/tmp/worker-6006-radieu-p24-infra" # the path you were assigned to work in
ACTUAL_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "<none>")
if [ "$ACTUAL_TOP" != "$EXPECTED_WT" ]; then
echo "FATAL: cwd toplevel is '$ACTUAL_TOP', expected '$EXPECTED_WT' — aborting before any write" >&2
exit 1
fiPowerShell / Windows:
$ExpectedWt = "C:\code_2026\p24-infra\tmp\wt-6006" # normalise separators to match your convention
$ActualTop = (git rev-parse --show-toplevel) -replace '/','\'
if ($ActualTop -ne $ExpectedWt) {
throw "FATAL: cwd toplevel is '$ActualTop', expected '$ExpectedWt' — aborting before any write"
}Run this once at the top of the agent’s implementation phase (and again after any cd). It costs
one git call and turns a silent cross-branch write into a loud, safe abort.
Decision: use both
(a) prevents the leftover directory in the first place; (b) catches any residual fall-through
(a leftover dir from an older cleanup that predates fix (a), a hand-created path, a stale
isolation: "worktree" checkout). They are complementary layers — fix (a) is hygiene, fix (b) is
the safety net. (b) is now a standing rule in
standards/common/claude-role.md §Worktree branch
isolation, which every role loads at session start, so it applies to every worktree-based agent.
Related
stale-agent-worktree-cleanup.md— bulk cleanup of accumulated.claude/worktrees/agent-*checkouts (a different symptom of the same “git doesn’t fully remove” behaviour)shared-checkout-worktree-isolation.md— the hook guard that blocks branch/commit ops in a shared checkoutconcurrent-sops-write-worktree-race.md— the filesystem-level sibling race for SOPS writesstandards/common/claude-role.md§Worktree branch isolation — the standing ruleCLAUDE.md§Agent Workflow — Worktree Branch Isolation (global policy)