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 .git file/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:

  1. Unregisters the worktree — removes the admin entry under .git/worktrees/<name>.
  2. 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-5992tmp → 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 -v

PowerShell / 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 -v

Only 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
fi

PowerShell / 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.

  • 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 checkout
  • concurrent-sops-write-worktree-race.md — the filesystem-level sibling race for SOPS writes
  • standards/common/claude-role.md §Worktree branch isolation — the standing rule
  • CLAUDE.md §Agent Workflow — Worktree Branch Isolation (global policy)