Triage Repo Cleanup
Cleanup is not a delete list. It is a reachability question asked four times — of worktrees, local branches, remote-tracking refs, and stashes. Answer it first and most of the cleanup turns out to be free; skip it and you eventually delete the one commit that existed nowhere else.
Run the triage
Codex: this path resolves under Claude Code; substitute the directory containing
this SKILL.md.
SKILL_DIR="${CLAUDE_SKILL_DIR}"
bash "$SKILL_DIR/scripts/repo-cleanup-triage.sh"
Flags: --base <ref> (default: origin/HEAD, else main, else master),
--remote <name> (default origin), --json, --apply-safe.
Exit status is 1 when anything landed in WOULD LOSE WORK or NEEDS A DECISION,
so it reads as “a human still has to look at this,” not as a failure.
The four tiers
| Tier | Meaning | Your move |
|---|---|---|
| SAFE | Deleting destroys no commit | Do it, report it after |
| RECOVERABLE | Not in base, but a remote still holds it | Do it, name the refetch command |
| NEEDS A DECISION | Holds state nothing else has (dirty tree, stash) | Ask |
| WOULD LOSE WORK | Commits on no remote and not in base | Ask, and offer to push first |
| KEEP | In use, or owned by another tool | Leave alone |
Apply the free tier without a round-trip:
SKILL_DIR="${CLAUDE_SKILL_DIR}"
bash "$SKILL_DIR/scripts/repo-cleanup-triage.sh" --apply-safe
--apply-safe runs the SAFE tier and stops there. It never drops a stash, even a
SAFE-classified one, because a stash is the one artifact with no second copy
anywhere — confirm those individually.
What makes a verdict trustworthy
Do not classify with git branch -d. It answers against your current HEAD
and against the branch’s own upstream, so a branch fully merged into main
gets refused as “not fully merged” whenever you are standing on a third branch,
and a rebased branch gets refused because its stale remote counterpart diverged.
Both read as “unmerged work here” when nothing is at risk. Ask reachability
directly instead:
git merge-base --is-ancestor <branch> <base> && echo "every commit is in base"
git rev-list --count <upstream>..<branch> # 0 = a remote still has all of it
git for-each-ref --contains <sha> refs/remotes # empty = no remote has it
Removing a worktree is not deleting a branch. A clean worktree’s commits live on its branch ref, which survives the removal untouched — so a stale worktree is almost always free to remove while its branch stays put. Triage the two separately; treating them as one decision is what makes cleanup feel risky when it is not.
A detached worktree is the exception. Its HEAD may be on no branch at all, in
which case removal orphans that commit. Check before removing, and if nothing
contains it, branch it first: git branch rescue/<name> <sha>.
Leave tool-managed worktrees alone. Anything under .git/ belongs to the tool
that made it (beads-sync, for one). Its owner creates and reaps it on its own
schedule; removing it by hand breaks that tool rather than tidying the repo.
Reporting
Lead with what you deleted, then the single decision you need. Name what each
deletion could not have cost — “every commit already in main”, “identical to
origin/<branch>” — because that is the sentence that makes the cleanup
auditable. End on the open question only when the triage actually produced one.
Do not present the whole table back when a tier is empty. A repo with nothing in WOULD LOSE WORK gets one line saying so.