An interactive terminal UI for pruning local git branches. It lists branches like
git branch -vv --sort=-committerdate (most recently committed first), lets you re-sort on the
fly, multi-select branches, and delete them — with a per-branch option to also delete the
matching remote branch.
You can also view a branch's changes as a colorized diff, and fetch-and-prune to find branches whose upstream has been deleted so they can be cleaned up in one step.
Above: p fetches and prunes, marking three branches whose upstream was deleted and selecting
them; space and r add a merged branch and arm its remote deletion too; d opens the
confirmation, where R deletes local and remote.
Requires Go 1.26+ and git on your PATH.
./install.sh # build + install to a user bin directory
./install.sh --bindir ~/bin # ...or pick the directory yourselfWith no flags it installs to the first usable of ~/.local/bin or ~/bin, falling back to
/usr/local/bin (via sudo), and warns if the directory is not on your PATH. See
./install.sh --help for the details.
Then run it from inside any git repository:
git_prunerTo print the build's commit and date (from Go's automatic VCS stamping):
git_pruner version # also --version, -v| Key | Action |
|---|---|
↑/k, ↓/j |
Move cursor |
g / G |
Jump to top / bottom |
space |
Toggle selection (locked branches cannot be selected — see Protected branches) |
a / n |
Select all listed branches / clear selection |
m |
Select listed branches that are merged and older than N days (prompts for N) |
tab |
Switch between local branches and remote-only branches |
c |
Checkout the branch under the cursor (git switch); on a remote-only row, create a local branch that tracks it |
/ |
Filter branches by name; enter keeps it, esc clears it |
r |
Toggle "also delete remote" for the row (needs an upstream) |
v |
View the branch's diff (through delta when installed) |
x |
Select gone branches that hold no unique work (no fetch) |
p |
Fetch --all --prune, then select gone branches that hold no unique work |
s |
Cycle sort field: committerdate -> name -> ahead/behind |
o |
Reverse sort direction |
f |
Toggle delete mode: safe -d <-> force -D |
u |
Undo: recreate the local branches the last delete removed |
d / enter |
Go to the confirmation screen |
? |
Help screen (build metadata, keybindings, column guide) |
q / ctrl+c |
Quit |
On the confirmation screen, n/esc cancels. When no remote deletions are armed, y deletes.
When at least one remote deletion is armed (R in the row), the prompt splits so remote
deletion is never a single accidental keystroke: y deletes local branches only (sparing
the remotes), while R deletes local + remote. If any branch is then refused by the safe
delete (-d) because it isn't fully merged, a follow-up prompt offers to force delete (-D)
just those branches — y discards their unmerged commits, n/esc keeps them.
Deletions run in the background with a live progress screen (a spinner plus a per-branch
checklist), so the UI stays responsive while remote pushes complete. ctrl+c there asks for a
second press: quitting mid-run can leave a remote branch behind a deleted local one.
On the results screen, u recreates the deleted local branches at their old commits, with
their upstream config. enter returns to the list, and q quits. When you quit, git_pruner
prints a restore command for every branch it deleted this session, so the way back stays in your
scrollback:
git_pruner: to restore a deleted branch, run:
git branch feature/foo 3e2210a…
git push origin 3e2210a…:refs/heads/feature/foo
In the diff view: ↑/↓ scroll, space/ctrl+d page down, ctrl+u/pgup page up,
g/G jump to top/bottom, and q/esc/v return to the list.
The confirmation, force-delete, progress and results screens scroll with the same keys, so a
wide selection never pushes the prompt off the bottom of the terminal. The answer keys take
precedence, so y, R and n still work while a list is scrolled.
> [x] R * feature/foo ↑2↓1 ✓ 3 days ago a1b2c3d Fix the thing
>cursor,[x]selected,Rremote deletion armed*current branch,+checked out in another worktree,Pprotected — all three are locked- ahead/behind shown as
↑N↓M(=when in sync,gonein red when the upstream was deleted) - a green
✓after the track column means the upstream is merged into the remote default branch — i.e. the remote is safe to delete - relative commit date, short hash, and commit subject. On a narrow terminal the relative date, then the hash, then the date drop out, so a row never wraps
Press v to see what a branch contains as a colorized patch — green for additions, red
for removals, magenta hunk headers. The diff is computed against the repository's default branch
(see Resolving the default branch) using a three-dot diff
(git diff <base>...<branch>), so it shows only the changes introduced on that branch since it
diverged. The view is scrollable for large diffs; the header shows which base it was compared to.
The default branch is used as the diff base, as the merge target for the ✓ indicator, and to
measure what a force delete would discard. It resolves to <remote>/HEAD if set, else
<remote>/main, else <remote>/master, trying each configured remote in turn with origin
first — so repositories whose only remote is named something else (upstream, a fork, …) still
get merge information. If no remote resolves, a local main/master is used.
Press p to run git fetch --all --prune in the background (the UI stays responsive). Once it
finishes, any local branch whose upstream was deleted is marked gone, and a status line
reports what was found. Press d to review and delete them.
If the branches are already marked gone (from an earlier fetch), press x to select them
without another fetch. Both keys skip gone branches that hold commits not in the base branch;
select those with space if you really want to discard them.
This is the interactive equivalent of:
git fetch --all --prune && git branch -vv | awk '/: gone]/{print $1}' | xargs git branch -DGone branches are always removed with git branch -D (force), since -d refuses a branch whose
upstream no longer exists — this is why selecting them via x/p prunes them even in safe mode.
Because -D discards unmerged commits and git reports no ahead/behind count for a gone
branch, git_pruner measures each one against the default branch with git cherry and counts
the commits that have no equivalent patch there:
- gone branches holding no such commits are auto-selected by
p— the one-keystroke workflow - gone branches that do hold unique commits are left unselected and reported in the status
line, so discarding them takes a deliberate
space; the confirmation screen then shows⚠ N commit(s) not in <base> — force delete (-D) will discard them
git cherry is used rather than git rev-list <base>..<branch> so commits that were
cherry-picked, rebased, or squashed individually into the base are correctly recognized as
already integrated. A group of commits squashed together into one still counts as unique, since
no single equivalent patch exists — which is why the warning reads "not in <base>" rather than
claiming the work is unrecoverable.
- Local:
git branch -dby default (refuses unmerged branches);fswitches togit branch -D. Branches whose upstream is gone are always deleted with-D, regardless of the mode, and the confirmation screen flags any commits that would be discarded (see above). When a-ddelete is refused for being unmerged, a follow-up prompt lets you retry those branches with-Dwithout leaving the results — no need to back out and re-select. - Remote: when armed with
r, runsgit push <remote> --delete <branch>, where the remote is derived from the branch's upstream. Because this affects shared history, remote deletion requires the explicitRkey on the confirmation screen — plainydeletes locals only. The confirmation screen also shows, per branch, whether the upstream is merged into the remote default (✓ merged/⚠ not merged) to help you judge whether the remote is safe to delete. - A confirmation screen always lists exactly what will be deleted before anything happens. Deletions then run concurrently in the background on a live progress screen, and a results screen reports per-branch success or failure.
Press m, type an age in days (it starts at 90, or at pruner.staleDays), and press enter.
git_pruner selects every listed branch that is merged into the default branch and whose last
commit is older than that. A local branch counts as merged when its tip is in the default
branch, or when its upstream is merged and it has no commits of its own on top. Like a, it only
acts on the listed rows, so a / filter narrows it.
Press tab to list the remote branches that no local branch tracks — work other people pushed,
or branches you deleted locally but not on the remote. They are read on first use, so startup
does not pay for them. A ✓ means the branch is merged into the remote default; the
confirmation screen counts the commits of any branch that is not.
Deleting one is a push, so only R does it; y never touches a remote. The exit summary
prints the git push command that puts a deleted remote branch back.
The default branch is always protected. Add your own with name globs:
git config --add pruner.protect 'release/*'
git config --add pruner.protect developA protected branch shows P and cannot be selected by any key. Remote rows are matched by the
branch part of their name, so release/* covers origin/release/1.0 too.
The same selection rules work without the screen, for cron jobs or shell aliases:
git_pruner --prune-gone # list what would be deleted
git_pruner --fetch --prune-gone --yes # fetch, then delete
git_pruner --merged-older-than 90 --yes # delete merged branches older than 90 daysNothing is deleted without --yes (--dry-run forces a listing even with it). Script mode never
deletes remote branches, and never deletes a gone branch that holds commits missing from the
default branch. It prints restore commands for what it deleted, and exits 1 if any delete failed.
git fetch and git push --delete run with GIT_TERMINAL_PROMPT=0 and without a terminal, and
stop after 60 seconds. A remote that asks for a password or does not answer fails with a message,
instead of freezing the screen. Use a credential helper or an ssh agent for remotes that need a
login.
The sort field and order are saved in ~/Library/Application Support/git_pruner/settings
(macOS) or ~/.config/git_pruner/settings (Linux), and restored on the next start.
make build # build straight to $BINDIR (default ~/shared/bin; override with BINDIR=...)
make test # go test ./...
make vet # go vet ./...
make clean # remove the binary from $BINDIR
make assets # re-record the README's demo GIF and screenshot (needs vhs)CI runs gofmt, go build, go vet, and go test -race on Linux and macOS for every push to
master and every pull request (.github/workflows/ci.yml).
The README's assets/demo.gif and assets/branches.png are generated, not hand-captured — rerun
make assets (needs vhs: brew install vhs) after any
change to the UI. It builds a throwaway repo under /tmp/git_pruner-demo with branches in every
interesting state — merged, stale upstream, unmerged work, no upstream — then records both assets
against that one repo so their commit hashes agree. The two tapes share their terminal settings
and setup via assets/common.tape, since the GIF and the still sit side by side in this file and
would look mismatched if the width or theme drifted between them.
docs/improvements.md records the codebase analysis, the reasoning behind
the current safety behavior, and the roadmap of remaining work.
The code is split by layer: git.go (every git call), model.go (state and key handling),
view.go (rendering), cli.go (script mode), settings.go, and main.go.
The test suite drives a real git binary against throwaway repositories created per test, so it
needs git on PATH and a committer identity (user.name / user.email); the tests set one
inside each temporary repo.

