[Desktop] WorktreePool runs `git checkout` in deregistered worktree directories — git walks up and mutates the parent repo (detaches HEAD / moves it onto session branches)

Status Fixed / completed
Reported on v2.1.215
Maintainer reply None cached
Activity 2 comments · opened Jul 21, 2026 · closed Aug 25, 2026

Environment

  • Claude Desktop (Windows): appVersion 1.21459.3, bundled Claude Code binary 2.1.215
  • OS: Windows 11 Pro 10.0.26200
  • git: 2.53.0.windows.1
  • Repo layout: standard desktop-app layout — session worktrees created at <repo>\.claude\worktrees\<name>, i.e. nested inside the main repository's working tree
  • App log: %APPDATA%\Claude\logs\main.log (excerpts below; full log available on request)

Summary

The desktop app's WorktreePool executes git commands (release: git checkout --detach; lease/rebind: git checkout -b claude/<branch>) against a worktree directory path without verifying that the directory is still a registered worktree. On Windows, worktree cleanup frequently half-fails (file locks from orphaned child processes keep the directory alive after the worktree is deregistered), leaving a "shell" directory with no .git file under <repo>\.claude\worktrees\. When the pool later runs git inside such a shell, git's upward repository discovery walks out of the shell and resolves to the parent repo's main working tree — so the pool's checkout executes against the user's main repo: detaching its HEAD, or moving it onto a claude/* session branch.

This fired four times in one afternoon (2026-07-20) on my machine, each with an exact-second match between the pool's log line and the main repo's reflog entry. Consequences included: my automation committing to a detached HEAD while its git push silently no-opped, and one Claude session transparently operating on the main repo while believing it was in an isolated worktree (worktree isolation silently broken).

Related issues (same root cause, different triggers)

This appears to be the WorktreePool-side sibling of an existing cluster around stale .claude/worktrees/ directories resolving to the parent repo: #78980 (EnterWorktree — agent commits land on the parent's branch), #79234 and #76590 (session start / chip spawn runs checkout -b in the parent repo on Windows), #77304 (unregistered stub worktree — cross-session branch collision), #74089 (auto-cleanup mid-session — fall-through). What I believe is new in this report: the pool's release step (git checkout --detach) firing in these directories — leaving the parent repo detached rather than on a session branch (3 of the 4 firings below) — plus exact-second app-log↔reflog correlation for every firing, and evidence that a fully deleted directory is harmless while a lingering shell re-fires deterministically at every pool boundary. A pool-level validate-before-git fix would close this whole class.

Evidence: four exact-second log ↔ reflog matches

Main repo: C:\dev\teg-os (branch main). All times local, 2026-07-20. Reflog entries are from the main repo, not any worktree.

| # | main.log (WorktreePool) | main repo reflog | Effect on main repo |
|---|---|---|---|
| 1 | 16:55:46 Cleaning up worktree objective-elbakyan-39fac7 for session local_146f8030…16:55:48 [WorktreePool] Released worktree objective-elbakyan-39fac7 to pool | 97de1dd HEAD@{16:55:48}: checkout: moving from main to HEAD | Detached at the tip of main |
| 2 | 17:09:32 [rebindWorktree] Rebound C:\dev\teg-os\.claude\worktrees\objective-elbakyan-39fac7 (was leased by none) to local_40283624… on branch claude/trusting-wozniak-5a94f5 | b5b722c HEAD@{17:09:32}: checkout: moving from main to claude/trusting-wozniak-5a94f5 | Main repo moved onto the session branch; that session then operated directly on the main repo (its git status showed ../../../… paths) |
| 3 | 17:19:31 Cleaning up worktree zealous-bell-fb6a1c for session local_4fb8927f…17:19:32 [WorktreePool] Released worktree zealous-bell-fb6a1c to pool | a3c6206 HEAD@{17:19:31}: checkout: moving from claude/trusting-wozniak-5a94f5 to HEAD | Detached again (off the branch firing #2 had put it on) |
| 4 | 17:34:26 [WorktreePool] Released worktree unruffled-poitras-1215b7 to pool (was leased by local_f05b7569…) | 8bdf86c HEAD@{17:34:26}: checkout: moving from main to HEAD | Detached again |

All three directories involved (objective-elbakyan-39fac7, zealous-bell-fb6a1c, unruffled-poitras-1215b7) were shells at firing time: present on disk, no .git file, absent from git worktree list.

Two corroborating details from the same log:

  • 17:36:09 [WorktreePool] Pruning orphaned store entry zealous-bell-fb6a1c (directory gone) — the pool already handles directory gone, but not directory present yet deregistered, which is exactly the state Windows lock-failures produce.
  • 17:39:31 [WorktreePool] Released worktree objective-elbakyan-39fac7 … after I had manually deleted that directory produced no reflog entry — with no cwd, git can't run, so full deletion is safe while a lingering shell is not.

Eliminated as causes (checked during the incident investigation): every git-touching script and hook in the repo (none contain checkout --detach), obsidian-git (disabled), and the transcripts of all four concurrent Claude sessions in the window (zero git checkouts). Note git checkout HEAD is a no-op that writes nothing to the reflog — checkout: moving from main to HEAD is produced only by an explicit --detach, which pins this on the pool's release step rather than any checkout $VAR-with-empty-var bug.

Root cause

  1. The pool intentionally parks pooled worktrees detached (git checkout --detach on release) and checks out claude/<branch> on lease/rebind — legitimate for real worktrees. (A healthy pooled worktree on my machine sits at detached HEAD in git worktree list, consistent with this.)
  2. These commands are run by directory path, with no verification that the path is still a registered worktree.
  3. On Windows, worktree cleanup half-fails routinely: the worktree gets deregistered (or its .git file deleted) while the directory itself survives because orphaned child processes or shell CWDs hold locks. Result: a shell directory under <repo>\.claude\worktrees\.
  4. Git run inside a shell finds no .git there and discovers upward — and because the app nests worktrees inside the main repo's working tree, discovery always lands on the user's main repo. The pool's checkout then mutates it.

(2)–(4) are inferred from the log↔reflog correlation above; I can't see the app source. The inference is deterministic and reproducible, though — see below.

Minimal reproduction (git-level, no app needed)

git init C:\tmp\repro; cd C:\tmp\repro
git commit --allow-empty -m init
git worktree add .claude\worktrees\wt-a
# Simulate the Windows half-failed cleanup: deregistered, directory left behind
del .claude\worktrees\wt-a\.git
git worktree prune                      # wt-a no longer in `git worktree list`; dir remains

# What the pool's RELEASE step effectively runs:
git -C .claude\worktrees\wt-a checkout --detach
git status                              # -> "HEAD detached at <sha>"  <- MAIN repo mutated

# What the LEASE/REBIND step effectively runs (second flavor):
git checkout main
git -C .claude\worktrees\wt-a checkout -b claude/some-session
git status                              # -> main repo now on claude/some-session

Impact

  • Silent main-repo mutation. The user's primary working tree is detached or moved onto a claude/* branch with no error, no prompt, and no UI indication. Downstream tooling that assumes "on main" misbehaves quietly — in my case a sync script kept committing onto a detached HEAD and its bare git push silently no-opped (origin/main stopped advancing while the script reported success).
  • Worktree isolation silently broken. In firing #2, the checkout fell through during lease, so the new session's branch was created on the main repo and the session ran there directly — everything it committed and touched happened in the main tree the user was also using.
  • Deterministic once a shell exists: it re-fires at every lease/release boundary that touches the shell (4× in ~40 minutes here) until the directory is fully deleted.

Suggested fix

  1. Verify before operating. Before running any git command in a pool directory, assert it is still a registered worktree: the directory contains a .git file and git -C <dir> rev-parse --show-toplevel resolves to <dir> itself (or the path appears in git worktree list --porcelain for the parent repo). On mismatch, treat it like the existing "directory gone" case: prune the store entry, attempt deletion, and never run repo-mutating git there.
  2. Defense in depth. Run pool git commands with the repository pinned explicitly (--git-dir/--work-tree), or with GIT_CEILING_DIRECTORIES=<repo>\.claude\worktrees in the environment, so upward discovery can never escape into the parent repo even if the check in (1) races.
  3. Harden Windows cleanup. The shells are minted by cleanup that deregisters but fails to delete (locked by orphaned child processes / shell CWDs). Retrying deletion later — the pool already detects directory gone — would drain the shell population instead of leaving landmines.

Workaround (for anyone hitting this)

Delete leftover directories under <repo>\.claude\worktrees\ that have no .git file and don't appear in git worktree list (close their conversation tabs first — the app pins them as CWD). Check your repo afterwards: if git status says detached or shows an unexpected claude/* branch, git push origin HEAD:main (fast-forward only) then git checkout -B main HEAD recovers without history rewrite.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗