Skip to content

v0.24.0 — sessions remove their own merged worktree (worktree-finish)

Choose a tag to compare

@Koroqe Koroqe released this 22 Oct 14:09
· 2 commits to main since this release
b1be330

[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 (--apply removes), targets the current worktree or --path <dir>, and --all-merged lists 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 by skip-worktree or assume-unchanged are found with git ls-files -v and 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_Store files, *.pyc directly in __pycache__, a node_modules git itself reports as an ignored directory, and OPOS state), whose HEAD is not an ancestor of refs/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, --apply only), is not a worktree of this repository (20), has a non-empty .claude/.current-task (21: another live task remains in that worktree, in --path and --all-merged mode 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 --path is 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.md and settings.local.json are user content and block removal. Removal uses git worktree remove on git 2.17 or newer and rm -rf on older git and then deletes only the target's own worktrees/<id> admin dir (never a global git 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 from git worktree list and every other worktree is still listed (else 17), then git 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 against origin/<default>) is the ref dropped with a guarded git update-ref -d; any other branch error keeps the branch and exits 17. Not covered, as with git's own worktree 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 and rm -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 lets worktree add reuse 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 --path never picks one record by list position; the removal deletes only the admin dir named by the target's own .git file 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-merged and refused by --path unless --unregister-missing is 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 as git status). A failing git status is exit 26 with git's message instead of "not a repo". --all-merged skips (25) a brand-new worktree whose reflog shows only its creation (a live session's fresh worktree is trivially "merged"); --path is 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's node --test list.

Changed

  • task-complete 0.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-rolled rm -rf stays under Explicit approval, and removing worktrees the session did not create (--all-merged over 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-merged is Confirm. The tools: frontmatter is unchanged.
  • .claude/CLAUDE.md git rule gains one line stating the same.

Migration

  • None required. To clear an existing backlog: node shared/scripts/worktree-finish.mjs --all-merged to preview, then add --apply (Confirm tier, as the worktrees are not the session's own).