gh-worktree-gc
Prune merged and stale git worktrees safely — clean-only, so it can never lose work. The companion to gh-issue-lease: that one stops work stampedes, this one stops worktree bloat.
When a fleet of agents (or one busy human) spins up a worktree per task, they pile up fast — dozens of merged branches with dead worktrees hanging off them. gh-worktree-gc cleans them up without ever touching anything that could hold unsaved work.
npx gh-worktree-gc # safe dry-run: shows the plan
npx gh-worktree-gc --apply # execute
Zero runtime dependencies. One small file. gh is optional (used only to catch squash/rebase merges).
The safety invariant
Automation only ever removes CLEAN worktrees. A clean worktree has no uncommitted changes, so removing it cannot lose anything — the code is safely on a branch. Anything dirty or detached is BLOCKED — listed for you to handle by hand, never auto-removed (unless you pass --force and mean it).
| Worktree state | Action on --apply |
|---|---|
| merged, clean | remove worktree + delete its local branch |
| stale (old), clean, unmerged | remove worktree, keep the branch (work is parked, not lost) |
| dirty / detached | BLOCKED — reported, never touched (needs --force) |
protected (primary checkout, current worktree, bare, locked, /tmp) |
never touched |
Why gh matters here
"Merged" is detected two ways and OR'd together:
- git ancestry (
git branch --merged <base>, one batched call) — fast, local, no network. - merged PR head branches from
gh pr list --state merged— whenghis available.
That second signal is the important one: a squash or rebase merge creates new commits on the base that are not ancestors of your branch, so a pure git check thinks the branch was never merged and leaves the worktree behind forever. Pulling the merged-PR list from GitHub catches exactly those. If gh isn't installed or authed, it silently falls back to ancestry-only (pass --no-gh to force that).
Install & use
npx gh-worktree-gc # one-off, no install
npm i -D gh-worktree-gc # or as a dev dependency
Requires git. gh (authenticated) is optional but recommended.
| Flag | Default | Effect |
|---|---|---|
| (none) | — | Dry run. Print the plan, change nothing. |
--apply |
off | Execute the removals. |
--merged-only |
off | Ignore stale age; only prune merged worktrees. |
--stale-days <n> |
14 |
Age (days, by last commit) at which an unmerged clean worktree is "stale". |
--base <ref> |
remote default branch | What "merged" is measured against (auto-detects origin/HEAD). |
--force |
off | Also remove dirty/detached worktrees. Dangerous — can lose uncommitted work. |
--no-gh |
off | Skip PR-merge detection; use git ancestry only. |
--no-fetch |
off | Don't git fetch first. |
--include-external |
off | Don't protect /tmp, /private/tmp, /var/folders worktrees. |
--json |
off | Machine-readable plan. |
--quiet |
off | Suppress the report (for hooks/cron). |
Programmatic API
import { classify, parseWorktrees, resolveDefaultBase } from "gh-worktree-gc";
// classify() is the pure decision function; the rest are the git helpers.
Automate it
Post-merge hook (opt-in) — GC after a local merge into the default branch:
mkdir -p .githooks
cp node_modules/gh-worktree-gc/hooks/post-merge .githooks/post-merge
chmod +x .githooks/post-merge
git config core.hooksPath .githooks
git config worktreeGc.enable true
Most merges happen on GitHub (which a local hook can't see), so the reliable catch-all is a periodic run — a cron, or after each git fetch:
gh-worktree-gc --apply --quiet
Because it uses gh to find merged PR branches, this reaps the squash/rebase-merged worktrees that a local hook would never notice.
Guarantees & edge cases
| Case | Behaviour |
|---|---|
| Uncommitted changes in a worktree | BLOCKED — never removed without --force. The core safety guarantee. |
| Squash/rebase-merged branch | Detected via gh merged-PR list (git ancestry alone would miss it). |
gh offline / not installed |
Falls back to git-ancestry merge detection; still safe, just misses squash/rebase merges. |
| Current worktree / primary checkout | Protected — never touched. |
| Detached HEAD | BLOCKED (could hold commits not on any branch). |
| Stale but unmerged | Worktree removed, branch kept — parked work is never deleted. |
| Bare / locked worktrees | Protected. |
| Very large merged-PR history | gh pr list is capped at 300 by default; older merges fall back to ancestry. |
License
MIT Mackenzie Robertson