TL;DR
- If you
/exitaclaude --worktree <name>session with no uncommitted changes and no new commits, Claude Code removes the worktree and its branch without showing the Keep/Remove dialog. - This is documented behavior, not a bug. There is no setting to turn it off.
- Claude Code holds its own
git worktree lockon the worktree and only ever releases locks whose reason matches its own format. Re-lock the worktree with your own reason and the cleanup backs off. - A
SessionStarthook can do the re-lock automatically. - Pre-creating the worktree with
git worktree adddoes not protect it. If its branch is namedworktree-<name>, existing unmerged commits get force-deleted too.
Tested on Claude Code 2.1.263, git 2.52.0, macOS.
The symptom
You start an isolated session:
claude --worktree wt-clean
You read some code, maybe run a few commands, and then /exit. You never picked "Remove worktree". But:
$ git worktree list
/path/to/demo 731024a [main]
# .claude/worktrees/wt-clean is gone, and so is branch worktree-wt-clean
If you blink you see Cleaning up worktree (no pending changes)… flash by. That's the only notice you get.
What the exit code actually checks
I pulled the bundled source out of the Claude Code binary to see the decision. On /exit from a worktree session it runs two git commands inside the worktree:
git status --porcelain # changed + untracked files
git rev-list --count <HEAD at session start>..HEAD # new commits this session
Then it branches like this:
| Worktree state | Session named? | What /exit does |
|---|---|---|
| 0 changed files, 0 new commits | no | Removes worktree and branch, no prompt |
| 0 changed files, 0 new commits | yes (/rename) |
Keep / Remove dialog |
| Any uncommitted change or any new commit | either | Keep / Remove dialog |
The silent path runs:
git worktree unlock <path>
git worktree remove --force <path>
git branch -D worktree-<name>
The "new commits" check is relative to the HEAD at session start. Anything committed before the session doesn't count. Keep that in mind for the pre-created worktree gotcha below.
It's documented
From the official worktrees page:
The worktree is clean: for an unnamed session, Claude removes the worktree and its branch automatically. A named session prompts you first so you can keep the worktree for later
So the design intent is "clean means disposable". Plenty of people disagree. Related issues (all closed):
- #27753 Worktree auto-deleted on exit when work is committed
- #46444 worktree auto-cleanup permanently deleted 10 days of uncommitted project work without any warning
- #58432 WorktreeRemove hook suppresses the /exit keep-or-remove picker
There is no worktree.keepOnExit-style setting. So we lean on git instead.
Why git worktree lock works
Claude Code already uses git worktree lock on every worktree it creates. Look at the registry while a session is running:
$ git worktree list --porcelain
worktree /path/to/demo/.claude/worktrees/wt-lock
HEAD 731024acd920b9883ae2f00845fa4ebc8ae0230e
branch refs/heads/worktree-wt-lock
locked claude session wt-lock (pid 72411 start Tue Sep 8 13:50:41 2026)
At exit, the cleanup routine reads the lock reason back and tests it against this regex (from the bundle):
/^claude (?:agent|session) .{1,255} \(pid (\d{1,10})(?: start (.{1,255}))?\)$/
If the reason doesn't match, or it matches but the PID belongs to another live process, the routine keeps the worktree and logs:
cleanupWorktree: kept <path> — locked by another live Claude Code process, or with a reason we did not write
The docs say the same thing about the background stale-lock sweep: "The sweep never releases a lock you set yourself with git worktree lock." A user-set lock is consistently treated as off-limits.
Fix 1: re-lock by hand
During the session, from another terminal (or with the ! prefix inside Claude Code):
git worktree unlock .claude/worktrees/wt-lock
git worktree lock --reason "pinned by ryan" .claude/worktrees/wt-lock
You must unlock first. Claude Code's lock is already there, so a bare lock fails:
$ git worktree lock --reason "keep" .claude/worktrees/wt-lock
fatal: '.claude/worktrees/wt-lock' is already locked, reason: claude session wt-lock (pid 72411 start ...)
Now /exit:
$ git worktree list
/path/to/demo 731024a [main]
/path/to/demo/.claude/worktrees/wt-lock 731024a [worktree-wt-lock] locked
The reason text doesn't matter. Running git worktree unlock . && git worktree lock . from inside the worktree, with no reason at all, also kept it.
Next time, claude --worktree wt-lock drops you back into the same worktree. Your lock stays, so it survives the next /exit too.
Fix 2: automate it with a SessionStart hook
Save this script:
#!/bin/sh
# .claude/hooks/pin-worktree.sh
# Keep Claude Code from auto-removing this linked worktree on exit.
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
[ "$git_dir" = "$common_dir" ] && exit 0 # main worktree: nothing to pin
wt=$(git rev-parse --show-toplevel)
git worktree unlock "$wt" 2>/dev/null # drop Claude Code's own lock
git worktree lock --reason "pinned by SessionStart hook" "$wt"
In the main worktree --git-dir and --git-common-dir are the same path, so the script is a no-op there. In a linked worktree it swaps the lock.
Register it:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "sh \"$CLAUDE_PROJECT_DIR/.claude/hooks/pin-worktree.sh\"" }
]
}
]
}
}
Right after claude --worktree starts:
$ git worktree list --porcelain | grep locked
locked pinned by SessionStart hook
/exit leaves the worktree in place, still locked.
Where the settings file has to live
This tripped me up, so here's what I measured:
| Settings location | Hook fires in the worktree session? |
|---|---|
.claude/settings.json committed to the repo |
yes (it's in the worktree checkout) |
Untracked .claude/settings.local.json only in the main checkout |
no (the worktree checkout doesn't have it) |
A settings file outside the repo (passed with --settings) |
yes |
~/.claude/settings.json (user settings) |
yes |
The worktree session reads project settings from the worktree's own checkout. If you don't want to commit anything, put the hook in your user settings at ~/.claude/settings.json with an absolute script path. The script has no repo-specific logic, so it's safe globally.
Side note from the docs: $CLAUDE_PROJECT_DIR keeps pointing at the main checkout after Claude enters a worktree, while the hook's cwd follows the worktree. That's why the script derives the path from git rev-parse instead of the env var.
Gotchas
Pre-creating the worktree doesn't help
I assumed a worktree I made myself would be left alone. It wasn't. claude --worktree <name> adopts an existing .claude/worktrees/<name> and applies the same exit logic.
Worse: if the branch is named worktree-<name>, commits made before the session don't count as "new", so an unmerged branch gets force-deleted:
$ git worktree add -b worktree-wt-pre4 .claude/worktrees/wt-pre4
$ cd .claude/worktrees/wt-pre4 && echo precious > precious.txt && git add . && git commit -m "precious"
$ claude --worktree wt-pre4 # do nothing, /exit
$ git branch --list worktree-wt-pre4
# empty. No ref points at that commit any more.
The commit object still exists, so git fsck --lost-found can dig it out, but only if you notice. If you hand a hand-made worktree to Claude Code, lock it first.
One-off alternative: name the session
A named session always gets the dialog, even when clean:
Exiting worktree session
This session was named "keepme". Keep the worktree to resume it later, or remove it to clean up.
❯ 1. Keep worktree
2. Remove worktree
/rename keepme then /exit is the cheapest way to keep a worktree once. You have to remember it every time, which is why I prefer the hook.
Cleaning up later
Claude Code won't touch a worktree you locked, so removal is on you:
git worktree unlock .claude/worktrees/wt-lock
git worktree remove .claude/worktrees/wt-lock
git branch -D worktree-wt-lock
Wrap-up
Claude Code's worktree cleanup is aggressive by design: clean plus unnamed equals gone. The escape hatch is already built in, just not advertised. Claude Code refuses to release a git worktree lock it didn't write, so a re-lock with your own reason, done by hand or by a SessionStart hook, is enough to keep the worktree across sessions.
Links:
- Claude Code docs, worktrees: https://code.claude.com/docs/en/worktrees
- Claude Code docs, hooks: https://code.claude.com/docs/en/hooks
- git worktree manual: https://git-scm.com/docs/git-worktree
Top comments (5)
Great forensic writeup on the lock regex — pulling the cleanup logic out of the bundle instead of guessing from behavior is the right way to debug this. One thing worth flagging for anyone lifting your SessionStart hook wholesale: SessionStart hooks follow the same fail-open rule as the rest of the hooks system, any exit code other than 0 or 2 just gets logged and ignored rather than blocking anything. So if
git rev-parsefails for a reason you didn't anticipate, the re-lock silently doesn't happen and nothing in the transcript tells you the pin failed. I'd add a self-check at the end of the script, something likegit worktree list --porcelain | grep -q locked || echo "WARNING: pin failed" >&2, so a broken pin shows up in stderr instead of failing invisibly. Also worth noting: since your regex only matches locks Claude Code itself wrote, a lock reason from a different Claude Code install with a slightly different version string would also read as "not ours" and get kept — a nice accidental safety margin if two versions ever touch the same repo.The lock fix has a second-order cost worth planning for. In git's own manual, lock exists to stop a worktree's administrative files from being pruned, and it "also prevents it from being moved or deleted". So for as long as the lock stands,
git worktree prunewill not collect that worktree automatically. If you ever clear one withrm -rfinstead of your unlock-then-remove recipe, thelockedfile keeps the entry under.git/worktrees/<name>in the registry, and prune leaves it there until you unlock it. Hamid Ahmadian already put the monitoring half in this thread withgit worktree list --porcelain | grep -q locked, and that is the check I would schedule once the hook is in, because your failure mode flips from silent deletion to silent accumulation.The code you pulled out of the bundle also shows how thin the guarantee is. The silent path runs
unlockthenremove --force, a single--force, and git-worktree says removing a locked worktree needs--forcespecified twice. So the pin holds entirely because a string comparison against that reason regex fails, not because git refuses anything. Which is the thing I would test next: doesclaude --resumeon a pinned worktree re-apply Claude Code's own lock reason and re-arm the silent delete? And your hook config is already set up for it: you leftSessionStartwithout a matcher, so it fires on resume too. The open part is ordering.Good build-on, and the accumulation risk you flagged is the more likely failure in practice — teams reach for rm -rf way more often than the unlock-then-remove recipe, precisely because it's the muscle memory. On your resume question: worth testing on a fork rather than reasoning it out, since the answer probably depends on whether Claude Code re-checks for an existing lock before writing a new one or just calls the lock command unconditionally on SessionStart. If it's unconditional, resume re-arms the guard for free; if it checks first and treats "already locked" as a no-op, then a lock you set manually with a different reason string could get silently overwritten with Claude Code's own reason on the next resume, which would be worse than either of the failure modes we've discussed so far because it looks protected the whole time. That ordering question is worth its own follow-up post if you end up running the fork test.
"Documented behavior, not a bug" is doing a lot of work in that sentence - it's a bug that got promoted. Deleting a branch because its diff happens to be empty right now confuses "no changes" with "no value": an empty worktree can hold an hour of experiment setup, the staged-but-uncommitted state of a thought, or just the branch NAME you were about to push work into. The silent part is the worst; a destructive default should at least be loud. git worktree lock as the fix is good to know, but the deeper pattern is worth naming: tools that manage workspaces for you will eventually garbage-collect one you weren't done with, because their model of "done" is a diff check and yours is an intention. Lock everything you intend to return to. Intentions are invisible to cleanup code.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.