[BUG] Windows: isolation-worktree guard compares paths case-sensitively, so IDE-started worktree sessions cannot be resumed (regression in 2.1.222)
What happened
Since 2.1.222, a session that entered an isolation worktree from the VS Code /
Cursor extension can no longer be resumed on Windows. The CLI exits 1 and the panel
shows Error: Claude Code process exited with code 1 followed by MCP shutdown
logging - the actual reason is cut off the head of the stderr snippet:
[ERROR] [worktree] declining to resume into c:\...\.claude\worktrees\x (work-tree-elsewhere):
Refusing to use c:\...\.claude\worktrees\x as an isolation worktree:
git resolves its working tree to C:/.../.claude/worktrees/x
(a core.worktree redirect, or a checkout discovered above it), so commands run there
would write outside the worktree.
Error: cannot resume into worktree ... This session was not started.
Both paths are the same directory. They differ only in the case of the drive letter.
Root cause
The isolation-worktree adoption guard compares the pinned path againstgit rev-parse --show-toplevel case-sensitively, on a platform where paths are
case-insensitive.
In the 2.1.226 win32-x64 binary the guard is
let u = Fye(c.topLevel, e);
if (u === "distinct") return { ok:!1, reason:"work-tree-elsewhere", ... }
and Fye resolves to the case-sensitive arm of a shared helper, while the sibling
comparator used for the containment checks right next to it takes the case-insensitive
arm:
function gyr(e,t,r){ let n = r ? e.toLowerCase() : e, o = r ? t.toLowerCase() : t; if(n===o) return !0; ... }
function mBt(e,t){ return gyr(e,t,!0) } // case-insensitive -> IJe -> uun / cun
function n6u(e,t){ return gyr(e,t,!1) } // case-SENSITIVE -> yyr -> Fye (this guard)
The win32 canonicaliser normalises separators (/ -> \) but never touches letter
case, so c:\... and C:\... survive as distinct strings and the guard reads them as
two different directories.
The two spellings arise naturally and neither side is wrong:
- The pin comes from the IDE. Every workspace entry VS Code / Cursor store is
file:///c%3A/..., URI.fsPath renders it as c:\..., and Node passes a spawn
cwd through verbatim (process.cwd() in the child returns exactly c:\...), so
the CLI records "worktreePath":"c:\\..." in the session's worktree-state.
--show-toplevelreturnsC:/....
Reproduction
Clean repo, real worktree, real session. Only the drive-letter case in the recordedworktree-state changes between the two runs.
git init repo; cd repo
git commit --allow-empty -m init
git worktree add .claude/worktrees/wt -b wtbranch
claude -p "say ok" --output-format json # run from .claude\worktrees\wt, note the session id
Then set the session's worktree-state record to each spelling and resume from the
worktree:
| recorded worktreePath | result |
| ------------------------------------------- | ------------------ |
| C:\...\worktrees\wt (what the OS reports) | resumes, exit 0 |
| c:\...\worktrees\wt (only the drive letter)| refused, exit 1 |
| C:/.../worktrees/wt (forward slashes) | resumes, exit 0 |
| C:\appdata\... (one segment miscased) | refused, exit 1 |
So separators are normalised and are not the trigger, and there is no drive-letter
special case - it is a plain case-sensitive comparison of the whole path.
Same lower-cased input across versions:
| CLI | result |
| ------- | --------------- |
| 2.1.221 | resumes, exit 0 |
| 2.1.222 | refused, exit 1 |
| 2.1.226 | refused, exit 1 |
The strings work-tree-elsewhere, as an isolation worktree and cannot resume into are absent from the 2.1.220 and 2.1.221
worktreewin32-x64 binaries and present
from 2.1.222 - matching that release's changelog line, *"isolation now applies to file
edits and Bash in every session type"*.
Expected
c:\project and C:\project are the same directory on Windows and the guard should
treat them as such - i.e. Fye should use the case-insensitive comparator on win32,
like the containment checks beside it already do.
Impact
Every worktree session ever started from the IDE on Windows is unresumable, not just
new ones: the bad spelling is already persisted in the transcripts. On this machine
that was 65 of 98 sessions holding a worktree binding.
Environment
- Claude Code 2.1.226 (CLI and the bundled VS Code extension), Windows 11 26200
- Reproduced with the
win32-x64binaries of 2.1.221 / 2.1.222 / 2.1.226 from OpenVSX - Surfaces: Cursor and VS Code extensions. Terminal sessions are unaffected, because
a shell supplies the canonical C:\ spelling.
Workaround
Upper-case the drive letter in the worktreePath / originalCwd /preEnterOriginalCwd fields of the {"type":"worktree-state"} records in~/.claude/projects/**/<session>.jsonl, and launch the CLI with a canonical cwd.
3 Comments
Independent confirmation on another Windows 11 machine (VS Code extension 2.1.226), plus a second symptom of the same guard that this report doesn't cover: creation, not just resume.
EnterWorktree({name})hits the same comparator, and fails openCalling
EnterWorktreewithname:(nopath:) fails every time in an IDE-started session:Same two spellings, same directory. The candidate path is built by concatenating onto the launch cwd — which, exactly as you describe, the IDE supplies as
c:\....Measured on the rejected worktree, to rule out the stated reason:
git config --get core.worktree→ empty.gitfile →gitdir: C:/repo/.git/worktrees/foo(correct)git rev-parse --show-toplevel→C:/repo/.claude/worktrees/foo(the worktree itself)And
git rev-parse --show-toplevelreturns the uppercase spelling regardless of the case you pass in (-C c:\repoand-C C:\repoboth returnC:/repo), so on this path the mismatch is unconditional.Why this one is worse than the resume case: it fails open. The refusal happens after
git worktree addsucceeds, so the session simply keeps running in the main checkout with no isolation. For anyone running several sessions against one repo — which is what worktree isolation is for — that silently reintroduces exactly the cross-session clobbering the feature prevents. The resume failure at least surfaces asexit 1.Every failed attempt leaks a locked worktree plus its branch
The worktree is created and locked before the guard rejects it, and nothing releases it:
The lock names the PID of the session that just failed, so it isn't recoverable by the retry either — these accumulate one per attempt. Cleanup is
git worktree unlock <path>→git worktree remove <path>→git branch -D worktree-<n>→git worktree prune.Workaround for the creation path
Unlike the resume case, this one needs no jsonl surgery: create the worktree yourself and enter it by path with a canonical drive letter.
That works because it hands the guard the same spelling git will return. It's been reliable across four uses here.
Two small data points for your analysis
~/.claude/projectson this machine has accumulated both spellings of the same project over months (c--Users-...andC--Users-...), depending on how each session was launched. The lowercase root key dates back to June, i.e. the bad spelling long predates 2.1.222 — it was simply harmless until this guard started comparing it.Get-Process claude | Select Path→c:\Users\...\.vscode\extensions\anthropic.claude-code-2.1.226-win32-x64\resources\native-binary\claude.exe.So the fix you propose (
Fyetaking the case-insensitive arm on win32) would close both symptoms at once.Independent confirmation from a third entry point: Remote Control spawn mode (
claude rc, "worktree" spawn choice, CLI not IDE).Repro:
Same root cause confirmed independently, via the actual filesystem/git state after a failure:
git worktree list(from the main checkout) shows the rejected worktree correctly registered andlocked— git's own bookkeeping is fine..gitfile, its.git/worktrees/<name>/gitdirback-reference, and.git/worktrees/<name>/config.worktreeare all intact; nocore.worktreeanywhere (checked the shared config and every per-worktreeconfig.worktree— this repo hasextensions.worktreeConfig = true).git rev-parse --show-toplevelrun against the rejected worktree resolves correctly, and returns the canonical uppercase-drive spelling (C:/Claude/...) regardless of whether it's invoked via the lower- or upper-case path — matching @salisjuanjose's measurement exactly.C:\claude\vn-market-data-v2(lowercase c, typed by hand without tab-completion); the real on-disk casing isC:\Claude\vn-market-data-v2. That's the entire mismatch — nothing on disk or in git is actually broken.Two more data points from this entry point:
bridge-cse_<session-id>worktrees accumulated over ~2 hours of automatic retries in oneclaude rcsession, one per failed spawn attempt, each still holding itslockedfile naming the (long-idle) host pid. Cleanup needed manualgit worktree unlock+remove --forceper orphan — the retry loop never releases them itself.EnterWorktree-in-IDE case reported above, this surface does not appear to fail open: theclaude rcTUI's capacity counter stayed at 0/32 across every failed retry, i.e. no unisolated fallback session seems to actually run in the main checkout here. Worth the team confirming, but from the outside this entry point looks fail-closed (blocks spawn) rather than fail-open (silent unisolated session) — a useful contrast for prioritizing the fix.Three independent trigger surfaces now (resume,
EnterWorktreecreate,claude rcspawn create) all reducing to the same win32 case-sensitive comparator. +1 for the case-insensitive arm on win32 fixing all three at once.Fourth entry point hitting the same guard, plus a filesystem-level workaround that doesn't require touching session storage.
Environment: Claude Code 2.1.251, Windows 11, git 2.55.0.windows.3, terminal/CLI session (not VS Code/Cursor extension, not
claude rc).Repro (a fourth code path — top-level
Agent()tool call withisolation="worktree", not resume, notEnterWorktree, not Remote Control spawn):Root cause matches the analysis already on this thread: the session's tracked cwd was
C:\repos\...(lowercase parent dir) while the actual NTFS entry for the parent directory was stored asC:\Repos\...(uppercase) — same directory on a case-insensitive filesystem, but the guard's case-sensitive string compare againstgit rev-parse --show-toplevel's canonicalized output rejects it.Workaround that fixed it, without editing any session storage: renamed the mismatched parent directory on disk to match the casing the harness already had tracked (
C:\Repos→C:\repos). After the rename,git rev-parse --show-toplevelreturns the same casing the session already expects, andisolation="worktree"dispatch succeeds and cleans up correctly on both a no-op test run and real work afterward. This is a decent stopgap for anyone hitting this via a plain terminal session (not IDE-resume) where the documented JSONL-editing workaround doesn't obviously apply — but it only works if the mismatched directory isn't shared with other active projects/sessions, since it's a real filesystem rename with real blast radius.Given four independent entry points now confirmed (resume,
EnterWorktreecreate, Remote Control spawn, top-levelAgent(isolation="worktree")), this looks like it's the single shared comparator (Fye/gyr(e,t,false)per the analysis above) used everywhere the harness validates a worktree's resolved toplevel against its tracked path — not a narrow IDE-resume-only bug. Given the scope, a one-line fix flipping that comparator to case-insensitive on Windows (matching the sibling containment check that already does this correctly) seems low-risk and would close all four reports at once.