An interactive CLI tool for cleaning up git worktrees. Lists your worktrees, lets you select which ones to remove, and deletes the associated branches — all with a custom terminal UI. Also doubles as a worktree picker: hit o on any row to cd your shell straight into that worktree. For unattended use, --auto skips the UI and sweeps the merged and closed worktrees on its own, and --force extends that sweep to the dirty and locked ones.
- Custom-built keyboard-driven TUI (no external prompt library)
- Key bindings:
↑/↓— move cursor; wraps around at both ends of the listspace— toggle selection on the cursor rowc— clean: select every merged/closed worktree and confirm immediately (dirty/locked ones still prompt for force removal)o— open (cd into) the worktree under the cursor and exitenter— confirm selection and remove the checked worktreesqorctrl-c— quit without doing anything
- The cursor row is marked with a cyan
❯and its branch name is bold; selected rows show a green[x] - Main worktree is always hidden from the list — you can never accidentally select it
- Any key that isn't bound does nothing (beyond dismissing a notice, below)
Worktrees appear in the order git worktree list reports them (git's own bookkeeping order, which in practice sorts by the worktree's directory name rather than by creation time).
Each row shows visual tags so you know what you're about to delete:
⚠ dirty— uncommitted changes in the working tree🔒 locked— the worktree is locked (with the lock reason if one was given)✓ merged— the worktree's branch has a merged PR on GitHub (detected via theghCLI)✕ closed— the worktree's branch has a PR that was closed without merging- Detached-HEAD worktrees are shown as
(detached)
A branch with an open PR, or with no PR at all, gets no tag — only the two states that mean "this branch is finished" are called out, and only those two are what c acts on. Detached worktrees have no branch, so they're never looked up.
The dirty, merged, and closed tags are all resolved after the list is on screen (see Startup), so they pop in a moment after the TUI opens. A ⋯ checking … footer shows what's still outstanding and disappears once everything has landed. PR lookups use a 10-second timeout per branch; if gh isn't installed or the lookup fails, the tag is simply omitted — it never blocks the cleanup flow.
Two places wait for this background work, so a fast keypress can never act on incomplete data:
c(clean merged/closed) refuses to run while any PR lookup is outstanding — acting on a partial set would silently skip worktrees that are in fact merged. It says so in the footer and leaves your selection untouched.enterwaits for thegit statuschecks before prompting, so a dirty worktree always gets its force-removal confirmation.
The footer doubles as a place for transient notices, shown in yellow and cleared by the next keypress:
Still checking PR status — try again in a moment.—cpressed before the PR lookups finishedNo merged or closed PRs to clean up.—cpressed when nothing qualifies
- Clean worktrees are removed in one go after the user confirms with enter
- Dirty and/or locked worktrees prompt a per-worktree
y/nconfirmation before being force-removed (--forceonce for dirty, twice for locked, asgit worktree removerequires). The prompt names the reason —has uncommitted changes,is locked (<reason>), or both joined with "and". - Answering
nprintsSkipping <branch>and moves on to the next prompt;ctrl-caborts the whole run. Any other key is ignored, so a stray keystroke can't be read as a yes. - After a worktree is removed, its branch is deleted with
git branch -D. If branch deletion fails (e.g., it's checked out elsewhere), the worktree is still reported as removed and the branch failure is surfaced as a warning rather than an error. - Detached worktrees have no branch, so nothing is deleted after the worktree itself
- Final
git worktree prunecleans up any stale references
Nothing-to-do cases exit quietly with status 0: No additional worktrees found. (the repo has only a main worktree), Nothing selected. (enter pressed with no rows checked), and Nothing to remove. (every prompt was declined).
- Selected worktrees are removed in parallel
- Each removal gets its own animated spinner line (braille frames, updated in place via ANSI cursor moves)
- While a removal is in flight the line shows
<branch> — deleting branch...once the worktree itself is gone - Spinners transition to
✓(success),✗(failure), or⚠(partial — worktree gone but branch couldn't be deleted) - One worktree failing doesn't abort the others; the run still finishes with a prune
Press o on any row to cd your parent shell into that worktree's path and exit. This is implemented by:
- The shell function (installed into
~/.zshrc/~/.bashrcbyinstall.sh) creates a temp file and passes its path to the binary via theGIT_WORKTREE_CLEAN_CD_FILEenv var. - When you press
o, the binary writes the chosen worktree path to that file and exits. - The shell function reads the file and
cds into it.
If the shell function isn't installed (e.g., you ran the binary directly), pressing o prints the chosen path along with a hint to re-run install.sh, since a subprocess can't change its parent shell's directory on its own.
git-worktree-clean --auto does the same sweep the c key does, with no TUI and no keyboard:
git-worktree-clean --auto- Resolves every
git statusand PR lookup first, then removes each worktree whose most recent PR is merged or closed, deletes its branch, and prunes - Only clean worktrees are removed by default. A merged or closed worktree that is dirty or locked would need
--force, and there is nobody to confirm that, so it is named in aSkipping …block with its reason and left alone --forceremoves those too. They are named up front in aForce-removing …block with the same reasons, then removed alongside the clean ones (--forcefor a dirty tree, twice for a locked one). This destroys uncommitted work with no prompt, which is why it takes an explicit flag; the tally counts them as removed rather than skipped- Worktrees with an open PR, no PR, or a detached HEAD are never touched
- Never reads stdin and never moves the cursor, so the report survives being piped to a file or a log. Color still switches itself off when stdout isn't a TTY, or when
NO_COLORis set - Ends with a one-line tally:
Done. Removed 3, skipped 1, failed 0. - Requires the
ghCLI. The TUI degrades quietly without it (the merged/closed tags simply never appear), but--autoacts on exactly those tags, so it says what is missing and exits1rather than reporting an empty sweep
-a / --auto, -f / --force and -h / --help are the accepted spellings, parsed by node:util.parseArgs in strict mode. Short flags bundle, so -af is --auto --force, and a bad letter inside a bundle is named on its own: -ax reports Unknown option '-x'. Attached values (--auto=true), positional arguments and -- are all errors, as is any unrecognised flag. -f on its own is accepted but does nothing, and says so on stderr before the TUI opens.
Before doing anything, the tool chdirs into the main worktree. That way, if you happen to be sitting inside a worktree you're about to remove, the removal doesn't break subsequent git commands (or leave your shell stranded). If your original shell cwd was inside a removed worktree, the tool prints a final reminder telling you to cd into the main worktree.
Tags and status symbols are colored with ANSI escapes: yellow for dirty and warnings, red for locked, closed and failures, green for merged, checkmarks and [x], cyan for the cursor and spinner frames, and dim grey for the header, [ ] and (detached).
Color turns itself off when stderr isn't a TTY (so piping or redirecting gives you clean text) and when NO_COLOR is set to anything.
The TUI and the removal spinners are drawn on stderr. Stdout carries the y/n force-removal prompts and the plain progress lines (Removing 3 worktrees..., Skipping ..., Pruning stale worktree references..., Done.); failures go to stderr.
0— cleanup finished, or you quit withq/ctrl-c, or there was nothing to do1— not inside a git repository,owas pressed without the shell function installed, an unknown argument was passed,--autoran withoutgh, at least one worktree failed to be removed under--auto, or an unexpected error was thrown130—ctrl-cat ay/nforce-removal prompt
A worktree that is removed but whose branch survives counts as success in both modes: the removal is what was asked for, and the branch is reported as a warning.
git clone git@github.com:adrianbw/git-worktree-clean.git
cd git-worktree-clean
./install.shinstall.sh:
- Runs
pnpm install(orcorepack pnpm installas a fallback) to fetch dependencies. - Runs
pnpm buildto compilesrc/todist/— this is what keeps startup fast (see Startup). - Symlinks
bin/git-worktree-cleaninto~/.local/bin/. - Appends a small shell function to
~/.zshrcand~/.bashrcso theo(open) key cancdyour parent shell. The block is idempotent — re-runninginstall.shwon't add it twice. - Warns if
~/.local/binisn't on yourPATH.
Requirements: git, Node.js, and pnpm (or corepack). The gh CLI is optional and only used to detect merged/closed PRs.
From inside any git repository:
git-worktree-cleanYou'll see a checkbox list of every worktree except the main one. Select the ones you want removed and press enter, press c to sweep every merged/closed worktree at once, or press o to jump into the worktree under the cursor.
Or skip the UI entirely:
git-worktree-clean --auto # remove every clean merged/closed worktree, then report
git-worktree-clean -af # the same sweep, plus the dirty and locked ones, no prompts
git-worktree-clean --help # usageThose are the only flags. The only environment variables the tool reads are GIT_WORKTREE_CLEAN_CD_FILE (set for you by the shell function) and NO_COLOR.
The file at bin/git-worktree-clean (symlinked into your ~/.local/bin/) is a small bash wrapper whose job is to locate the repo checkout and run the app. Here's what it does step by step:
SOURCE="$0"
while [ -L "$SOURCE" ]; do
DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
done
DIR="$(cd "$(dirname "$SOURCE")/.." && pwd)"
BUILT="$DIR/dist/main.js"
if [ -f "$BUILT" ] && [ -z "$(find "$DIR/src" -name '*.ts' -newer "$BUILT" -print -quit)" ]; then
exec node "$BUILT" "$@"
fi
exec "$DIR/node_modules/.bin/tsx" "$DIR/src/main.ts" "$@"- Resolve symlinks —
~/.local/bin/git-worktree-cleanis a symlink pointing tobin/git-worktree-cleaninside the repo. Thewhileloop follows the chain of symlinks until it reaches the real file. At each step it resolves relative symlink targets into absolute paths. - Find the repo root — Once it has the real file path (inside
bin/), it goes up one directory (/..) to get the repo root and stores it inDIR. - Prefer the compiled build — If
dist/main.jsexists and no.tsfile undersrc/is newer than it, run it with plainnode. This skipstsx's on-the-fly transpile, which is most of the fixed startup cost. - Otherwise fall back to
tsx— Ifdist/is missing or stale, it runs the TypeScript source directly, so editingsrc/always takes effect without a rebuild (you just pay the transpile cost until you runpnpm build).
The net effect: you can call git-worktree-clean from anywhere on your system, and it always runs the code from the cloned repo using the repo's own dependencies — fast when built, still correct when not.
install.sh appends this function to your shell rc files so the o (open) key can change your shell's working directory:
git-worktree-clean() {
local cd_file
cd_file="$(mktemp -t gwtc.XXXXXX)" || return 1
GIT_WORKTREE_CLEAN_CD_FILE="$cd_file" command git-worktree-clean "$@"
local rc=$?
if [ -s "$cd_file" ]; then
cd "$(cat "$cd_file")" || true
fi
rm -f "$cd_file"
return $rc
}It creates a temp file, hands its path to the binary via GIT_WORKTREE_CLEAN_CD_FILE, and after the binary exits, cds into whatever path the binary wrote to that file. command git-worktree-clean bypasses the function itself so we actually invoke the binary on PATH.
-
Checks you're inside a git repo (
git rev-parse --git-dir) -
Runs
git worktree list --porcelainand parses the porcelain output into structured worktree records — pulling out the path, HEAD, branch ref, and anylockedreason. The first block (the main worktree) is skipped from the picker but its path is kept forchdir-ing into safely. -
With
--auto, checks thatghruns, awaits everygit statusand PR lookup, then removes the clean merged/closed worktrees — plus the dirty and locked ones when--forceis set — and prints the report: the checks, removal and prune below, without the TUI or the prompts. Otherwise: -
Renders the TUI immediately, with
isDirtyandprStatestill unresolved -
In the background, and all concurrently:
git -C <path> status --porcelainper worktree to detect uncommitted changesgh pr list --head <branch> --state all --json state --limit 1per branch, reading the most recent PR's state to flagmergedandclosed(10s timeout, soft-fails)
Each result mutates its worktree record and repaints the affected row. Detached worktrees skip the
ghcall entirely. -
User toggles selections and confirms, sweeps every merged/closed worktree with
c, opens a worktree, or quits -
Waits for the
statuschecks, then promptsy/nper selected dirty/locked worktree to confirm force removal — including for worktrees thatcselected -
Removes selected worktrees in parallel (
git worktree remove, with--forcefor dirty and--force --forcefor locked), deletes their branches (git branch -D), and shows progress with animated spinners -
Runs
git worktree pruneto clean up stale references -
Warns if the shell's original
cwdwas inside a removed worktree
Nothing slow sits between launch and the first frame. Three things make that work:
- The list is painted before anything is known about it. Parsing
git worktree list --porcelaintakes ~15ms; the per-worktreegit statusandghcalls are the slow part, so they run after the TUI is up rather than before it, and rows gain their tags as results arrive. - The background checks all run concurrently rather than one worktree at a time.
git statusbails early. Detecting "dirty" only needs to know whether there is any output, so it's spawned rather than buffered and killed on the first byte — no need to finish walking the tree. (This also removes a latent bug: a worktree dirty enough to overflow the old 1MBexecSyncbuffer used to be silently reported clean.)
On a monorepo with 9 worktrees, time-to-first-frame went from ~3.7s to ~140ms (~27×), and full decoration from ~3.7s to ~1.3s. Roughly 170ms of the fixed cost came from tsx transpiling on every run, which the compiled build removes.
bin/git-worktree-clean— bash launcher (resolves symlinks, prefersdist/, falls back totsx)install.sh— installs deps, builds, symlinks the binary, adds the shell functionsrc/main.ts— parses the flags, then orchestrates either flow and drives the background status/PR checkssrc/auto.ts— the headless--autoflow and its plain-text reportsrc/git.ts— git command wrappers (list, dirty check, PR-state check, remove, branch delete, prune)src/remove.ts— the parallel removal pass both flows share, reporting through spinners or plain linessrc/ui.ts— the selection TUI and the dirty/locked confirmation promptsrc/spinner.ts— the multi-line animated spinner group used during parallel removalsrc/color.ts— ANSI helpers, switched off per output streamsrc/types.ts— theWorktreerecord shapedocs/— the SVG screenshots used in this READMEdocs/screenshots/— the harness that regenerates them (details)test/— the end-to-end suite, run bypnpm test
pnpm build # compile src/ -> dist/ (what the launcher prefers)
pnpm test # run the end-to-end suite (node:test)
pnpm typecheck # tsc --noEmit, over src/ and test/
pnpm screenshots # regenerate the SVGs in docs/ from the real binaryYou don't have to rebuild while iterating — the launcher notices when src/ is newer than dist/ and falls back to tsx. Run pnpm build when you're done to get the faster startup back.
pnpm test drives the launcher end to end against the same throwaway repo the
screenshots use (docs/screenshots/demo-repo.sh), with gh stubbed. Each test
builds its own copy under mktemp, so no state is shared and each cleans up
after itself. They assert on the report, the exit code, and what git worktree list
and git branch say afterwards:
- The sweep removes exactly the clean merged/closed worktrees and their branches, and leaves open-PR, no-PR and detached ones alone
- A merged worktree that is dirty, or locked, is named in the skip block and survives
- A second run reports nothing to remove
- A blocked removal exits
1, is counted in the report, and deletes no branch - Missing
ghexits1and removes nothing --help, an unknown argument, and a repo with only a main worktree- The report carries no ANSI escapes, and nothing is written to stderr, when stdout is a pipe
The TUI itself is not unit-tested — it needs raw-mode stdin and a pty. The screenshot harness covers that path, and it fails loudly if a frame changes.
If you change how the TUI looks, run pnpm screenshots to refresh the images above. It drives the real binary against a throwaway repo under a pty, so the screenshots can't drift from actual behaviour — see docs/screenshots/README.md.
- Git
- Node.js
- pnpm (or corepack) — install/build-time only
ghCLI (optional, only used to detect merged/closed PRs)