Playbook: Shared Checkout Worktree Isolation Enforcement
Created: 2026-07-08 Incident: Two stray commits from unrelated sessions landed on this orchestrating session’s checked-out branch within a single working session Issue: #3230
Trigger
Multiple concurrent Claude Code sessions/agents (main session, background Agent() calls, other
human/agent sessions on the same machine) all operate against the same primary checkout
(C:\code_2026\p24-infra) instead of isolated tmp/wt-{issue} worktrees mandated by
CLAUDE.md §Agent Workflow — Worktree Branch Isolation.
Whichever session runs git checkout <branch> last determines what’s checked out. If a second
session then runs git commit without first switching to its own branch, the commit lands on
whatever branch the first session left checked out — silently, with no error.
Symptoms
git log --oneline -3shows an unexpected commit authored by a different task/topic than what you’re working ongit branch --show-currentshows a branch name unrelated to your current task (e.g.,security/3216-jwt-rotationorrc/3223-atrax-n8n-bms4when you expectedmain)- A commit you just made doesn’t appear where expected, or an extra commit appears above yours in
git log
Root Cause
This is not a bug in any single session’s logic — every session correctly runs git commit in
“the current directory.” The problem is that the current directory is shared, mutable state
across concurrent sessions, and nothing enforced the documented worktree-isolation policy
technically — it was documentation-only, easy to forget under task pressure.
Observed twice in one session on 2026-07-08:
- A commit intended for
docs/is-3218-...landed onsecurity/3216-jwt-rotation(a JWT rotation branch from an unrelated concurrent session) - Minutes later, the working directory had switched again to
rc/3223-atrax-n8n-bms4(an ATRAX n8n branch from yet another concurrent session)
Immediate Recovery (if you discover a stray commit)
Do NOT discard it — it may be someone else’s real, unmerged work.
# 1. Identify the stray commit(s) on top of / mixed into your branch
git log --oneline -5
# 2. Rescue it onto its own branch before touching anything
git branch rescue/<topic>-<short-desc> <stray-commit-sha>
git push origin rescue/<topic>-<short-desc>
# 3. Reset your own branch back to remove the stray commit
# (only if it hasn't been pushed anywhere with the stray commit included)
git reset --hard origin/<your-branch> # or origin/main if branch is new
# 4. Redo your own work cleanly in an isolated worktree (see Prevention below)
# 5. File an issue documenting the rescued branch so a human/session can
# properly re-file it under the correct issue number and review itPermanent Fix — Technical Enforcement (applied 2026-07-08)
Documentation alone (CLAUDE.md §Worktree Branch Isolation) was not sufficient — it relies on every
session remembering to opt in. Added a technical guard to both safety hooks:
.claude/hooks/pre-bash-safety.sh(Bash tool).claude/hooks/pre-bash-safety-windows.ps1(PowerShell tool)
Detection logic: a linked git worktree’s --git-dir points to .git/worktrees/<name>, while the
primary checkout’s --git-dir equals its --git-common-dir. Any git commit, git checkout -b,
git switch -c, or git branch -m/-M run where these are equal (i.e., in the primary checkout)
is now blocked with a message directing to git worktree add.
# This now fails in the primary checkout:
cd C:\code_2026\p24-infra
git commit -m "..."
# BLOCKED: git commit / branch-create in the PRIMARY checkout, not an isolated worktree (#3230)...
# This still works — inside any linked worktree:
cd C:\code_2026\p24-infra\tmp\wt-1234
git commit -m "..."
# proceeds normallyWhat is NOT blocked (intentionally — these are read-only/navigational, needed even in the
primary checkout): git pull, git fetch, git status, git log, git worktree add itself.
Extension — plain branch switch is also blocked (#3319, applied 2026-07-08 same day)
The #3230 guard above only catches branch-create (checkout -b / switch -c). A plain
git checkout <existing-branch> / git switch <existing-branch> (no -b/-c) also moves HEAD
in the shared primary checkout and silently reparents whatever another concurrent session had
checked out there — confirmed 3× in two days during the original rollout. Both hooks now block
this too:
# This now fails in the primary checkout:
cd C:\code_2026\p24-infra
git checkout main
# BLOCKED: git checkout/switch to 'main' in the PRIMARY checkout, not an isolated worktree (#3319)...Still allowed (no HEAD change, or explicit file/path restore — never a branch switch):
git checkout -- <file>/git checkout <tree-ish> -- <file>(restores a file, doesn’t moveHEAD)git checkout <path>where the argument is a bare path, not a refgit pull,git fetch,git status,git log,git worktree add
So the only sanctioned way to change branches at all in the primary checkout is git worktree add
— every checkout/switch form that would move HEAD there is blocked, create or no.
Standard Workflow (unchanged, now technically enforced)
# 1. Create an isolated worktree for this issue
git worktree add tmp/wt-{issue} -b {branch-name} origin/main
# 2. Do ALL commit/branch work inside the worktree
cd tmp/wt-{issue}
# ... edits, git add, git commit — all fine here ...
git push -u origin {branch-name}
# 3. Open PR, review, merge as usual
# 4. Clean up — remove, then GUARANTEE the dir is gone (git may leave it behind), then prune
cd ../..
git worktree remove --force tmp/wt-{issue}
rm -rf tmp/wt-{issue} # leftover unregistered dir silently falls through to the parent repo — #6006
git worktree prune -vA worktree dir left behind after
git worktree remove(Windows file-lock, stale checkout) makes git silently operate on the parent repo’s current branch. Seeworktree-remove-fallthrough-guard.mdfor the root cause and the mandatory pre-writegit rev-parse --show-toplevelguard.
Limitations
-
This guard only protects sessions running through Claude Code’s hook system on this machine. It does not protect against: manual human
gitcommands run outside Claude Code, or other machines/CI runners operating on a clone of this repo. -
It cannot undo damage from before 2026-07-08 — only prevents new occurrences going forward.
-
Background
Agent()calls withisolation: "worktree"already get their own isolated worktree automatically (unrelated mechanism) — this hook guards the orchestrating session and anyAgent()calls made without that isolation flag that end up sharing the primary checkout. -
Confirmed harness-side gap — hook scripts are correct, the harness is not invoking them (#6442, 2026-08-19): the #3319 guard was live in both hooks yet a background
secret-manager-roleAgent()spawned withoutisolation: "worktree"still switched the shared primary checkout from a feature branch tomain(git reflogtimestamp correlated to the agent’s execution window; PR #6441 writeup). Follow-up diagnosis under #6442 isolated this to the Claude Code harness, not our hook code, on both tool surfaces:- PowerShell (
pre-bash-safety-windows.ps1): invoked directly with the exact configured command and$env:CLAUDE_TOOL_INPUT = '{"command":"git checkout -b … origin/main"}'→ correctly returned exit 1 and the fullBLOCKED … (issue #3230)message. But livegit checkout/git checkout -bPowerShell tool calls in the same session were not blocked — for a backgroundAgent()and the top-level orchestrating session’s own direct calls. - Bash (
pre-bash-safety.sh): invoked directly withCLAUDE_TOOL_INPUT='git checkout -b … origin/main'and='git checkout main'in a primary checkout (git-dir == git-common-dir) → correctly exits 1 with theBLOCKED … (#3230)/(#3319)message;git checkout -- <file>correctly allowed. Both hook scripts are therefore provably correct when handed the expectedCLAUDE_TOOL_INPUT. Anthropic’s own hooks documentation states PreToolUse hooks fire identically for subagent tool calls as for the main session’s — so these calls should have been blocked. The fault is the harness not invoking the PreToolUse hook for livegit checkouttool calls, contradicting that documentation. (The original 0-byte subagent transcript in the #6441 incident is consistent with this — no record of the exact evading command was persisted.)
Standing rule until Anthropic root-causes it — not a soft caveat: the PreToolUse hook is a best-effort backstop only; do not rely on it.
isolation: "worktree"is the only reliable enforcement mechanism and is required, not optional, for everyAgent()call whose role (secret-manager,sys-admin) runs git hygiene commands (git checkout,git pull) as routine setup. Spawn those agents withisolation: "worktree"every time.Escalation (human-action, #6442): verify with Anthropic support / the Claude Code issue tracker whether this is a known harness bug in the current Claude Code version — our hook code is provably correct, so a fix must come from the harness. A worker agent cannot file this; it stays a
human-actionitem on #6442.Re-verify the hook scripts are still correct (does not test harness invocation — that needs a live tool call in the affected session):
# Bash hook — run from a primary checkout (git-dir == git-common-dir); expect exit 1 + BLOCKED: CLAUDE_TOOL_INPUT='git checkout -b test/x origin/main' bash .claude/hooks/pre-bash-safety.sh; echo "exit=$?" CLAUDE_TOOL_INPUT='git checkout main' bash .claude/hooks/pre-bash-safety.sh; echo "exit=$?" CLAUDE_TOOL_INPUT='git checkout -- README.md' bash .claude/hooks/pre-bash-safety.sh; echo "exit=$?" # expect 0 (allowed)# PowerShell hook — from the primary checkout; expect exit 1 + BLOCKED: $env:CLAUDE_TOOL_INPUT = '{"command":"git checkout -b test/x origin/main"}' powershell -NoProfile -NonInteractive -File .claude\hooks\pre-bash-safety-windows.ps1; echo "exit=$LASTEXITCODE" - PowerShell (
Related
CLAUDE.md§Agent Workflow — Worktree Branch Isolation (the pre-existing documented policy this hook now enforces technically)concurrent-sops-write-worktree-race.md— the sibling filesystem-level race (#5242): a SOPS write is not agit commit, so the hook guard here never fires on it; that playbook adds a pre-flight guard for shared-file/SOPS writes- Issue #3230, #3319 (original hook creation), #6442 (harness-side non-invocation confirmed; both hook scripts proven correct), PR #6441 (first documented instance of the gap + playbook fix)
- Rescued branch example:
rescue/3161-test-net-v42(re-filed as issue #3233, PR #3234)