{"slug": "claude-code-deletes-clean-worktrees-on-exit-without-asking-git-worktree-lock-it", "title": "Claude Code deletes clean worktrees on exit without asking. git worktree lock stops it.", "summary": "A developer found that Claude Code 2.1.263 silently deletes a clean, unnamed worktree and its branch on /exit without prompting, running git worktree unlock, git worktree remove --force, and git branch -D. The cleanup routine only preserves worktrees whose lock reason matches its own internal regex, so re-locking the worktree with a user-set reason via git worktree lock prevents the deletion. The finding was tested on git 2.52.0 on macOS.", "body_md": "`/exit` a `claude --worktree <name>` session with `git worktree lock` on the worktree and only ever releases locks whose reason matches its own format. `SessionStart` hook can do the re-lock automatically.`git worktree add` does `worktree-<name>`, existing unmerged commits get force-deleted too.\nTested on Claude Code 2.1.263, git 2.52.0, macOS.\n\nYou start an isolated session:\n\n```\nclaude --worktree wt-clean\n```\n\nYou read some code, maybe run a few commands, and then `/exit`. You never picked \"Remove worktree\". But:\n\n``` bash\n$ git worktree list\n/path/to/demo  731024a [main]\n# .claude/worktrees/wt-clean is gone, and so is branch worktree-wt-clean\n```\n\nIf you blink you see `Cleaning up worktree (no pending changes)…` flash by. That's the only notice you get.\n\nI 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:\n\n```\ngit status --porcelain                                  # changed + untracked files\ngit rev-list --count <HEAD at session start>..HEAD      # new commits this session\n```\n\nThen it branches like this:\n\n| Worktree state | Session named? | What `/exit` does | \n|---|---|---|\n| 0 changed files, 0 new commits | no | **Removes worktree and branch, no prompt** | \n| 0 changed files, 0 new commits | yes ( `/rename` ) | Keep / Remove dialog | \n| Any uncommitted change or any new commit | either | Keep / Remove dialog | \n\nThe silent path runs:\n\n```\ngit worktree unlock <path>\ngit worktree remove --force <path>\ngit branch -D worktree-<name>\n```\n\nThe \"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.\n\nFrom the official worktrees page:\n\n**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\n\nSo the design intent is \"clean means disposable\". Plenty of people disagree. Related issues (all closed):\n\nThere is no `worktree.keepOnExit`-style setting. So we lean on git instead.\n\nClaude Code already uses `git worktree lock` on every worktree it creates. Look at the registry while a session is running:\n\n``` bash\n$ git worktree list --porcelain\nworktree /path/to/demo/.claude/worktrees/wt-lock\nHEAD 731024acd920b9883ae2f00845fa4ebc8ae0230e\nbranch refs/heads/worktree-wt-lock\nlocked claude session wt-lock (pid 72411 start Tue Sep  8 13:50:41 2026)\n```\n\nAt exit, the cleanup routine reads the lock reason back and tests it against this regex (from the bundle):\n\n```\n/^claude (?:agent|session) .{1,255} \\(pid (\\d{1,10})(?: start (.{1,255}))?\\)$/\n```\n\nIf the reason doesn't match, or it matches but the PID belongs to another live process, the routine keeps the worktree and logs:\n\n```\ncleanupWorktree: kept <path> — locked by another live Claude Code process, or with a reason we did not write\n```\n\nThe 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.\n\nDuring the session, from another terminal (or with the `!` prefix inside Claude Code):\n\n```\ngit worktree unlock .claude/worktrees/wt-lock\ngit worktree lock --reason \"pinned by ryan\" .claude/worktrees/wt-lock\n```\n\n**You must unlock first.** Claude Code's lock is already there, so a bare `lock` fails:\n\n``` bash\n$ git worktree lock --reason \"keep\" .claude/worktrees/wt-lock\nfatal: '.claude/worktrees/wt-lock' is already locked, reason: claude session wt-lock (pid 72411 start ...)\n```\n\nNow `/exit`:\n\n``` bash\n$ git worktree list\n/path/to/demo                             731024a [main]\n/path/to/demo/.claude/worktrees/wt-lock   731024a [worktree-wt-lock] locked\n```\n\nThe reason text doesn't matter. Running `git worktree unlock . && git worktree lock .` from inside the worktree, with no reason at all, also kept it.\n\nNext time, `claude --worktree wt-lock` drops you back into the same worktree. Your lock stays, so it survives the next `/exit` too.\n\nSave this script:\n\n``` bash\n#!/bin/sh\n# .claude/hooks/pin-worktree.sh\n# Keep Claude Code from auto-removing this linked worktree on exit.\ngit_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0\ncommon_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0\n[ \"$git_dir\" = \"$common_dir\" ] && exit 0      # main worktree: nothing to pin\nwt=$(git rev-parse --show-toplevel)\ngit worktree unlock \"$wt\" 2>/dev/null          # drop Claude Code's own lock\ngit worktree lock --reason \"pinned by SessionStart hook\" \"$wt\"\n```\n\nIn 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.\n\nRegister it:\n\n```\n{\n  \"hooks\": {\n    \"SessionStart\": [\n      {\n        \"hooks\": [\n          { \"type\": \"command\", \"command\": \"sh \\\"$CLAUDE_PROJECT_DIR/.claude/hooks/pin-worktree.sh\\\"\" }\n        ]\n      }\n    ]\n  }\n}\n```\n\nRight after `claude --worktree` starts:\n\n``` bash\n$ git worktree list --porcelain | grep locked\nlocked pinned by SessionStart hook\n```\n\n`/exit` leaves the worktree in place, still locked.\n\nThis tripped me up, so here's what I measured:\n\n| Settings location | Hook fires in the worktree session? | \n|---|---|\n| `.claude/settings.json` committed to the repo | yes (it's in the worktree checkout) | \n| Untracked `.claude/settings.local.json` only in the main checkout | **no** (the worktree checkout doesn't have it) | \n| A settings file outside the repo (passed with `--settings` ) | yes | \n| `~/.claude/settings.json` (user settings) | yes | \n\nThe 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.\n\nSide 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.\n\nI 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.\n\nWorse: if the branch is named `worktree-<name>`, commits made **before** the session don't count as \"new\", so an unmerged branch gets force-deleted:\n\n``` bash\n$ git worktree add -b worktree-wt-pre4 .claude/worktrees/wt-pre4\n$ cd .claude/worktrees/wt-pre4 && echo precious > precious.txt && git add . && git commit -m \"precious\"\n$ claude --worktree wt-pre4    # do nothing, /exit\n\n$ git branch --list worktree-wt-pre4\n# empty. No ref points at that commit any more.\n```\n\nThe 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.\n\nA named session always gets the dialog, even when clean:\n\n```\nExiting worktree session\nThis session was named \"keepme\". Keep the worktree to resume it later, or remove it to clean up.\n❯ 1. Keep worktree\n  2. Remove worktree\n```\n\n`/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.\n\nClaude Code won't touch a worktree you locked, so removal is on you:\n\n```\ngit worktree unlock .claude/worktrees/wt-lock\ngit worktree remove .claude/worktrees/wt-lock\ngit branch -D worktree-wt-lock\n```\n\nClaude 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.\n\nLinks:", "url": "https://wpnews.pro/news/claude-code-deletes-clean-worktrees-on-exit-without-asking-git-worktree-lock-it", "canonical_source": "https://dev.to/day_b3fa2204948ded3059f6f/claude-code-deletes-clean-worktrees-on-exit-without-asking-git-worktree-lock-stops-it-ej", "published_at": "2026-09-11 23:12:29+00:00", "updated_at": "2026-09-11 23:52:27.732081+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents"], "entities": ["Claude Code", "Anthropic", "git", "macOS"], "alternates": {"html": "https://wpnews.pro/news/claude-code-deletes-clean-worktrees-on-exit-without-asking-git-worktree-lock-it", "markdown": "https://wpnews.pro/news/claude-code-deletes-clean-worktrees-on-exit-without-asking-git-worktree-lock-it.md", "text": "https://wpnews.pro/news/claude-code-deletes-clean-worktrees-on-exit-without-asking-git-worktree-lock-it.txt", "jsonld": "https://wpnews.pro/news/claude-code-deletes-clean-worktrees-on-exit-without-asking-git-worktree-lock-it.jsonld"}}