Find and delete merged & stale git branches, safely. Dry-run by default, never touches main/master/develop/current, handles local + remote. Zero dependencies.
Project description
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 (pure Python stdlib). Zero config. No daemon, no account.
pipx run branchtidy
branchtidy 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
pipx run branchtidy # no install, run on demand
pip install branchtidy # or install the `branchtidy` command
There's an identical Node build too: npx branchtidy / npm i -g branchtidy
(see branchtidy). 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.
select_branches(branches, policy, now_ms)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 — nodatetimeparity 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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file branchtidy-0.1.0.tar.gz.
File metadata
- Download URL: branchtidy-0.1.0.tar.gz
- Upload date:
- Size: 14.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50c24a9d0193594d001379f533dbd922213b706916d6ccb3028dd2f85d3a6a76
|
|
| MD5 |
40906ad1182df9b0d44252c551f40747
|
|
| BLAKE2b-256 |
a3fd3a38c3d74d44e02a2f1c6a540f596524af0748e427be8388d019c835dd6f
|
File details
Details for the file branchtidy-0.1.0-py3-none-any.whl.
File metadata
- Download URL: branchtidy-0.1.0-py3-none-any.whl
- Upload date:
- Size: 11.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a026ba2f6bc46216047ace817e781d63658171de9150cec4944c5ef6cbb02ed2
|
|
| MD5 |
f0415f09dadd724479f7aec2965c3f0e
|
|
| BLAKE2b-256 |
48fd3f56c0edcfa2a3d08f110c1a38e1fb32365159276a07faf6087bd4bad830
|