Last week I ran git branch in one of my older working trees and got back forty-some local branches. Forty. I work on maybe three things at a time. The rest were residue: half of them were merged into main months ago, a few tracked remote branches that someone had already deleted on the server, and the rest I genuinely did not recognize anymore.
The problem with this pile is not disk space. A local branch ref is tiny. The problem is that it makes every other git command worse. git log --all gets noisy. Tab completion for branch names becomes useless. And the moment you want to prune, you freeze, because deleting a branch you think is dead is exactly the kind of operation that bites you six weeks later when you need a commit that only existed there.
I tried the usual tricks. git branch --merged main gives you a real signal, but it also happily lists your current branch, and if you pipe that straight into a delete you can shoot yourself in the foot. git branch -vv shows [gone] for branches whose upstream vanished, but you have to eyeball it across dozens of lines. And neither tool says anything at all about the branch you touched last in March and never came back to. There was no single command that gave me a reason per branch. So I wrote one.
What I built
git-stale is a single Python file. The published tool.py is 5,092 bytes and imports four things, all from the standard library: argparse, subprocess, sys, and datetime. No pip installs, no virtualenv, no version-pinning argument with a dependency that no longer exists. If you have Python 3.8 and git on your PATH, it runs. That was a deliberate constraint, not laziness: a cleanup tool that itself requires an install step is a tool I will not actually run, and a supply chain of one file has nothing to rot.
The core idea is that a branch is stale if it matches any of three independent checks, and the tool reports which checks fired rather than a bare list:
- it is fully merged into the default branch (
mainormaster), - its upstream remote branch no longer exists (
[gone]), - its last commit is older than a threshold (30 days by default).
The detection is thin wrapper-over-git on purpose. I did not reimplement merge-base logic or parse the index. I ask git itself, because git already knows the truth and I would only introduce a divergence bug by trying to agree with it:
def merged_branches(base, cwd=None):
out = run_git("branch", "--merged", base, cwd=cwd)
return {line.strip().lstrip("* ") for line in out.splitlines() if line.strip()}
def remote_gone_branches(cwd=None):
gone = set()
out = run_git("branch", "-vv", cwd=cwd)
for line in out.splitlines():
if "[gone]" in line:
gone.add(line.strip().lstrip("* ").split()[0])
return gone
def commit_date(branch, cwd=None):
out = run_git("log", "-1", "--format=%ct", branch, cwd=cwd)
return datetime.fromtimestamp(int(out), tz=timezone.utc)
Enter fullscreen mode Exit fullscreen mode
find_stale stitches the three together, and this is where the safety actually lives: the current branch and the default branch are skipped before a reason is ever attached. You will never see the branch you are standing on in the output.
def find_stale(days, cwd=None):
base = default_branch(cwd=cwd)
cur = current_branch(cwd=cwd)
merged = merged_branches(base, cwd=cwd)
gone = remote_gone_branches(cwd=cwd)
cutoff = datetime.now(timezone.utc).timestamp() - days * 86400
stale = {}
out = run_git("branch", "--format=%(refname:short)", cwd=cwd)
for branch in out.splitlines():
branch = branch.strip()
if not branch or branch == cur or branch == base:
continue
reasons = []
if branch in merged:
reasons.append("merged into " + base)
if branch in gone:
reasons.append("upstream gone")
if commit_date(branch, cwd=cwd).timestamp() < cutoff:
reasons.append(f"untouched > {days}d")
if reasons:
stale[branch] = reasons
return stale
Enter fullscreen mode Exit fullscreen mode
The default branch is not hard-coded. default_branch() probes refs/heads/main, falls back to refs/heads/master, and the whole repo works the same on either name. Output is one line per branch with the reasons joined:
feature/auth-redo: merged into main
wip/scraper-v2: upstream gone; untouched > 30d
Enter fullscreen mode Exit fullscreen mode
Running it with no flags is read-only. You see the list, you decide, nothing changes. Deletion is opt-in and, by default, asks you to confirm each branch one by one:
python3 tool.py # just list
python3 tool.py --days 90 # a looser age threshold
python3 tool.py --delete # confirm per branch
Enter fullscreen mode Exit fullscreen mode
The reason --delete is separate from listing is behavioral, not technical. A tool that cleans up after you and a tool that cleans up without asking are different products, and the second one you only appreciate after it deletes the wrong thing once.
What it does not do
Being straight about the boundaries matters more than the feature list here.
Deletion uses git branch -D, the force flag. That means it does not re-verify that a branch is merged at the moment of deletion. It trusts the reasons it printed and trusts you to have read them. I made that choice so the tool can delete [gone] and untouched branches that are legitimately unmerged but clearly abandoned, but the cost is real: read the reasons before you answer y. The README says this explicitly rather than burying it.
The age check keys off the branch tip’s commit date. If you have a three-week-old branch you only push to once a quarter, it will show up at the default 30 days. That is a listing, not a deletion, and --days is there for exactly that case.
It reads local branch refs against your last-known view of the remote. If your remote-tracking refs are stale because you have not fetched, [gone] detection is only as good as your last fetch. There is no silent git fetch behind the scenes; the tool does not touch the network.
It has no idea what your team’s workflow is. A long-lived release/* branch will happily be flagged as untouched. The tool surfaces candidates with reasons; the judgment call stays with you on purpose.
And there is a self-test, because I did not want to ship a thing whose whole job is deleting refs based on my own confidence. It creates a throwaway repo in a TemporaryDirectory, builds a known branch topology, and asserts that merged-into-main shows up, that a fresh unmerged branch does not, and that main and the checked-out branch never appear:
python3 tool.py --self-test
# self-test OK
Enter fullscreen mode Exit fullscreen mode
That runs in the CI alongside a fresh clone. If a future edit makes the tool report the branch you are standing on, the test fails and nothing gets published. I would rather catch that on a temporary directory than in someone’s real working tree.
Try it
If your git branch output has drifted into archaeology, run it read-only first and just look at the reasons. It is one file you can read top to bottom before you let it near your repo.
git clone https://github.com/Raknaos/git-stale
cd git-stale
python3 tool.py --self-test # prove it to yourself first
python3 tool.py # in any repo you want to inspect
Enter fullscreen mode Exit fullscreen mode
The source is at https://github.com/Raknaos/git-stale (MIT). The whole thing lives in that single tool.py; open it before you run it, and if the age default is wrong for how you work, change the number. The tool will not touch a branch you did not explicitly tell it to.