SLOPSHOPPER

wt-gc

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

newcommandprocess
v0.1.0MITupdated 2026-10-09AliakbarNateghi/cc-wt-gc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wt-gc
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /wt-gc ⎿ wt-gc: wt-gc: no worktrees besides the main one. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

wt-gc

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.

Install

/plugin install wt-gc --marketplace AliakbarNateghi/cc-wt-gc
Source 1 files
hooks/register.ts 81 lines
1import 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