Open any repo you've worked in for a month and run git branch. Half those
branches are merged and gone. A few are dead spikes from six months ago. One or
two might still matter. And every few weeks someone gets tired of scrolling past
them and reaches for the cleanup one-liner everyone half-remembers:
git branch --merged | grep -v main | xargs git branch -d
That line works right up until the day it doesn't. You forgot you weren't on
main, so it tried to delete your current branch. Or grep -v main also matched
maintenance. Or you upgraded -d to -D to clear the "not fully merged"
warning and quietly nuked three hours of unpushed work. The one-liner has no
preview, no guardrails, and no memory of which branch you're standing on.
So I built branchtidy — a zero-dependency CLI whose entire personality is
not deleting the wrong branch.
npx branchtidy
branchtidy local branches · default main · stale > 90d
BRANCH LAST COMMIT MERGED ACTION
feature/login-v2 12d ago yes delete (merged)
spike/redis-poc 210d ago no delete (stale 210d)
main 2d ago no keep (protected)
feature/wip 3d ago no keep (active)
Dry run. 2 branch(es) WOULD be deleted. Re-run with --delete to apply.
Notice what just happened: nothing. No flags means dry-run. It prints a table
of what it would do — with a reason on every single branch — and exits. You read
it, then you decide.
Safety is the whole product
Branch cleaners aren't a new idea; the space is a little crowded. The thing most
of them get wrong is treating "delete branches" as the happy path and safety as
an afterthought. branchtidy inverts that:
-
Dry-run is the default. You opt in to destruction with
--delete. -
Deletion is gated twice.
--deleteand an interactive confirm. The prompt only goes away with--yes(for scripts/CI). -
Protected branches are never candidates.
main,master,develop, your currentHEAD, and anything in--protect a,bare filtered out before any staleness math runs — so the classic "deleted the branch I was on" footgun simply can't happen. -
Merged vs unmerged is respected. Merged branches use the safe
git branch -d; an unmerged one is only touched if you add--force(mapping togit branch -D). No silent-D. -
Remote deletion is double-gated:
--remote --deleteplus its own confirm before anygit push origin --delete.
The rule in the code is literally: when unsure, keep.
How it decides
For each branch it asks four questions — current? protected? merged? how old? —
and applies these rules in order: current → keep; protected → keep;
merged into the default branch → delete (merged); older than --stale
(default 90d) → delete (stale <N>d); otherwise → keep (active).
--merged-only drops the staleness rule, so age alone never deletes anything.
The default branch is discovered from origin/HEAD, falling back to main then
master, so you don't configure it.
# stricter window, merged branches only, do it (with a confirm)
branchtidy --stale 30d --merged-only --delete
# clean up gone-stale branches on the remote (double-gated + confirm)
branchtidy --remote origin --delete
# you really do want the unmerged dead spikes gone — say so out loud
branchtidy --stale 180d --delete --force
# pipe the plan into your own tooling
branchtidy --json | jq '.toDelete'
Install
It ships on both registries — half the world's repos sit next to a Node
toolchain, half next to a Python one:
npx branchtidy # Node — zero deps
pipx run branchtidy # Python — pure stdlib
Both ports share one table of selection vectors, so they make byte-for-byte
identical decisions — proven by building a throwaway repo with merged / stale /
protected / current branches, running both CLIs in --json mode, and diff-ing
the output.
A few design notes
-
One pure function at the core.
selectBranches(branches, policy, nowMs)takes a snapshot of branches and returns{toDelete, toKeep}with a reason on each — no git, no fs, no clock. The CLI is a thin git wrapper around it. That separation is why the two builds can be proven equivalent: all the judgement lives in a function you unit-test with a literal data table. -
Time is integer math. Ages come from
committerdate:unixagainst a single capturednowin ms — noDate/datetimeparity games between languages. The staleness test is a strict>, so a branch exactly on the threshold is kept. -
Zero dependencies, zero config. It's a filter over
git for-each-refandgit branch --merged.npx/pipxit on demand; no daemon to install.
Try it / break it
It's MIT and tiny. Code, issues, and full README:
Run it in your messiest repo — dry-run can't hurt anything — and tell me what it
got wrong. What's your current branch-cleanup ritual: a shell alias, a script, or
just letting them pile up forever?
Top comments (0)