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.
- 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.
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, or an unexpected error was thrown130—ctrl-cat ay/nforce-removal prompt
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.
The tool takes no arguments or flags; the only environment variables it 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. -
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— orchestrates the flow and drives the background status/PR checkssrc/git.ts— git command wrappers (list, dirty check, PR-state check, remove, branch delete, prune)src/ui.ts— the selection TUI and the dirty/locked confirmation promptsrc/spinner.ts— the multi-line animated spinner group used during parallel removalsrc/types.ts— theWorktreerecord shapedocs/— the SVG screenshots used in this READMEdocs/screenshots/— the harness that regenerates them (details)
pnpm build # compile src/ -> dist/ (what the launcher prefers)
pnpm typecheck # tsc --noEmit
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.
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)