Repository navigation
v0.24.0 — sessions remove their own merged worktree (worktree-finish)
[0.24.0] - 2026-10-22
A session now removes its own merged git worktree instead of leaving it behind.
Added
shared/scripts/worktree-finish.mjs, a deterministic worktree cleanup (zero dependencies). Worktrees piled up because nothing ever removed them once their branch was merged; a consumer had about twenty, most long merged. The script is a dry run by default (--applyremoves), targets the current worktree or--path <dir>, and--all-mergedlists or removes every merged, clean linked worktree and skips the rest (a target that errors is reported as SKIP; the run never aborts mid-way). It refuses with a distinct exit code and a one-line message when the target is the main checkout (10), has uncommitted or untracked changes (11; read with--untracked-files=all --ignore-submodules=none, so user git config cannot hide a file; edits hidden byskip-worktreeorassume-unchangedare found withgit ls-files -vand compared to the index, a sparse-checkout absence is fine), has ignored files other than known OPOS local state, cannot have its ignored files enumerated, or contains a nested git repository anywhere below it (12; git does not descend into those, so the whole tree is walked and no subtree is skipped, allow-listed ones included; the allow-list is.DS_Storefiles,*.pycdirectly in__pycache__, anode_modulesgit itself reports as an ignored directory, and OPOS state), whose HEAD is not an ancestor ofrefs/remotes/origin/<default branch>after a fetch (13; the default branch is detected, never hardcoded), holds leases (14), is locked (15; read from the repository's worktree admin directory, so it works when the directory is missing), contains the process's own cwd (16,--applyonly), is not a worktree of this repository (20), has a non-empty.claude/.current-task(21: another live task remains in that worktree, in--pathand--all-mergedmode alike), or has another worktree inside it (22). Exit 17 means removal failed or could not be verified, 18 the default branch or fetch is unavailable, 19--pathis not a worktree of this repo. The ignored-file allow-list covers the framework's own per-clone state (.claude/.sessions/,.claude/.opos-update-*,.last-update-check,.update-probe, lease state,.clone-id, task caches);.claude/scratchpad.mdandsettings.local.jsonare user content and block removal. Removal usesgit worktree removeon git 2.17 or newer andrm -rfon older git and then deletes only the target's ownworktrees/<id>admin dir (never a globalgit worktree prune, which would orphan another worktree whose directory is temporarily missing), re-runs the status, hidden-edit, ignored and nested checks right before the delete, confirms the target is gone fromgit worktree listand every other worktree is still listed (else 17), thengit branch -d; never-D, never a remote branch. Only when git reports the branch "not fully merged" (it judges against the main checkout's HEAD, while the merge is already proven againstorigin/<default>) is the ref dropped with a guardedgit update-ref -d; any other branch error keeps the branch and exits 17. Not covered, as with git's ownworktree remove: commits reachable only from the target's HEAD reflog (a detached HEAD that is not on origin/ is refused by 13), and, on git older than 2.17, a file created in the milliseconds between the final re-check andrm -rf. Round-3 hardening: exit 23 when more than one worktree record or admin dir claims the target's directory (git older than 2.20 letsworktree addreuse a registered path whose directory is gone; the stale merged record used to pass against the new clean directory and the removal deleted both admin dirs and an unpushed commit), in every mode, and--pathnever picks one record by list position; the removal deletes only the admin dir named by the target's own.gitfile after confirming it is the only match and its HEAD is the checked HEAD. Exit 24: a record whose directory is missing is skipped by--all-mergedand refused by--pathunless--unregister-missingis given, which unregisters only that record, keeps its branch and reports "unregistered (directory missing)" rather than "removed". Hidden-edit detection also compares on-disk size (and exec bit) to the index stat data, so a size-changing or mode-changing edit is caught even under a lossy clean filter (a same-size edit under such a filter is not, the same blind spot asgit status). A failinggit statusis exit 26 with git's message instead of "not a repo".--all-mergedskips (25) a brand-new worktree whose reflog shows only its creation (a live session's fresh worktree is trivially "merged");--pathis unaffected. Tests (shared/scripts/worktree-finish.test.mjs, 48) build throwaway origins and worktrees with real git, include a regression test for each review finding, and run under git 2.15; added to CI'snode --testlist.
Changed
task-complete0.3.0 gains a last step 17: when no other active task remains in the worktree and its work is merged and pushed, remove it and its merged branch with the script, from the main checkout. A refusal is reported, never forced.- Steward permission tiers. Removing the session's own merged worktree and branch is part of finishing its own work (Notice tier). Only removal through
worktree-finish.mjs's gates is Notice: a hand-rolledrm -rfstays under Explicit approval, and removing worktrees the session did not create (--all-mergedover others') is added to the Confirm tier. The Hard refuse "deleting other people's branches" means remote or shared branches; deleting fully merged LOCAL branches together with their worktrees via--all-mergedis Confirm. Thetools:frontmatter is unchanged. .claude/CLAUDE.mdgit rule gains one line stating the same.
Migration
- None required. To clear an existing backlog:
node shared/scripts/worktree-finish.mjs --all-mergedto preview, then add--apply(Confirm tier, as the worktrees are not the session's own).