git-reap: deleting old branches and the worktrees sitting on them

I wrote git-reap because git branch --merged | xargs git branch -d in my dotfiles stopped being enough. It didn’t handle branches that aren’t merged but are finished anyway, and worktrees. I also wanted a clean interface, which many of the other “git cleanup” alternatives don’t provide.

Worktree cleanup became important when I started using agents more. Claude Code and similar tools leave detached worktrees under .claude/worktrees, and they accumulate quietly. A worktree also pins the branch it has checked out, so git refuses to delete that branch — which means a branch-only cleanup can’t finish the job.

git reap finds four kinds of leftovers in one pass, shows you what it found, and deletes only what you mark. I particularly like the integration with fzf to provide a clean, clear way to select what to clean up.

git reap picking through the candidates in a repository

What it considers dead

ReasonWhat it means
mergedalready contained in the base branch
upstream gonethe remote branch was deleted — what a squash-merged PR leaves
unusedno commits in the last --days days (90 by default)
detacheda clean worktree on a detached HEAD, idle for --days days

Detached worktrees are matched by age, not by path, so it doesn’t care where your agent tooling puts them. Worktrees are removed before branches, for the reason above.

Never touched: the base branch, the branch you’re on, the branch the main worktree holds, the main worktree itself, and any worktree that’s locked, dirty, or currently in use.

Using it

Install:

go install github.com/m-d-brown/git-reap@latest

Anything named git-reap on your PATH is reachable as git reap, so that’s the whole installation. fzf drives the picker; without it, --dry-run and --all still work.

git reap                # pick through the candidates
git reap -n             # just look, and see what was skipped and why
git reap -a -y          # take everything, no questions
git reap -d 30          # count a month of silence as unused
git reap develop        # measure "merged" against develop

TAB marks a row, Enter deletes what’s marked, Esc walks away. The preview pane shows the recent history of whatever the cursor is on, which is usually enough to remember what a six-month-old spike branch was for.

--dry-run also reports its omissions, which is where the interesting information tends to be:

worktree  worktrees/csv-export            merged    9 days ago    clean        feature/csv-export
worktree  .claude/worktrees/agent-7f21e0  detached  6 months ago  clean        detached at 1baafd65
branch    chore/bump-deps                 merged    5 days ago    no upstream  chore: bump axios, vite, and typescript
kept    worktree /src/checkout-service/.claude/worktrees/agent-e5a018 (detached but recent)

A couple of implementation notes

Each run starts with git fetch --prune. That’s what makes [gone] mean anything: until the remote refs are pruned, a branch whose upstream was deleted still looks alive. --no-fetch skips it when you’re offline.

Merged branches are deleted with git branch -d, so git gets the last word. upstream gone and unused branches aren’t merged as far as git is concerned, so those need -D. A branch that’s both merged and idle is reported as merged, since that’s the gentler of the two.

Source and issues at github.com/m-d-brown/git-reap.