branchtidy
v0.1.0
Published
Find and delete merged & stale git branches, safely. Dry-run by default, never touches main/master/develop/current, handles local + remote. Zero dependencies.
Maintainers
Readme
branchtidy
Delete merged & stale git branches — safely. branchtidy finds the local (and
optionally remote) branches that are already merged, or haven't seen a commit in
N days, previews them, and deletes them in one batch. It is dry-run by
default, refuses to touch main / master / develop / your current branch,
and won't nuke unmerged work unless you explicitly ask.
Zero dependencies. Zero config. No daemon, no account, nothing to set up.
npx branchtidybranchtidy local branches · default main · stale > 90d
BRANCH LAST COMMIT MERGED ACTION
feature/login 12d ago yes delete (merged)
feature/old-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.Nothing was deleted. That's the point — you read the table, then decide.
Why another branch cleaner?
Everyone reinvents this as a throwaway git branch --merged | grep -v ... | xargs
one-liner, and those one-liners are exactly how people delete branches they
wanted. branchtidy's whole pitch is safety + zero config:
- Dry-run is the default. No flags → it only prints what it would do.
- Real deletion is gated twice:
--deleteand an interactive confirm (skip the prompt only with--yes). - Protected branches are never candidates:
main,master,develop, the currentHEAD, plus anything you pass to--protect. - Merged vs unmerged is respected. Merged branches use the safe
git branch -d. Unmerged branches are only deletable with an explicit--force(which maps togit branch -D). - Remote deletion is double-gated: it requires
--remote --deleteand its own confirmation, and usesgit push <remote> --delete.
When in doubt, branchtidy keeps the branch.
Install
npx branchtidy # no install, run on demand
npm i -g branchtidy # or install the `branchtidy` command globallyThere's an identical Python build too: pipx run branchtidy /
pip install branchtidy (see
branchtidy-py). Both ports share one
selection-vector table, so they make byte-for-byte identical decisions.
Usage
branchtidy [options] # dry-run preview (default — deletes nothing)
branchtidy --delete # actually delete, after a confirm| Option | Description |
| --- | --- |
| --delete | Perform deletion. Without it, branchtidy only previews. |
| --yes | Skip the interactive confirm (use with --delete, e.g. in scripts). |
| --stale <dur> | Staleness threshold. Default 90d. Accepts 30d, 2w, 12h, 45m, 30s, or a bare number (days). |
| --merged-only | Only delete merged branches; never delete on age alone. |
| --remote [name] | Operate on remote-tracking branches (default remote: origin). |
| --protect <a,b> | Extra branch names to never delete (comma-separated). |
| --force | Allow deleting unmerged branches (maps to git branch -D). |
| --json | Machine-readable output; never prompts, never deletes (preview only). |
| --no-color | Disable ANSI colors. |
| -h, --help | Show help. |
| -v, --version | Print version. |
Exit codes: 0 success/clean, 1 one or more deletions failed, 2 usage or
environment error (e.g. not a git repo).
Examples
# what WOULD be cleaned up, right now?
branchtidy
# stricter window, only merged branches, do it (with a confirm)
branchtidy --stale 30d --merged-only --delete
# clean up gone-stale remote branches on origin (double-gated + confirm)
branchtidy --remote origin --delete
# delete unmerged stale branches too — you have to ask for it
branchtidy --stale 180d --delete --force
# protect a couple of long-lived branches by name
branchtidy --protect release/v1,staging --delete
# pipe the plan somewhere
branchtidy --json | jq '.toDelete'How it decides
For each branch branchtidy looks at: is it the current HEAD? is it protected?
is it merged into the default branch? how old is its last commit? Then:
- current branch → keep (
current) - protected (default set or
--protect) → keep (protected) - merged → delete (
merged) - otherwise, if older than
--stale→ delete (stale <N>d) - otherwise → keep (
active)
In --merged-only mode, step 4 is skipped entirely — age never causes a
deletion.
The default branch is resolved from origin/HEAD when available, otherwise it
falls back to main, then master.
Design notes
- One pure function at the core.
selectBranches(branches, policy, nowMs)has no git, no fs, no clock — it's a pure data→data transform that returns{toDelete, toKeep}with a reason on every branch. The CLI is a thin git wrapper around it. That's what makes the Node and Python ports verifiably identical: they run the same vector table. - Time is integer math. Ages are computed from
committerdate:unixagainst a single capturednowin milliseconds — noDate/datetimeparity to worry about between languages. - Safe by construction. Protected and current branches are filtered out
before any staleness logic runs, the staleness test is a strict
>(a branch exactly at the threshold is kept), and deletion always passes through the safegit branch -dunless you opt into-Dwith--force.
License
MIT
