Branch Hygiene
Local git state rots faster than people notice. clean_gone catches branches
whose remote was deleted, but misses:
- Worktrees pointing at branches that no longer exist
- Local branches merged into
mainorreleaseweeks ago - Remote PR branches that the contributor forgot to delete
- Orphan worktree directories on External HD with no git registration
This composite handles all four in one pass with confirmation prompts at every destructive step.
When this fires
- User says "branch hygiene", "clean up branches", "prune branches", "dead worktrees", "git is a mess in this repo"
- Auto-suggest at session start when
git branch | wc -l > 30in the active repo - After
/release-cutPhase 9 (which already deletes some stale branches but doesn't touch worktrees) - Weekly cadence per active repo (let
/next-prioritysurface it)
If the repo is not a git repo or git is unavailable: exit with
branch-hygiene skipped: not a git repository.
Workflow
Phase 1 — Fetch + prune (always)
git fetch --all --prune --prune-tags
This refreshes the remote-tracking state so subsequent phases see accurate
[gone] markers. No prompts; safe.
Phase 2 — Kill [gone] local branches (skill: commit-commands:clean_gone)
Invoke the existing clean_gone skill. It already prompts before deleting and
removes associated worktrees registered with git. If it bails out (e.g.,
dirty worktree on a [gone] branch), record the offender in the reconciliation
block and continue — do not abort the chain.
Phase 3 — Worktree prune + orphan sweep (composite logic)
git worktree prune --verbose
git worktree list --porcelain
Compare git worktree list output against the directory listing under
$WORKTREE_ROOT (default: $HOME/.worktrees/ —
the user's storage policy). For any directory that exists on disk but is NOT
registered as a worktree:
- Confirm with user before removing (single prompt summarizing all orphans)
rm -rfonly after explicit confirmation- Record paths removed in the reconciliation block
If the worktree volume is not mounted, skip the orphan sweep with
(skipped: External HD not mounted).
Phase 4 — Branches merged to main (composite logic)
git fetch origin main
git branch --merged origin/main \
| grep -vE '^\*|^\s*(main|master|release|develop)\s*$'
For each candidate:
- Show: branch name, last commit date, last commit subject
- Single bulk prompt: "Delete N merged branches? (y/N/select)"
y→git branch -d <each>(safe delete; refuses if not fully merged)N→ skip phase, record count in reconciliationselect→ enumerate and let user pick by index
Never use -D here. If -d refuses, surface that branch in the
reconciliation block as unsafe-merge: <branch> and continue.
Phase 5 — Branches merged to release (skip if no release branch)
Probe: git ls-remote --heads origin release 2>/dev/null | grep -q .
- If no release branch:
(skipped: no release branch on origin), continue - If exists: repeat Phase 4 logic with
git fetch origin release+git branch --merged origin/release
This catches the case where a PR landed on release via /pr-to-release and
the contributor never deleted the source branch locally.
Phase 6 — Stale remote PR branches (skill: gh-cli)
If gh is available:
gh pr list --state merged --limit 100 \
--json number,headRefName,mergedAt,headRepositoryOwner
Filter to:
mergedAtmore than 7 days agoheadRefNamenot in {main, master, release, develop, HEAD}- Branch still exists on origin (
git ls-remote --heads origin <ref>non-empty) - PR head repo owner matches the current repo's owner (skip fork branches)
Show the filtered list, prompt once for bulk deletion. On confirm:
git push origin --delete <ref> # per branch, swallow individual failures
If gh is missing: (skipped: gh CLI unavailable).
Phase 7 — Final state snapshot (always)
echo "Branches remaining: $(git branch | wc -l)"
echo "Worktrees remaining: $(git worktree list | wc -l)"
git branch -vv | head -20
These numbers go into the reconciliation block so the user sees the delta.
Reconciliation block (mandatory output)
BRANCH HYGIENE — <repo>
Fetched: <n> branches updated, <m> tags pruned
Gone-killed: <n> branches (skill: clean_gone)<, unsafe-merge: <list> if any>
Worktree prune: <n> registered worktrees removed
Orphan sweep: <n> directories removed | (skipped: <reason>)
Merged to main: <n> deleted, <m> kept (unsafe-merge: <list>)
Merged to release: <n> deleted | (skipped: no release branch)
Stale PR refs: <n> deleted | (skipped: gh unavailable)
Delta: <before> → <after> local branches, <before> → <after> worktrees
Snapshot: (none — interactive cleanup, no handoff written)
Open watch: (none) | <e.g. "1 unsafe-merge branch: feature/X — manually verify before -D">
If any phase failed mid-step, mark it (failed: <reason>) and continue to the
next phase. Phases are independent — failure of one does not abort the rest.
Stop conditions
- Not a git repo → exit immediately with the skip message
- Repo has uncommitted changes on the currently-checked-out branch → run anyway; the destructive phases never touch the current branch
- User answers
Nto any phase's bulk prompt → record phase as(skipped: user declined)and continue - More than 50 deletion candidates in any single phase → require explicit
confirmation phrase (
yes, delete all <n>) not justy— guards against catastrophic miscounts
Configuration
Optional .claude/branch-hygiene-config.json in the repo root:
{
"worktree_root": "$HOME/.worktrees",
"stale_pr_threshold_days": 7,
"protect_branches": ["main", "master", "release", "develop", "staging"],
"skip_phases": []
}
Defaults apply when the file is missing. skip_phases accepts phase numbers
(e.g., [6] to skip the remote PR sweep on a repo where the user prefers to
review those manually).
Negative rules
- Do NOT use
git branch -Dto force-delete branches. The whole point is to delete what's safely merged; force-delete defeats the safety check. - Do NOT skip the bulk prompt. Even when only one branch matches, the prompt is the contract — silent deletion is forbidden.
- Do NOT delete branches that are protected by the repo's
protect_branchesconfig OR by GitHub branch protection (probe viagh api); surface them asprotected: <branch>instead. - Do NOT touch worktrees outside
worktree_root. If the user has worktrees elsewhere, the orphan sweep does not see them — flag this in the reconciliation block ifgit worktree listshows paths outside the root. - Do NOT auto-queue another composite. This is a maintenance pass, terminal.