worktree-cleanup
Close out finished workstreams: remove the worktree directory, delete the local branch, and prune worktree metadata — only for branches that are merged and trees that are clean. Decide what to tear down, confirm with the user, then do it.
Instructions
Orient. From the repo's main checkout, determine the default branch (
git symbolic-ref refs/remotes/origin/HEAD, falling back to whichever ofmain/masterexists — the symref is often unset on a fresh clone even when a remote is configured) and whether the repo provides a teardown script atscripts/rm-worktree.sh. If it does, that script owns the safety gates and every removal goes through it; if not, run the equivalent steps yourself in step 4.Resolve targets. If
$ARGUMENTSnames a branch, that is the only candidate. Otherwise (or on "sweep"), walkgit worktree list --porcelainand skip entries with nobranchline (detached HEAD or bare) plus the main checkout itself. For each remaining worktree, check both:- Merged —
git merge-base --is-ancestor refs/heads/{branch} refs/heads/{default-branch}(fully-qualified: a bare name lets a same-named tag win the lookup), falling back togh pr list --state merged --head {branch}to catch squash-merges, which the ancestor check cannot see. For the manual path in step 4 this check is the sole authority on "merged" — the delete acts on its verdict and nothing later re-asks. (The script path re-checks via the script's own gate, which is why gh-verified squash-merges need--forcethere.) - Clean —
git -C {dir} status --porcelainsucceeds AND prints nothing. Capture the output and the exit code separately: a status command that FAILS also prints nothing, and reading that as "clean" skips the gate. A failing status is a refusal, not a pass.
- Merged —
Present the plan and get confirmation. List candidates with branch, directory, and merge evidence (ancestor vs merged PR number); then list every worktree SKIPPED with the reason — unmerged, dirty, or it is the worktree this session is running in. Git refuses to remove the directory you are standing in; hand that one to a session in another checkout or run from the main checkout. State plainly that removal destroys anything untracked-and-ignored inside the directory (virtualenvs, copied
.envfiles, build output). Symlinks are removed but their targets are not. Never remove anything before the user confirms.Tear down each confirmed branch from the main checkout:
- With a teardown script:
scripts/rm-worktree.sh {branch}. Add--forceonly for a squash-merge you verified via PR state in step 2, and--delete-remoteonly if the user asked — GitHub's "delete branch after merge" setting usually handled it already. - Without one:
git worktree remove {dir}, thengit branch -D {branch}, thengit worktree prune.-D, not-d: the removal is irreversible by the time the branch delete runs, so nothing after it may veto — the merged check in step 2 is the sole authority, and it already accepted both ancestor-merged and PR-verified squash-merged branches.-dasks a DIFFERENT question (reachable from HEAD or upstream) and, when the two disagree — which a squash-merge admitted viagh pr listtypically produces, always once the remote branch is deleted and pruned — it refuses only after the worktree is already gone, leaving the branch orphaned and prune never run. Run one branch at a time and stop on the first failure rather than escalating to--force.
- With a teardown script:
Report. What was removed, what remains (
git worktree list), and every candidate skipped or refused by a gate.
Notes
- Never tear down the main checkout, and never delete a branch that is not merged — flag unmerged or dirty workstreams for the user instead.
- A failing gate is information, not an obstacle. Report it; do not work
around it with
--forceorrm -rfunless the user explicitly asks after seeing the reason. (-Dafter the step-2 gates have passed is the standard delete, not an escalation;-Dto override a gate that REFUSED is the thing that needs an explicit user ask.)