cleanup-worktrees
Audit every git worktree against origin/main, decide which branches are actually done (merged, already in main, or PR closed), delete those worktrees and their local/remote branches, and report. Use w
SKILL.md.claude/skills/cleanup-worktrees/SKILL.md
GitHubCleanup worktrees
Sweep all worktrees, keep anything unfinished, delete what is provably done. Bias to keeping. Deleting a worktree with unmerged work loses it — when the evidence is not conclusive, leave it and say why in the report.
scan.sh only reports facts; every judgement below is yours. Resist the urge to push
those judgements into the script — the interesting cases are one-offs, and a rule that
needs guards is a decision that belongs to you or the user.
Step 1 — Gather evidence
bash .agents/skills/cleanup-worktrees/scan.sh
One git fetch plus one gh pr list for the whole repo, then pure local git —
about ten seconds for thirty worktrees. (Per-branch gh calls get throttled into
30s stalls; don't reintroduce them.) It fast-forwards main (falling back to
comparing against origin/main) and prints one block per non-primary worktree:
| field | meaning |
|---|---|
dirty=N |
uncommitted files in the worktree — anything but 0 blocks cleanup |
ahead/behind |
commits vs origin/main |
contained=ancestor |
branch is an ancestor of origin/main — fully merged |
contained=merge-is-noop |
merging the branch into main would change nothing — work is in main (covers squash merges) |
contained=no (touched=T still-differ=D) |
D of the T files the branch touched still differ from main |
remote= |
remote branch, unpushed commits (local-ahead), commits only on the remote (remote-ahead), and its full tip SHA for the delete lease |
remote-contained= |
printed when remote-ahead>0: the same containment verdict for origin/<branch>, because the remote tip is what --delete destroys |
pr= |
every PR for that branch head, with state and merge/close timestamps |
pr=unknown |
gh failed — the header says so too, and no verdict can rest on PR state |
tmux= |
a live agent session rooted in that worktree |
Step 2 — Verdict per worktree
Blockers — KEEP, whatever else is true
Check these first, for every worktree. They are the whole safety story, so they are written once rather than repeated per rule:
dirty>0— uncommitted work.tmux=anything butnone— a live agent owns that directory, and it may commit between your scan and your deletion.remote-ahead>0unlessremote-contained=ancestor/merge-is-noop— another clone pushed commits this worktree never saw, and the remote tip is what the delete destroys.pr=unknown—ghfailed, so PR state is not evidence.
Then, one reason to delete
DONE — contained=ancestor or contained=merge-is-noop. The work is in main, so
nothing can be lost. These go into the batch go-ahead.
ABANDONED? — every PR for the branch is CLOSED and unmerged. The work is not in
main, so this is a question, not a verdict: report each one with the commits that would
die (git log --oneline origin/main..<branch>, plus anything the remote holds) and get a
per-worktree yes.
Do not try to prove the tip "is only what was abandoned". Comparing the branch against
GitHub's PR head cannot show it (headRefOid advances on any push, including after
closure), and neither can commit dates (a committer date is not a push time). That is why
the scan no longer reports either — show the losses and ask.
KEEP — everything else: pr=OPEN, pr=none with commits not in main, a branch that
is neither contained nor abandoned.
A MERGED PR with contained=no is not DONE — the branch usually grew commits after
the squash merge. It may only be moved to DONE after inspecting what the difference is:
SQUASH=$(git log --oneline origin/main --grep "(#<pr>)" | head -1) # the squash commit in main
git log --oneline <squash-sha>..<branch> # what came after it
git diff origin/main <branch> -- <a differing file> # main moving on, or lost work?
If those extra commits are real work that never landed: KEEP. If they only look
different because main moved past the branch (branch is far behind, its version
of the files is the older one): DONE.
Step 3 — Confirm, then clean
List the DONE set with a one-line reason each and take a single go-ahead for it. List the ABANDONED? set separately, each with its dying commits, and take a yes or no per worktree. Then, per approved worktree:
# The remote first: it is the only step that can fail on new information, and a stale
# lease must find the worktree and branch still intact so the branch can be re-judged.
# Only when the scan reported a remote branch.
git push origin --force-with-lease="refs/heads/<branch>:<remote-tip-from-scan>" ":refs/heads/<branch>"
git worktree remove "<dir>" # never --force: a refusal means unexpected state — re-check
git branch -D "<branch>" # only works once the worktree is gone
git worktree prune
A stale info rejection means the remote moved: stop, keep the worktree, re-run the scan
for that branch and re-judge. Never fall back to a plain --delete.
If a step fails, stop on that worktree, keep it, and note the failure in the report.
Step 4 — Report
# Worktree cleanup — <date>
main @ <sha> <subject> · scanned N worktrees · removed X, kept Y
## Removed — work is in main
| worktree | branch | why | remote branch |
|---|---|---|---|
| issue-300 | issue-300 | PR #301 merged (squash), merge is a no-op | deleted |
## Abandoned? — not in main, needs your call
| worktree | branch | closed PR | commits that would be lost |
|---|---|---|---|
| fs-sync-convergence | fix/fs-sync-convergence | #243 closed 2026-07-24 | 9 — anti-entropy convergence work, `git log --oneline origin/main..fix/fs-sync-convergence` |
## Kept
| worktree | branch | why | next step |
|---|---|---|---|
| issue-357 | issue-357 | PR #358 open, 4 commits ahead | land or close #358 |
| fs-0x | chore/fs-0x-versioning | 3 uncommitted files, no PR | review the diff |
## Needs a human
- <worktree>: <what was ambiguous and the command to settle it>
Keep the report in the chat unless the user asks for a file.