/wt-gc lists git worktrees with their PR/merge status; /wt-gc prune removes clean merged or closed ones.

A Claude Code mod that adds /wt-gc: a garbage collector for git worktrees.
/wt-gc lists every worktree except the main one, with its path, branch and status. It only reports and never deletes anything./wt-gc prune runs git worktree remove <path> (no --force) on each clean worktree whose status is MERGED or CLOSED, then runs git worktree prune. Branches are kept.Statuses:
DIRTY: git status --porcelain is not empty, or status failed. Never removed.OPEN: the branch has an open PR (gh pr list --head <branch> --state all).MERGED: the branch has a merged PR, or it is an ancestor of the default branch.CLOSED: the branch has a closed PR that was not merged.NO-PR: none of the above, or a detached HEAD.Note: a new branch with no commits of its own counts as MERGED, because it is an ancestor of the default branch.
Needs git. Uses gh (logged in) for PR states; without it only the git merge check is used.
Option defaultBranch: the branch to check merges against. When it is empty, the mod uses origin/HEAD, or main if that is not set.
/plugin install wt-gc --marketplace AliakbarNateghi/cc-wt-gchooks/register.ts 81 lines1import type { Register } from 'claude-code'
2
3type Status = 'MERGED' | 'CLOSED' | 'OPEN' | 'NO-PR' | 'DIRTY'
4
5export const register: Register = (on, options) => {
6 on('session.start', async ($, e, next) => {
7 await $.command.register({
8 name: 'wt-gc',
9 description: 'List git worktrees with PR/merge status. "prune" removes clean MERGED/CLOSED ones.',
10 argumentHint: '[prune]',
11 })
12 return next(e)
13 })
14
15 on('command.run', { command: 'wt-gc' }, async ($, e) => {
16 const run = (argv: string[], cwd?: string) =>
17 $.process.run(argv, { cwd, timeoutMs: 60_000 }).catch(() => ({ exitCode: 1, stdout: '', stderr: 'could not start' }))
18 const out = async (argv: string[], cwd?: string) => {
19 const r = await run(argv, cwd)
20 return r.exitCode === 0 ? r.stdout.trim() : undefined
21 }
22
23 const arg = e.args.trim()
24 if (arg !== '' && arg !== 'prune') return { text: 'Usage: /wt-gc [prune]' }
25
26 const root = await out(['git', 'rev-parse', '--show-toplevel'])
27 if (!root) return { text: 'wt-gc: not inside a git repo.' }
28
29 const list = await out(['git', 'worktree', 'list', '--porcelain'], root)
30 if (list === undefined) return { text: 'wt-gc: git worktree list failed.' }
31 // First block is the main worktree; skip it.
32 const worktrees = list.split(/\n\s*\n/).slice(1).map(block => {
33 const lines = block.split('\n')
34 const path = lines.find(l => l.startsWith('worktree '))?.slice(9) ?? ''
35 const ref = lines.find(l => l.startsWith('branch '))?.slice(7)
36 return { path, branch: ref?.replace(/^refs\/heads\//, '') }
37 }).filter(w => w.path)
38
39 if (worktrees.length === 0) return { text: 'wt-gc: no worktrees besides the main one.' }
40
41 const configured = String(options.defaultBranch ?? '').trim()
42 const base = configured
43 || (await out(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root))
44 || 'main'
45
46 const statusOf = async (w: { path: string; branch?: string }): Promise<Status> => {
47 const dirty = await run(['git', 'status', '--porcelain'], w.path)
48 // A failed status (missing path, broken worktree) counts as DIRTY so prune never touches it.
49 if (dirty.exitCode !== 0 || dirty.stdout.trim() !== '') return 'DIRTY'
50 if (!w.branch) return 'NO-PR' // detached HEAD: no branch to look up
51 const gh = await out(['gh', 'pr', 'list', '--head', w.branch, '--state', 'all', '--json', 'state'], root)
52 let states: string[] = []
53 try { states = (JSON.parse(gh ?? '[]') as { state: string }[]).map(p => p.state) } catch {}
54 if (states.includes('OPEN')) return 'OPEN'
55 const merged = (await run(['git', 'merge-base', '--is-ancestor', w.branch, base], root)).exitCode === 0
56 if (states.includes('MERGED') || merged) return 'MERGED'
57 if (states.includes('CLOSED')) return 'CLOSED'
58 return 'NO-PR'
59 }
60
61 const rows = await Promise.all(worktrees.map(async w => ({ ...w, status: await statusOf(w) })))
62 const pw = Math.max(...rows.map(r => r.path.length))
63 const bw = Math.max(...rows.map(r => (r.branch ?? '(detached)').length))
64 const lines = rows.map(r => `${r.path.padEnd(pw)} ${(r.branch ?? '(detached)').padEnd(bw)} ${r.status}`)
65 let text = `Worktrees (merge base: ${base}):\n${lines.join('\n')}`
66
67 if (arg === 'prune') {
68 const results: string[] = []
69 for (const r of rows.filter(r => r.status === 'MERGED' || r.status === 'CLOSED')) {
70 // No --force: git itself refuses dirty or locked worktrees.
71 const rm = await run(['git', 'worktree', 'remove', r.path], root)
72 results.push(rm.exitCode === 0 ? `removed ${r.path}` : `kept ${r.path}: ${rm.stderr.trim()}`)
73 }
74 await run(['git', 'worktree', 'prune'], root)
75 text += `\n\nPrune:\n${results.length ? results.join('\n') : 'nothing to remove'}`
76 }
77
78 return { text }
79 })
80}
81