Skills

stale-branch-cleaner

Development v2 Emdly recommended
@launifycorp 80 installs updated 44d ago MIT license

Stale Branch Cleaner

Audits the branches of a git repository, ranks them by how likely they are to be dead, presents the candidates for deletion, and deletes only what the user explicitly confirms — locally, on the remote, or both.

Deletion is destructive and, on a remote, effectively public. This skill NEVER deletes anything before an explicit confirmation, and never widens the scope it was given.

When to use

  • "clean up my branches", "which branches can I delete", "prune stale branches"
  • "my repo has 60 branches and I don't know which are dead"
  • Before a repo handover, an audit, or a release cleanup.

Do not use it to delete a branch the user already named — that is a one-line git command, not an audit.

Inputs to establish first

Ask only for what you cannot detect. Detect the rest.

  • Repository path — default: the current working directory. Refuse to continue if it is not a git work tree.
  • Staleness threshold — default: 90 days since the last commit. State the default; let the user override.
  • Remotes — git remote -v. A fork checkout has origin and upstream, and they are not interchangeable: deleting on the wrong one either fails or destroys the wrong branch. If there is more than one, ask which is meant and never assume origin.
  • Protected branches — never proposed for deletion under any circumstance: main, master, develop, dev, staging, production, prod, release, release/*, HEAD, the current branch, and the remote's default branch (git symbolic-ref refs/remotes/origin/HEAD). Add anything the user names.

Steps

  1. Refresh the view of the remote (read-only, safe): git fetch --all --prune --quiet --prune only cleans up local tracking refs for branches already deleted on the remote; it deletes no branches. Say so if the user asks.

  2. Collect the facts in one pass, so every judgement rests on real data:

    git for-each-ref --sort=committerdate \
      --format='%(refname:short)|%(committerdate:iso8601)|%(authorname)|%(upstream:track)' \
      refs/heads refs/remotes
    

    For each branch also determine:

    • days since last commit — from the date above;
    • merged or not: git branch --merged <default-branch> (local) and git branch -r --merged origin/<default-branch> (remote);
    • unmerged commits that would be lost: git log --oneline <default-branch>..<branch> | wc -l;
    • whether a local branch still has a live remote counterpart, and vice versa.
  3. Catch the squash-merged branches. This is the trap that makes a naive audit wrong on most modern repos: git branch --merged follows ancestry, and a squash merge or a rebase merge creates new commits with new SHAs. The branch's own commits are never ancestors of the default branch, so its work is fully shipped and it still reports as unmerged with N commits ahead.

    Treating those as STALE buries the safest deletions in the scariest pile. Before classifying, test each apparently-unmerged branch:

    git cherry -v <default-branch> <branch>
    

    Lines starting - are commits whose change is already present upstream (matched by patch-id); + means genuinely absent. A branch whose lines are all - is squash-merged — equivalent in content, unreachable by ancestry.

    Where a forge is reachable, its answer beats inference: gh pr list --state merged --head <branch> (or glab mr list) proves the branch was merged and names the PR. Use it when available and say which source the verdict came from.

  4. Classify each non-protected branch:

    Class Meaning Default proposal
    MERGED fully merged into the default branch by ancestry propose deletion at any age
    SQUASHED content present upstream (git cherry all -, or a merged PR) propose deletion, state the evidence
    STALE older than the threshold, has genuinely absent commits propose, flagged with the commit count
    GONE local branch whose upstream is gone ([gone]) propose local deletion
    ACTIVE newer than the threshold keep, do not list among candidates

    Never treat "no commits recently" as "abandoned" on its own — a long-lived release or hotfix branch can be both old and load-bearing. Anything with unmerged work is flagged, not quietly bundled in.

  5. Check what a deletion would disturb before proposing anything:

    • Worktrees — git worktree list. A branch checked out in another worktree cannot be deleted and, more importantly, someone is working in it.
    • Open PRs — gh pr list --head <branch>. An open PR means the branch is alive whatever its age; exclude it from the default proposal.
    • Tags — a tag pointing at a branch's tip keeps the commits reachable, so deleting that branch loses nothing. Worth saying, because it turns a scary deletion into a safe one.
    • Remote protection rules — a protected branch on the forge will refuse the push. Predict it rather than discovering it in an error.
  6. Present the list and stop. Group by class, most confident deletions first. For each branch show: name, age, last author, merged verdict and how it was reached, unmerged commit count, and where it exists (local / remote / both). Show the totals. Then state explicitly that nothing has been deleted yet.

  7. Ask for the scope — this is a separate question from which branches, and it has no default:

    • local only — safe, reversible, affects nobody else;
    • remote only — affects everyone who uses the repo;
    • both.

    Also ask whether to delete all proposed branches or a subset, and accept a subset by name or number.

  8. Confirm before acting. Restate the exact branch names, the exact remote, and the exact scope, then wait for a clear yes. Treat silence, "ok whatever", or an ambiguous reply as "no". If the scope includes a remote, say plainly that remote deletion affects other people.

  9. Delete only what was confirmed, one branch at a time, reporting each result:

    • local, merged: git branch -d <branch>
    • local, squashed or unmerged (only after the user confirmed seeing the evidence): git branch -D <branch>
    • remote: git push <remote> --delete <branch>

    Print every tip SHA before deleting, in one recoverable block. Recovery is git checkout -b <name> <sha>, and it works while the commit is still reachable: locally the reflog holds it for a limited window (gc.reflogExpireUnreachable, 30 days by default — quote the repo's actual setting, not an assumption), and on the remote only if someone still holds a copy. A deleted remote branch whose commits exist nowhere else is gone for good.

  10. Report: deleted, skipped, failed (with the error). If any deletion failed — branch protection, no push rights, an open PR, a checked-out worktree — say which and why, and do not retry with a more forceful command.

Rules

  • Never delete without an explicit confirmation naming the scope. No exceptions, including when the user says "just clean it up".
  • Never git push --delete a protected branch, even if asked; say why and stop.
  • Never force-delete (-D) a branch with genuinely unmerged commits unless the user confirmed after seeing the commit count.
  • Never call a branch unmerged on ancestry alone — run the patch-id check first, or the report is wrong on every squash-merging repo.
  • Never rewrite history, reset, or touch the working tree — this skill only fetches, reads refs, and deletes branches.
  • Never delete a branch checked out in a worktree, or one with an open PR, as part of a bulk action.
  • If the repo has uncommitted changes, say so, but do not block: branch deletion does not touch them.
  • Never run git gc, git prune, or git reflog expire. They are what turns a recoverable deletion into a permanent one.

Output format

Repository: <path>   Default branch: <name>   Remote: origin   Threshold: <N> days
Scanned: <N> local, <N> remote   Protected: <list>   Worktrees: <n>

MERGED — safe to delete (<n>)
  feature/login-form      142d   J. Novak    ancestry        local+origin

SQUASHED — shipped, invisible to --merged (<n>)
  feature/cart-rewrite     96d   M. Dvorak   PR #412 merged  local+origin
  fix/header-overflow     120d   J. Novak    patch-id match  local only

STALE — genuinely unmerged work (<n>)
  spike/graphql           210d   M. Dvorak   3 commits ahead origin only

GONE — upstream deleted (<n>)
  fix/typo                 88d   J. Novak    [gone]          local only

EXCLUDED
  feature/checkout-v2   open PR #488
  hotfix/tls            checked out in worktree ../hotfix

KEEPING: <n> active branches

Nothing deleted yet. Delete which branches, and where — local only, origin only, or both?

After deletion:

Recovery block (save this):
  feature/cart-rewrite  a3f91c2      fix/header-overflow  7d20e11
  git checkout -b <branch> <sha>   — local reflog holds unreachable commits for <N> days

Deleted (local): <list>
Deleted (origin): <list>
Skipped: <list with reasons>
Failed: <list with errors>

License

MIT