git branch -d is fine — until the day you delete the wrong one
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 branchtidylocal branches·default main·stale > 90d BRANCHLAST COMMIT MERGED ACTION feature/login-v212d ago yesdelete (merged) spike/redis-poc 210d agono delete (stale 210d) main2d agono keep (protected) feature/wip 3d agono 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. --delete and an interactive confirm. The prompt only goes away with --yes (for scripts/CI). Protected branches are never candidates. main , master , develop , your current HEAD , and anything in --protect a,b are 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 to git branch -D ). No silent -D . Remote deletion is double-gated: --remote --delete plus its own confirm before any git 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:unix against a single captured now in ms — no Date / datetime parity 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-ref and git branch --merged . npx / pipx it on demand; no daemon to install. Try it / break it It's MIT and tiny. Code, issues, and full README: Node: https://github.com/jjdoor/branchtidy Python: https://github.com/jjdoor/branchtidy-py 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?
Comments
No comments yet. Start the discussion.