DEV Community

Cover image for git branch -d is fine — until the day you delete the wrong one
benjamin
benjamin

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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.
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

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)