`/exit` hangs forever on "Cleaning up worktree…" when `claude --worktree` was launched from inside the worktree

Open 💬 0 comments Opened Jul 3, 2026 by fistral

Environment

  • Claude Code 2.1.191 (native binary, Homebrew cask, darwin arm64)
  • macOS (Darwin 25.5.0)
  • WorktreeCreate / WorktreeRemove hooks configured in ~/.claude/settings.json that relocate worktrees outside the repository (hook-based worktrees)

Summary

If a claude --worktree <name> session is launched from inside the worktree itself (so the session's recorded original cwd is the worktree), then at /exit the automatic cleanup succeeds — the WorktreeRemove hook removes the worktree — but Claude then hangs forever on the spinner:

✶ Cleaning up worktree (no pending changes)…

The process never exits (event loop idle, no child processes, cwd pointing at a deleted inode). The user has to kill it.

Launching from inside the worktree is a natural setup for terminal multiplexers/launchers that create the worktree first and then open a workspace/pane rooted in it running claude --worktree <name> (which re-enters the WorktreeCreate hook and reuses the directory).

Reproduction

Minimal relocating hooks in ~/.claude/settings.json:

{
  "hooks": {
    "WorktreeCreate": [{"hooks": [{"type": "command", "command": "bash ~/wt-create.sh"}]}],
    "WorktreeRemove": [{"hooks": [{"type": "command", "command": "bash ~/wt-remove.sh"}]}]
  }
}

~/wt-create.sh (creates/reuses a worktree outside the repo, prints its path):

#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
name=$(printf '%s' "$input" | jq -r '.name')
cwd=$(printf '%s' "$input" | jq -r '.cwd')
root=$(git -C "$cwd" rev-parse --show-toplevel)
dir="$HOME/tmp-worktrees/$name"
if ! git -C "$root" worktree list --porcelain | grep -qxF "worktree $dir"; then
  git -C "$root" worktree add -b "worktree-$name" "$dir" HEAD >&2
fi
printf '%s\n' "$dir"

~/wt-remove.sh (removes worktree and branch):

#!/usr/bin/env bash
set -uo pipefail
wt=$(cat | jq -r '.worktree_path')
main=$(dirname "$(git -C "$wt" rev-parse --path-format=absolute --git-common-dir)")
git -C "$main" worktree remove --force "$wt"
git -C "$main" branch -D "$(git -C "$main" branch --list "worktree-*" --format='%(refname:short)' | head -1)" 2>/dev/null
exit 0

Steps:

  1. In any git repo: create the worktree by invoking the create hook (or run claude --worktree demo once from the main checkout and keep it).
  2. cd ~/tmp-worktrees/demointo the worktree.
  3. claude --worktree demo (the create hook reuses the existing directory; session cwd = the worktree).
  4. /exit with a clean tree.

Expected: worktree removed, Claude exits.
Actual: worktree removed, Claude hangs forever on "Cleaning up worktree (no pending changes)…".

Control: repeat with step 2 replaced by cd <main checkout> — cleanup completes and Claude exits in under a second. (Verified both ways, repeatedly.)

Diagnosis (from the 2.1.191 bundle)

The exit dialog's auto-clean path runs (minified):

s("removing-clean"), yht().then((Z)=>{ Zyt(f.originalCwd), ..., s("done") })   // no .catch
  • yht() (the cleanup function) is internally try/caught and completes fine — with a --debug-file attached, its final log line Linked worktree cleaned up completely is the last line ever written.
  • Zyt is:
function Zyt(e){process.chdir(e),j_(e),jIm(),OT.cache.clear?.()}

process.chdir(originalCwd) is unguarded. When the session was launched from inside the worktree, originalCwd is the directory the hook just deleted, so chdir throws ENOENT. The .then callback dies before s("done"), there is no .catch/rejection handler on the chain, so the dialog state never advances and the spinner renders forever on an idle event loop.

Supporting observations from a hung process:

  • lsof -p <pid> -d cwd → cwd is the deleted worktree directory (deleted inode).
  • sample <pid> → main thread parked in kevent64 (idle uv loop); no child processes.
  • Debug log ends at Linked worktree cleaned up completely; the WorktreeRemove hook completed with status 0 and the worktree/branch are genuinely gone.

Notably, yht() itself guards its own chdir (try{process.chdir(n)}catch{...} with a "Could not chdir to original directory while cleaning up worktree" log), and the ExitWorktree tool has full recovery for "original directory no longer exists" (falls back to home/temp and reports it). The /exit dialog's post-cleanup Zyt(f.originalCwd) is the one path missing that protection — the same gap exists in the interactive "remove" branch (let K=await yht(); Zyt(f.originalCwd) ...) and the "keep" branches.

Suggested fix

Either (ideally both):

  1. Make Zyt (or its call sites in the exit dialog) tolerate a missing directory — reuse the ExitWorktree recovery logic (fall back to the worktree's parent repo root / home) instead of an unguarded process.chdir.
  2. Add a .catch (or second .then argument) to the exit-dialog cleanup promise chains so any unexpected error still reaches s("done") and the process can exit rather than spinning forever.

View original on GitHub ↗