Windows: isolation-worktree safety check rejects valid worktree due to case-sensitive drive-letter comparison

Status Open
Reported on v2.1.258
Maintainer reply None cached
Activity 4 comments · opened Sep 2, 2026

Environment

  • OS: Windows 11 Pro, build 10.0.26200.9278
  • Editor: Cursor (VS Code fork), extension anthropic.claude-code 2.1.258 (win32-x64)
  • Git: git version 2.54.0.windows.1 (Git for Windows / MSYS)
  • Repo opened via a .code-workspace file on a local NTFS drive (D:\...)

What happened

Running a workflow that uses agent(..., {isolation: 'worktree'}) (also reproduced via the orchestrate-spec pattern from the docs) fails on the very first isolated-worktree agent with:

Refusing to use d:\git_checkout\JTLDatevBridge\.claude\worktrees\wf_7ead4c95-9ec-4 as an isolation worktree:
git resolves its working tree to D:/git_checkout/JTLDatevBridge/.claude/worktrees/wf_7ead4c95-9ec-4
(a core.worktree redirect, or a checkout discovered above it), so commands run there would write outside
the worktree. Remove the redirect, restore the worktree's own .git, or recreate the worktree, then retry.

The worktree itself is completely healthy — manually verified:

  • .git file in the worktree correctly points to D:/git_checkout/JTLDatevBridge/.git/worktrees/wf_7ead4c95-9ec-4
  • commondir is ../..
  • git -C <worktree> rev-parse --show-toplevel returns the worktree's own path
  • git -C <worktree> status works normally, no redirect, no core.worktree override anywhere (checked local/global/system config)

Root cause

This is a case-only string mismatch between two representations of the same path, not an actual worktree/redirect problem:

  • The path the safety check is refusing (d:\git_checkout\JTLDatevBridge\...) — lowercase drive letter — comes from the workspace context. VS Code (and forks like Cursor) always canonicalize file:// URIs with a lowercase drive letter. Confirmed by inspecting the stored workspace URI: file:///d%3A/git_checkout/JTLDatevBridge/JTLDatevBridge.code-workspace.
  • The path git reports (D:/git_checkout/JTLDatevBridge/...) — uppercase drive letter — is git-for-windows'/MSYS's own normalization. Confirmed via pwd -W and git rev-parse --show-toplevel, both return D:/....

The isolation-worktree safety check appears to compare these two path strings with a case-sensitive equality (or similar) check instead of normalizing both sides (e.g. lowercasing both, or resolving both through the same realpath/native path API) before comparing. On NTFS, drive letters and paths are case-insensitive, so this mismatch is spurious — but it will reproduce on every Windows machine where VS Code/Cursor's default lowercase-URI convention meets git-for-windows' uppercase normalization, i.e. essentially all default Windows + VS Code/Cursor + git-for-windows setups.

Impact

isolation: 'worktree' (used by agent() calls and the general worktree-isolation pattern used by e.g. orchestrate-spec-style workflows) is currently unusable on native Windows — every isolated-worktree agent call fails immediately at worktree-safety-check time, before any actual work happens.

Suggested fix

Normalize both sides of the comparison before checking — e.g. lowercase the drive letter on win32 (or use fs.realpathSync.native) for both the expected worktree path and the path git reports, rather than a raw string/=== comparison.

Where I looked

I could not find any settings-schema override (worktree.* in claude-code-settings.schema.json) or env var to opt out of this specific check — worktree.location exists but per its own description is "not read yet" by the CLI/agent-isolation path. The check itself appears to live in the compiled resources/native-binary/claude.exe, not in extension.js, so I could not patch around it locally.

Happy to provide the full workflow script / journal.jsonl from the failing run if useful.

View original on GitHub ↗

3 Comments

jsboige · 1 month ago

Bisection datapoint: this reproduces deterministically on current stable 2.1.236 (Windows 11, git 2.55.0.windows.4).

Method

  1. Scratch repo containing two pre-existing worktrees under .claude/worktrees/.
  2. Start a headless session (claude -p) with the process working directory set to the lowercase-drive form of the repo path (c:\Users\...\repo) — the form was verified preserved in the child's process.cwd().
  3. Invoke the Task tool with isolation: "worktree".

Result — verbatim incident signature

Refusing to use c:\Users\...\repo\.claude\worktrees\agent-a75f651045fa09cc5 as an isolation worktree:
git resolves its working tree to C:/Users/.../repo/.claude/worktrees/agent-a75f651045fa09cc5
(a core.worktree redirect, or a checkout discovered above it), so commands run there would write outside the worktree.
Remove the redirect, restore the worktree's own .git, or recreate the worktree, then retry.

The refused path and the resolved path denote the same location — they differ only in drive-letter case and separators. For this instance the parenthetical guesses are directly falsifiable:

  • the worktree's .git is a standard gitfile: gitdir: C:/.../repo/.git/worktrees/agent-a75f651045fa09cc5 (same repo)
  • the admin gitdir back-pointer is consistent with it
  • core.worktree is unset
  • no git repository exists above the repo root

So the validator appears to compare the path form it holds (lowercase drive, backslashes) against git rev-parse --show-toplevel output (uppercase drive, forward slashes) with strict string equality on win32.

Additional observations on 2.1.236

  • No cleanup on the failure path: each refusal leaves the just-created worktree locked — locked claude agent agent-<id> (pid <session pid>) persists in git worktree list after the session exits.
  • isolation: "remote" silently stages a local worktree: it is an accepted enum value on this build, and it went through the same local provisioning — identical refusal with a second agent-<id> — without any error naming the mode actually used.

Version window (for bisecting): 2.1.41 has no isolation parameter at all; 2.1.86 did not reproduce under the same reconstructed trigger (there, the agent worktree path appeared to be derived from the resolved main working tree rather than the raw cwd string); 2.1.236 reproduces. The 2.1.238 / 2.1.257 / 2.1.259 changelog entries about worktree-isolation refusals concern Bash-command gating inside isolated sessions, not this pre-flight provisioning validation.

Workaround: after a refusal, remove the leftovers — git worktree unlock <path> + git worktree remove --force <path> + git worktree prune.

jsboige · 1 month ago

Bisection datapoint: 2.1.141 does NOT reproduce the reconstructed trigger (Windows 11).

Method: spawned claude -p (2.1.141) via node spawnSync with literal cwd d:\Dev\roo-extensions — lowercase-drive form verified preserved in the child's process.cwd() — then invoked the Task tool with isolation: "worktree".

Result: the worktree provisioned and validated with no refusal; the sub-agent ran inside .claude/worktrees/agent-acf9e64794c902166, and after completion both the worktree and its worktree-agent-* branch were fully cleaned up (no locked residue in git worktree list). An uppercase-drive cwd session on the same build also passes, as does a direct in-session Task invocation.

So the regression window narrows to (2.1.141, 2.1.236] — the previously known unaffected lower bound was 2.1.86.

jsboige · 26 days ago

Bisection datapoint: still reproduces on 2.1.277 (dist-tag latest, Windows 11, git 2.55.0.windows.4, 2026-09-18).

Method — identical to the previous datapoints: scratch repo with two pre-existing worktrees under .claude/worktrees/, headless claude -p spawned with the lowercase-drive form of the repo path as process working directory (form verified preserved in the child's process.cwd()), then a Task call with isolation: "worktree".

Results on 2.1.277

  1. Refusal fires verbatim — refused path c:\Users\…\agent-ab3b6c2b3ec1747e6 vs resolved C:/Users/…/agent-ab3b6c2b3ec1747e6: same location, drive-letter case and separators only. The worktree's .git is a standard gitfile pointing into the same repo's .git/worktrees/…, no core.worktree set — so the parenthetical guesses in the message are falsified for this instance, as in earlier reports.
  2. The refusal was terminal in this run — the Task call returned the error and the subagent never ran (run_in_background: false). For context, on 2.1.274 we observed the harness retry with a normalized cwd and the agent then starting (reported by another machine in our fleet on 2026-09-17); whether that retry is version-gated or flag-gated is not resolved from these two observations.
  3. Orphan locks on every refused call — each attempt left one locked claude agent agent-… (pid …) entry with a dead pid behind (3 calls → 3 locked orphans, all pids dead at inspection). No cleanup on the failure path.
  4. isolation: "remote" still silently provisions a LOCAL worktree on this build — same refusal for a locally created agent-* worktree, with no error naming the mode actually used.

Bisection so far: 2.1.86 no-repro · 2.1.141 no-repro · 2.1.236 repro (terminal) · 2.1.269 repro (terminal) · 2.1.274 repro→retry-success (orphan still left) · 2.1.277 repro (terminal). The 2.1.275–2.1.277 CHANGELOG has no entry matching this validator (the worktree-related fixes shipped in 2.1.274 concern Bash-command confinement and worktree removal safety, not pre-flight provisioning validation).

Showing cached comments. Read the full discussion on GitHub ↗