gh-purge: deleting dead git branches without holding your breath
A GitHub CLI extension that classifies every local branch, including squash-merged ones, before deleting anything, and can undo the whole run.
GitHub deletes the remote branch when a pull request merges, like it’s supposed to. Your laptop never gets the memo. So local branches accumulate for as long as the repo lives, and the usual cleanup is a one-liner everybody keeps in their shell history:
git branch -vv | grep gone | xargs git branch -D
I have run that command. It has always worked. That is exactly the problem. It force-deletes over a grep of porcelain output, and the day it eats a branch holding two hours of unpushed work, it will do so silently and with a 0 exit code. There is no undo. git reflog will help you if you know the SHA, and you won’t, because the branch that held it is gone.
So I wrote gh-purge: a GitHub CLI extension that classifies every local branch first, deletes only what it can prove is dead, and writes an undo log before it touches anything.
gh extension install RichardAtCT/gh-purge
Then gh purge in any repo.
Why merged branches don’t look merged
git branch -d refuses to delete a branch that isn’t an ancestor of your current HEAD. That check is sound, and it is also useless on most modern repos, because most modern repos squash-merge.
When GitHub squash-merges a PR, it takes your five commits, flattens them into one new commit with a new SHA, and applies that to main. Your local branch tip is now an ancestor of nothing. Rebase-merge does the same thing with more steps: every commit gets replayed onto a new base, and every replayed commit gets a new SHA. Your work is in main. Your commits, as objects, are not.
So git branch -d says “not fully merged”, and you reach for -D, and now you’re force-deleting branches based on vibes.
Three tiers, cheapest first
gh-purge resolves each branch through three detection tiers, and stops as soon as one gives an answer.
Tier 1 checks whether the upstream is gone. git for-each-ref reports [gone] for branches whose remote tracking ref has been pruned. That’s a strong hint, but on its own it is only a hint: a branch can be gone because the PR was closed unmerged, or because someone tidied up the remote.
Tier 2 looks for local proof. One batched git for-each-ref --merged call catches ancestry, and then git cherry does the interesting work. git cherry compares patch IDs rather than SHAs, so a commit that was squashed into main shows up as - (already upstream) even though its SHA appears nowhere in history. For single-commit PRs, which are a large share of real-world merges, this identifies a squash-merge with zero API calls. Tiers 1 and 2 together are enough to classify most branches in most repos before any network traffic happens at all.
Tier 3 asks GitHub, once. Only branches that are gone and still have patches not upstream reach the API.
The GraphQL corner
Tier 3 has a wrinkle that cost me an afternoon.
The natural query is repository { ref(qualifiedName:) { associatedPullRequests } }. It returns nothing. Not an error, just an empty, cheerful null. The reason is obvious in hindsight: associatedPullRequests hangs off the ref, and the ref was deleted when the PR merged. The exact condition that makes a branch a purge candidate is the condition that makes this query useless.
The way around it is to search from the PR side instead, with pullRequests(headRefName:), which still knows the name of a branch that no longer exists. That’s a per-branch lookup, which sounds like N requests, except GraphQL lets you alias the same field repeatedly in one document:
b0: pullRequests(headRefName: "feat/login", states: [MERGED, CLOSED], first: 1) { nodes { number state mergedAt headRefOid url } }
b1: pullRequests(headRefName: "fix/timeout", states: [MERGED, CLOSED], first: 1) { nodes { number state mergedAt headRefOid url } }
Build that string programmatically, keep an alias-to-branch map, chunk at 50 aliases per request, and run up to three chunks concurrently. A 150-branch backlog resolves in three requests instead of 150.
Eight statuses, three of them deletable
Classification lands each branch in one of eight statuses. Three are safe to delete without a flag: gone_merged, merged, squash_merged. Three are dangerous and refuse to be deleted without --force: unpushed_work, pr_closed, gone_unverified. Two are simply left alone: active and protected.
The one I care about most is unpushed_work, because it is the branch the one-liner eats. When tier 3 finds a merged PR, gh-purge compares the PR’s headRefOid against your local tip. If they match, the branch is done. If your local tip is ahead of the merged head, meaning you committed something after the PR landed and never pushed it, the branch is flagged, excluded from “select all safe”, and requires an explicit --force to remove. The same goes for divergence after an amend or rebase.
Protection rules run first and win over everything: the current branch, the default branch, anything checked out in another worktree, and anything matching the protect globs (main, master, develop, release/* by default).
Local classification of a synthetic 500-branch repo benchmarks at around 50 ms, against a target of 200 ms. The TUI (Bubble Tea) paints local results immediately and re-classifies rows in place as the GraphQL chunks come back, so you’re reading the list while the network is still working.
Every run is reversible
Before a single git branch -D runs, gh-purge appends every branch name and tip SHA to .git/gh-purge/undo.jsonl. Then it deletes. Then it prints the restore commands anyway, as a copy-paste fallback.
gh purge --undo
restores the last run at exact SHAs, skipping anything you’ve since recreated. The log keeps the last 20 runs.
There is no local database, no cache, no sync step. Git’s ref store already is the database, and that statelessness is most of why I trust the tool.
The first real run
The first repo I pointed it at was my most active project: 173 local branches, months of daily merges, live uncommitted work in the tree.
Classification took a couple of seconds, most of it the git fetch. The verdict: 137 branches safe to delete, of which 72 were provably merged, 37 gone-and-merged, and 28 squash-merges that only tier 3 could confirm, each annotated with its PR number. About a hundred of the dead branches were worktree-agent-* refs left behind by Claude Code sessions, which felt like the tool auditing its own author.
The interesting column was the other one. Nine branches flagged unpushed_work, and five of those had merged PRs. The PR landed, then one to six more commits went onto the local branch and never left my laptop. Those five are exactly what the one-liner would have silently destroyed. They sat in the “needs review” section, unselected, with their unpushed counts next to them.
Then I tested the part I actually cared about: deleted three branches, ran gh purge --undo, and diffed the SHAs before and after. Byte identical. The repo ended the test in exactly the state it started.
Designing for agents as users
I built this while my own workflow was shifting: a lot of my repo hygiene now happens because I asked Claude Code to do it. That makes an AI agent a real user persona, not a novelty, and agents need different affordances than humans do.
Four things follow from taking that seriously. --help is documentation rather than decoration, so it embeds the JSON schema, the exit-code table, and the status vocabulary marking which statuses are safe, because --help is the first thing an agent reads. --json is deterministic, so two runs on an unchanged repo diff cleanly. The write path is an explicit set rather than a bulk judgment: gh purge --yes --branch feat/x --branch fix/y, where an unknown branch name aborts the entire invocation before anything is deleted. And dangerous statuses refuse --force semantics by default even when named explicitly, because “this branch has unpushed work” is a decision for the human, not the agent.
The canonical loop is: gh purge --json --dry-run to look, agent applies its own policy, gh purge --yes --branch <a> --branch <b> to act, gh purge --undo if the human objects. The repo ships a drop-in SKILL.md that teaches exactly that. An agent can drive a CLI shaped like this without anyone holding their breath, because inspection is read-only, writes have to name their targets, and the whole thing reverses.
Full disclosure on process: I wrote a detailed PRD and Claude Code built almost all of it, orchestrating parallel subagents against the spec. The design decisions in this post are mine. The typing mostly wasn’t.
Try it
gh extension install RichardAtCT/gh-purge
gh purge --dry-run
The code, the safety model, and the agent docs are at github.com/RichardAtCT/gh-purge. If it misclassifies a branch in your repo, that’s a bug I want to know about, so please open an issue with the scenario and I’ll turn it into a test case.