WorktreeRemove hook never fires when a worktree is removed at session exit

Status Open
Reported on v2.1.201
Maintainer reply None cached
Activity 7 comments · opened Jul 6, 2026

Environment

  • Claude Code 2.1.201 (native installer, latest at time of writing)
  • macOS 26.5 (reproduced identically regardless of exit path)

Description

The hooks reference documents that WorktreeRemove "fires when a worktree is being removed, either at session exit or when a subagent finishes." In 2.1.201 the hook is never invoked at session exit — neither when an unchanged worktree is removed automatically, nor when the user answers the remove/keep prompt for a changed worktree with "remove".

The worktree's branch is also left behind in both cases, although the worktrees doc says removal deletes the branch ("the worktree and its branch are removed automatically").

Since WorktreeRemove output is only visible in debug mode, this fails silently: any external resources a WorktreeCreate hook provisions (databases, containers, ports) leak on every worktree session.

Steps to reproduce

  1. In a git repo, add to .claude/settings.json:
{
  "hooks": {
    "WorktreeRemove": [
      { "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/remove-worktree.sh" }] }
    ]
  }
}
  1. Create the hook script (chmod +x):
#!/bin/sh
cat >> /tmp/worktree-remove-hook.log
echo "fired at $(date)" >> /tmp/worktree-remove-hook.log
  1. Run claude --worktree test, then exit the session (/exit).
  2. Claude Code prints Worktree removed (no changes) and the worktree directory is gone.
  3. /tmp/worktree-remove-hook.log was never created. Repeating with a modified file in the worktree and choosing "remove" at the exit prompt gives the same result.

Expected

The WorktreeRemove hook runs with {"hook_event_name": "WorktreeRemove", "worktree_path": ...} on stdin before/when the worktree is removed, and the worktree's branch is deleted, as documented.

Actual

The worktree directory is removed and deregistered, the hook never runs, and the branch remains.

Notes

  • Other hooks fire normally in the same session (WorktreeCreate runs at startup; the Stop hook runs at exit — visible in the exit output), so this isn't a hooks-config problem.
  • The WorktreeRemove event schema and executor exist in the CLI bundle (the string WorktreeRemove hook failed [...] is present), so it appears the executor simply isn't called from the session-exit cleanup path.
  • Possibly related docs mismatch: in 2.1.201 the WorktreeCreate stdin payload contains only name (plus common fields), while the docs describe worktree_path, branch, and detached.

View original on GitHub ↗

3 Comments

nicholas-lonsinger · 1 month ago

Reproduced on 2.1.206 — and the other documented trigger, "when a subagent finishes", never fires either.

Repro (subagent path, with a control): a settings file passed via --settings, containing both a WorktreeRemove and a SessionEnd hook that append their stdin to the same log:

{
  "hooks": {
    "WorktreeRemove": [{ "hooks": [{ "type": "command", "command": "IN=$(cat); printf 'WTR %s\n' \"$IN\" >> /tmp/wtr-fired.log" }] }],
    "SessionEnd":     [{ "hooks": [{ "type": "command", "command": "IN=$(cat); printf 'SE %s\n' \"$IN\" >> /tmp/wtr-fired.log" }] }]
  }
}
printf '%s' 'Use the Agent tool to spawn one subagent with subagent_type "general-purpose", isolation "worktree", and prompt "Run the bash command pwd and reply with its output. Make no file changes."' \
  | claude -p --settings hooks.json --allowedTools 'Bash(pwd)'

Result:

  • The subagent verifiably ran inside .claude/worktrees/agent-<id> (its pwd output says so), and the worktree was auto-removed when it finished (gone from disk and from git worktree list).
  • The log contains the SessionEnd entry — proving hooks from this settings file load and run — and no WorktreeRemove entry.

So as of 2.1.206, neither documented trigger — session exit (this issue) nor subagent finish — invokes the hook. #65645 reports the same symptom for Workflow-tool agent worktrees.

Additional impact beyond WorktreeCreate-provisioned resources: Remote Control (claude.ai bridge) sessions create and remove their worktrees entirely harness-side — the session transcript shows no EnterWorktree/ExitWorktree tool calls at all, just worktree-state/relocated entries — so PreToolUse/ExitWorktree matchers cannot cover that path either. WorktreeRemove is the only documented seam for per-worktree cleanup there (in our case, unregistering the worktree's built app bundles from LaunchServices, which otherwise leak as ghost registrations on every Remote Control session), and it never fires.

jamalx31 · 1 month ago

Still reproduces on 2.1.211 (latest), five releases after the 2.1.206 report above.

Adding one data point the thread doesn't cover yet: the subagent-finish path fires neither with nor without a WorktreeCreate hook. #50398 reported the hookBased variant back in 2.1.114 (and was auto-closed as NOT_PLANNED by the staleness bot, not fixed — with a plausible root cause identified in its comments: an early return in the subagent-finish path when hookBased is true). But with no WorktreeCreate hook configured at all — the plain git-worktree subagent path — WorktreeRemove still never fires. So the closed issue's hookBased early-return can't be the whole story.

Repro (2.1.211, macOS):

  1. Configure only a WorktreeRemove hook (no WorktreeCreate):
{
  "hooks": {
    "WorktreeRemove": [
      { "hooks": [{ "type": "command", "command": "cat > /tmp/wtremove-probe.json; echo fired >> /tmp/wtremove-probe.log" }] }
    ]
  }
}
  1. Start a fresh session (so settings are read at startup — editing settings mid-session isn't picked up, which is an easy way to mis-diagnose this) and spawn a subagent with isolation: "worktree" that makes no changes:
claude -p 'Use the Agent tool with isolation "worktree" to spawn a subagent that only runs `pwd`.'
  1. The subagent finishes; the worktree is removed automatically (git worktree list confirms it's gone).
  2. /tmp/wtremove-probe.log is never created.

Controls run:

  • Verified the removal actually happened (worktree absent from git worktree list afterwards), so this isn't "removal never occurred."
  • Reproduced with the hook in project (.claude/settings.local.json) and user (~/.claude/settings.json) scope independently — not a settings-precedence issue.
  • WorktreeRemove is present among the hook-event names in the 2.1.211 binary, so it's not an unrecognized-event typo.

Why this bites: the failure is silent (WorktreeRemove output is debug-only and it can't block removal), so anything a worktree provisions out-of-band — containers, databases, ports — leaks on every subagent finish with no signal. We hit this with per-worktree Postgres containers: the worktree vanishes, its container is stranded, and the reconciler that would reclaim it never runs because nothing fires at removal time. Wiring cleanup to this hook looks correct, passes review, and silently does nothing.

gluecksmensch · 25 days ago

Still reproduces on 2.1.222 (macOS 26.5.2), eleven releases after the 2.1.211 report above. Both documented triggers tested in one sitting, each with a positive control in the same settings file and the same session, so "the hook was never loaded" is excluded by construction rather than by argument.

Result: WorktreeRemove fired 0 times across 3 worktree removals. Every control hook fired.

Repro A — subagent-finish path

WorktreeRemove wired in .claude/settings.local.json, pointing at a script that appends its raw stdin to a log. Wired 4 minutes before the session started, so it was present from turn zero.

Two subagents spawned with isolation: "worktree", each running a single pwd and exiting with no file changes. Both worktrees were removed completely — deregistered from git worktree list and their directories deleted. No hook invocation; the log file was never created.

Controls:

  • The script writes correctly when fed stdin manually.
  • A PreToolUse hook added to the same file fired immediately.
  • That PreToolUse hook also fired for the already-running session seconds after being added — so hook config hot-reloads, which independently rules out "the hook was added mid-session" as an explanation for any of this.

Repro B — session-exit path

Hook moved to ~/.claude/settings.json, because a freshly created --worktree worktree contains no untracked project-local settings file — user scope is the only reliable location for this test.

claude --worktree test-worktree-remove-hook
  • WorktreeCreate fired. This is the load-bearing control: worktree-hook dispatch is alive, reaches user-scope settings, and works in this exact flow.
  • Worktree left clean (no file changes), then exited.
  • Worktree removed completely: directory gone, .git/worktrees/<name> metadata gone, absent from git worktree list.
  • WorktreeRemove: not dispatched.

Event tally from the probe log across the whole test:

   4 EVENT=SessionEnd
   1 EVENT=WorktreeCreate
   0 EVENT=WorktreeRemove

Same settings file, same session: WorktreeCreate and SessionEnd both dispatch, WorktreeRemove does not.

Two side observations

1. The payload schema for these events is undocumented, and WorktreeCreate does not carry a path. The hooks reference documents triggers, blocking behavior and decision control for both events, but no event-specific input fields for either. Observed WorktreeCreate payload (paths redacted):

{"session_id":"f34619ef-…","transcript_path":"~/.claude/projects/…/f34619ef-….jsonl",
 "cwd":"~/Projects/<repo>","hook_event_name":"WorktreeCreate","name":"test-worktree-remove-hook"}

It carries name — the proposed worktree name — and no path, which makes sense given the hook is what produces the path. What WorktreeRemove would carry is still unknown to us, since it never fires. If it does not carry the removed worktree's path, then even once fixed the event can only signal that a removal happened, not which — worth specifying in the docs alongside the fix. (#65646 reports a payload mismatch on the Workflow runtime; this is the plain --worktree path.)

2. Note for anyone else trying to reproduce this: WorktreeCreate cannot be used as a passive probe. It is documented as blocking and must print the new worktree path to stdout, so a logging-only hook aborts creation with:

Error creating worktree: WorktreeCreate hook failed: hook succeeded but returned no worktree path

That is documented behavior, not a bug — but it is an easy trap when instrumenting this issue. Use SessionEnd or PreToolUse as the control instead, which is what the tally above does.

Interaction with the picker-suppression reports

No keep/remove picker appeared at exit while the WorktreeRemove hook was registered — consistent with #57378, #58432 and #34137, all closed as duplicate or not-planned. Combined with this issue, the two compound badly: registering the hook suppresses the user's keep/remove choice, and the hook that replaced it never fires. The user loses the prompt and gets no teardown.

Impact

The original report notes that failures surface only in debug mode. Concretely: we were designing per-worktree build-cache reclamation (Xcode DerivedData, several GB per worktree) with WorktreeRemove as the trigger. Because the event never dispatches and never errors, that design would have reclaimed nothing while looking correctly wired — a silent no-op is considerably worse than a hook that fails loudly. We have dropped the hook and now reconcile against git worktree list instead: workable, but polling where an event was documented to exist.

Showing cached comments. Read the full discussion on GitHub ↗