What the isolation actually covers
- A worktree gets its own working directory and its own index. That is the boundary.
- The stash stack is shared. Every worktree sees every other worktree's stashes, and
git stash poptakes whichever is on top. MERGE_AUTOSTASHis per-worktree, but only because it is spelled in capitals. Git's own header file forecloses adding to that list.- One
git stash push -utakes a worktree from two dirty lines to zero, with nothing committed and nothing pushed. Cleanup rules read that as finished. - Two documented primitives give you a real per-worktree stash.
git stash exportis not one of them below git 2.51.
A git worktree gives you a second working directory and a second index. That is the whole of what it isolates. The object store is shared, .git/config is shared, and the stash stack is shared, which means two Claude Code sessions working in two worktrees can reach into each other in ways neither one reports.
I run a lot of sessions at once, so I went looking for the seams. Then I audited what had piled up: 58 worktrees in our Angular repo and 15 in the Laravel one, created ad hoc over months by sessions that each solved the setup problem their own way. 40 of those 73 turned out to be safely removable. Sorting the obvious ones was easy enough. The hard question was which of the rest were only pretending to be finished. Everything below was measured on git 2.50.1 (Apple Git-155) against a five-checkout fixture: one main clone plus four linked worktrees on a real bare remote. I have quoted git’s own source at tag v2.50.1 rather than master, because these functions have been edited recently and a line number from master would rot.
Related reading
This post is the start of the pipeline. A merge queue is theatre without a test oracle is the far end, where those branches come back together and a green tick stops meaning anything. Subagent versus parallel agent versus skill is the prior question, whether you need a separate checkout at all.
Why does a shared stash lose work?
Git decides where a ref lives by its name, and for anything under refs/ the rule is a whitelist of exactly three hierarchies. In refs.c, the whole function is four lines:
int is_per_worktree_ref(const char *refname)
{
return starts_with(refname, "refs/worktree/") ||
starts_with(refname, "refs/bisect/") ||
starts_with(refname, "refs/rewritten/");
}refs/stash is not in that list, so it resolves to the shared directory. I checked where the file physically lands from two different worktrees, and both answered main/.git/refs/stash. One file, one stack, five checkouts.
The REFS section of git-worktree(1) states the rule without ever naming the thing that bites you:
In general, all pseudo refs are per-worktree and all refs starting with
refs/are shared. […] There are exceptions, however: refs insiderefs/bisect,refs/worktreeandrefs/rewrittenare not shared.
You have to finish that sentence yourself. refs/stash starts with refs/, it is not one of the three listed exceptions, so it is shared. That is the entire derivation, and it is also why nobody spots this in advance: the general rule is stated accurately, the exceptions are listed accurately, and the one ref that every agent reaches for under pressure is left as an exercise for the reader. The manual does mention the stash elsewhere, in EXAMPLES, where it suggests using a worktree instead of stashing to avoid disarray in your tree. It never doubles back to say the stash you were avoiding is shared anyway.
Which produces this, after two worktrees each stashed their own local edit:
$ git -C ../wtA stash list
stash@{0}: On wtb: B work
stash@{1}: On wta: A workRead that carefully. From inside worktree A, the entry at the top of the stack is worktree B’s work. An agent that finishes a task, runs git stash pop to pick up where it thinks it left off, and carries on will apply a sibling’s half-finished edit to its own tree. I reproduced exactly that: a git stash pop in worktree A pulled in a third worktree’s stash and dropped its untracked scratch file into A’s directory. No warning, no conflict, exit code zero.
Here is the catch, and it is the part I had not seen written down anywhere. Stashing moves work sideways, yes. It also makes the worktree that did the stashing look disposable.
MERGE_AUTOSTASH survives by spelling, not by design
There is one autostash git does keep per-worktree, and the reason is worth knowing before you build anything on top of it.
MERGE_AUTOSTASH (what git merge --autostash writes) lands in main/.git/worktrees/<id>/MERGE_AUTOSTASH, properly isolated. I verified that from two worktrees and got two different paths. But it does not get there by being an autostash. It gets there by being a root ref, and root refs have a syntax rule:
static int is_root_ref_syntax(const char *refname)
{
const char *c;
for (c = refname; *c; c++) {
if (!isupper(*c) && *c != '-' && *c != '_')
return 0;
}
return 1;
}Every character has to be uppercase, a hyphen, or an underscore. A slash disqualifies you, so nothing under refs/ can ever be a root ref. MERGE_AUTOSTASH clears that bar and then appears in a hardcoded irregular_root_refs[] array alongside HEAD and AUTO_MERGE. refs/stash fails on the first character it hits.
Two things I noticed while reading around this, and together they tell you how much attention the area gets. First, refs.h documents the syntax rule as “all-uppercase or underscores” and never mentions the hyphen the code plainly accepts, so git’s own header and its own implementation disagree about what a root ref may be called. Second, and this is the part that matters, the same header documents the irregular list itself in language that shuts the door:
There is a special set of irregular root refs that exist due to historic reasons, only. This list shall not be expanded in the future
So the asymmetry is not a considered decision about which kinds of stashed work deserve isolation. MERGE_AUTOSTASH is grandfathered in, git’s own documentation says the grandfathering stops there, and the obvious fix of adding refs/stash to that array is closed off by policy in the header. I flip-flopped on whether to call this a bug. It reads more like a naming convention that accidentally produced a safety property, and then got frozen.
Stashing makes your worktree look disposable
This is the bit that actually costs you work, and I have not found it claimed anywhere.
Take the most ordinary state an agent can be in: partway through a task, some files edited, one new file written, nothing committed yet. Then it does the defensive thing and stashes. Here is that worktree before and after a single git stash push -u, measured against a real remote so the counts mean what they say:
=== agent mid-task in its worktree, nothing committed yet ===
uncommitted+untracked lines : 2
commits not on any remote : 0
reported by branch --merged : 1
=== same worktree, after ONE 'git stash push -u' ===
uncommitted+untracked lines : 0
commits not on any remote : 0
reported by branch --merged : 1Those three numbers are the predicates every worktree cleanup rule I have written or read checks: is it dirty, does it hold unpushed commits, is its branch merged. After the stash, all three say the worktree is finished and its branch is fully merged. It is neither. The work exists in exactly one place, a shared-stash entry labelled On agent-task:, pointing at a branch that a sweep is now free to delete.
One caveat, because I got this wrong in my own notes first and the correction matters. Stashing does not zero unpushed commits. I wrote that down, then measured it, and a worktree with real commits still reported them afterwards. So a worktree that committed something stays visible. The window is narrower than I first claimed, and it is also the most common state an agent occupies: mid-task, nothing committed.
If your sweep checks dirtiness first and stops there, that is exactly the state you delete.
The failure mode has been reported, though not this mechanism. claude-code#55724 is titled “parallel agents lose work due to git lock contention + auto-cleanup” and describes 13 agents where 5 committed and the rest lost work while cleanup removed their worktrees. On the Copilot side, copilot-cli#1725 is open with the title “Copilot CLI uses global git stash in worktrees”, and the maintainer’s reply there is the sentence I keep coming back to:
I don’t believe we’re explicitly using git stash anywhere deterministically. Likely, this is just the LLM generating a call to
git stashvia the bash tool.
Nobody wrote the dangerous line. The model reached for it, because stashing is what you do when a working tree is in your way.
Give each worktree its own wip ref
The fix needs no tooling. refs/worktree/ is on that whitelist, and git stash has a plumbing half that most people never touch:
sha=$(git stash create "wip")
git update-ref refs/worktree/wip "$sha"
# later, in this worktree only:
git stash apply refs/worktree/wipMeasured, that stored at main/.git/worktrees/wt-fix/refs/worktree/wip. It stayed invisible to git stash list, and both sibling checkouts answered “not found” when asked to resolve refs/worktree/wip. Applying it restored the change. Two documented primitives, a real per-worktree stash, no shared state.
Worth knowing that git stash’s own manual page describes create and store as “intended to be useful for scripts” and adds that each “is probably not the command you want to use”. Fair enough as general advice. That same page never mentions linked worktrees or concurrent access, and never warns you that refs/stash is shared, which is exactly the situation where you do want the plumbing.
Do not reach for git stash export --to-ref instead. It arrived in git 2.51.0, and on the git that ships with macOS today it fails outright:
$ git stash export --to-ref refs/worktree/exported
error: unknown option `to-ref'What the docs leave out
Claude Code’s worktrees documentation has a section headed “What worktrees share with the main checkout”. It names three: the repository’s .git directory, project-scope plugins, and saved permission approvals. It closes the list off with “All three apply whether you create the worktree with --worktree, with git worktree add, or through the desktop app”.
I grepped that page. “Stash” appears zero times on it. So does index.lock, and so does config.lock. What makes that a near miss rather than a plain gap is the first bullet, which states the mechanism outright: “git commands in a worktree write to the main repository’s shared .git directory”. That sentence is the cause of everything above. The stash, the locks, and the config all follow from it, and the page stops one inference short of saying so. Claude Code’s shipped system prompt does carry a shared-stash warning, so the product knows even where the page is quiet.
The lock story is the same shape and has been filed twice, in claude-code#47266 and claude-code#34645, both about parallel worktree agents failing on git config lock contention. What neither issue spells out is when it bites. git worktree add writes upstream tracking config into the shared .git/config, so the contention happens at creation, before your agent has run a single command. If you fan out five worktrees at once, that is the collision, and GIT_OPTIONAL_LOCKS=0 takes the edge off the read-side pressure.
None of this makes worktrees a nightmare to use. I still cobble them together for every parallel session and I would not go back. It does mean the mental model people carry, that a worktree is an isolated copy, is a bit too generous. It isolates files. Shared refs, shared config, and one shared stash stack are all still there, and the stash is the one that quietly turns “I saved my work” into “my worktree looks finished”.
The other half of this problem lives at the far end, when all those branches come back together. I wrote separately about why a merge queue is theatre without a trustworthy test oracle, which is where the parallel-agent story actually gets expensive. If you are still deciding whether you need OS-level worktrees at all, the difference between a subagent and a separate session is the thing to settle first.





