WorktreeRemove hook never fires when a worktree is removed at session exit
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
- In a git repo, add to
.claude/settings.json:
{
"hooks": {
"WorktreeRemove": [
{ "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/remove-worktree.sh" }] }
]
}
}
- Create the hook script (
chmod +x):
#!/bin/sh
cat >> /tmp/worktree-remove-hook.log
echo "fired at $(date)" >> /tmp/worktree-remove-hook.log
- Run
claude --worktree test, then exit the session (/exit). - Claude Code prints
Worktree removed (no changes)and the worktree directory is gone. /tmp/worktree-remove-hook.logwas 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 (
WorktreeCreateruns at startup; theStophook runs at exit — visible in the exit output), so this isn't a hooks-config problem. - The
WorktreeRemoveevent schema and executor exist in the CLI bundle (the stringWorktreeRemove 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
WorktreeCreatestdin payload contains onlyname(plus common fields), while the docs describeworktree_path,branch, anddetached.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
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 aWorktreeRemoveand aSessionEndhook that append their stdin to the same log:Result:
.claude/worktrees/agent-<id>(itspwdoutput says so), and the worktree was auto-removed when it finished (gone from disk and fromgit worktree list).SessionEndentry — proving hooks from this settings file load and run — and noWorktreeRemoveentry.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/ExitWorktreetool calls at all, justworktree-state/relocatedentries — soPreToolUse/ExitWorktreematchers cannot cover that path either.WorktreeRemoveis 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.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
WorktreeCreatehook. #50398 reported thehookBasedvariant back in 2.1.114 (and was auto-closed asNOT_PLANNEDby the staleness bot, not fixed — with a plausible root cause identified in its comments: an early return in the subagent-finish path whenhookBasedis true). But with noWorktreeCreatehook configured at all — the plain git-worktree subagent path —WorktreeRemovestill never fires. So the closed issue'shookBasedearly-return can't be the whole story.Repro (2.1.211, macOS):
WorktreeRemovehook (noWorktreeCreate):isolation: "worktree"that makes no changes:git worktree listconfirms it's gone)./tmp/wtremove-probe.logis never created.Controls run:
git worktree listafterwards), so this isn't "removal never occurred.".claude/settings.local.json) and user (~/.claude/settings.json) scope independently — not a settings-precedence issue.WorktreeRemoveis 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 (
WorktreeRemoveoutput 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.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:
WorktreeRemovefired 0 times across 3 worktree removals. Every control hook fired.Repro A — subagent-finish path
WorktreeRemovewired 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 singlepwdand exiting with no file changes. Both worktrees were removed completely — deregistered fromgit worktree listand their directories deleted. No hook invocation; the log file was never created.Controls:
PreToolUsehook added to the same file fired immediately.PreToolUsehook 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--worktreeworktree contains no untracked project-local settings file — user scope is the only reliable location for this test.WorktreeCreatefired. This is the load-bearing control: worktree-hook dispatch is alive, reaches user-scope settings, and works in this exact flow..git/worktrees/<name>metadata gone, absent fromgit worktree list.WorktreeRemove: not dispatched.Event tally from the probe log across the whole test:
Same settings file, same session:
WorktreeCreateandSessionEndboth dispatch,WorktreeRemovedoes not.Two side observations
1. The payload schema for these events is undocumented, and
WorktreeCreatedoes 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. ObservedWorktreeCreatepayload (paths redacted):It carries
name— the proposed worktree name — and no path, which makes sense given the hook is what produces the path. WhatWorktreeRemovewould 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--worktreepath.)2. Note for anyone else trying to reproduce this:
WorktreeCreatecannot 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:That is documented behavior, not a bug — but it is an easy trap when instrumenting this issue. Use
SessionEndorPreToolUseas 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
WorktreeRemovehook 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
WorktreeRemoveas 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 againstgit worktree listinstead: workable, but polling where an event was documented to exist.