SLOPSHOPPER

worktree-pane

A pane listing the git worktrees of the session folder with branch, dirty count and DerivedData size, and removing a worktree together with its DerivedData

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worktree-pane
│ ┃ Worktrees ✕ › fix the failing auth test and add an audit log call │ ┃ not a git repository │ ⏺ 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 │ │ › /worktrees │ ⎿ worktree-pane: not a git repository │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Worktrees
not a git repository
README

worktree-pane

A pane listing the git worktrees of the session folder, each with its Xcode DerivedData, and a two-step button that removes a worktree together with its DerivedData.

What it does

  • /worktrees opens a "Worktrees" pane (never opens by itself). Each row shows the path (relative to the main worktree or under ~), the branch or detached, a main marker, locked, prunable/missing flags, the number of dirty files, and the DerivedData folders (count and size) whose workspace lives inside that worktree.
  • Every worktree except the main one has a Remove button. The first press turns it into Confirm remove? for that row; the second press runs git worktree remove <path> (no --force). Any other press or a refresh resets the confirmation.
  • If git refuses (for instance a dirty worktree), its message is shown in the pane and nothing is deleted. Only after git succeeded are the worktree's DerivedData folders deleted, then the pane refreshes and a toast reads Removed <name> + N DerivedData (X GB).
  • Outside a git repository the pane says not a git repository.

Commands

CommandEffect
/worktreesReload and open the pane
/worktrees refreshReload and reset any pending confirmation
/worktrees closeClose the pane

Install

claude --plugin-dir /path/to/ModsTools/mods/worktree-pane

Limits

  • The main worktree is never removed. A folder is only deleted if it is a direct child of ~/Library/Developer/Xcode/DerivedData, is not an Xcode cache (*.noindex), and its info.plist still points inside the removed worktree.
  • DerivedData belongs to the deepest worktree containing its workspace path (a worktree nested in the main one is not counted for main).
  • Locked worktrees are refused by git and reported as such.
  • macOS only (needs plutil, du); requires HOME to be set for the DerivedData part.

Develop

claude plugin validate mods/worktree-pane
claude plugin test mods/worktree-pane   # 17 tests
Source 3 files
hooks/register.tsx 216 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DerivedFolder, WorktreeRow } from '../types'
5import {
6  DERIVED_SUBPATH,
7  NOT_A_REPO,
8  baseName,
9  derivedLabel,
10  dirtyCount,
11  firstLine,
12  isCacheName,
13  isSafeTarget,
14  ownerOf,
15  parseDuKb,
16  parsePorcelain,
17  parseWorkspacePath,
18  removedToast,
19  rowLabel,
20  totalKb,
21} from './worktrees'
22
23const PANE = 'worktree-pane'
24const TITLE = 'Worktrees'
25const QUICK_MS = 10_000
26const DU_MS = 20_000
27const RM_MS = 120_000
28const rows = atom({ plugin: 'worktree-pane', key: 'rows' } as const, [])
29const error = atom({ plugin: 'worktree-pane', key: 'error' } as const, null)
30const confirm = atom({ plugin: 'worktree-pane', key: 'confirm' } as const, null)
31const notice = atom({ plugin: 'worktree-pane', key: 'notice' } as const, null)
32
33async function derivedRoot($: EngineInterface): Promise<string | null> {
34  const home = await $.env.get('HOME')
35
36  return home === undefined || !home.startsWith('/') ? null : `${home.replace(/\/$/, '')}/${DERIVED_SUBPATH}`
37}
38
39async function workspaceOf($: EngineInterface, folder: string): Promise<string | null> {
40  try {
41    const ran = await $.process.run(['plutil', '-extract', 'WorkspacePath', 'raw', '-o', '-', `${folder}/info.plist`], { timeoutMs: QUICK_MS })
42
43    return ran.exitCode === 0 ? parseWorkspacePath(ran.stdout) : null
44  } catch {
45    return null
46  }
47}
48
49async function sizeKb($: EngineInterface, folder: string): Promise<number> {
50  try {
51    const ran = await $.process.run(['du', '-sk', folder], { timeoutMs: DU_MS })
52
53    return ran.exitCode === 0 ? parseDuKb(ran.stdout) : 0
54  } catch {
55    return 0
56  }
57}
58
59// DerivedData folders per owning worktree path; folders of no worktree are left out.
60async function derivedByWorktree($: EngineInterface, root: string, paths: string[]): Promise<Map<string, DerivedFolder[]>> {
61  const found = new Map<string, DerivedFolder[]>()
62  const entries = await $.fs.list(root).catch(() => [])
63  for (const entry of entries) {
64    if (entry.kind !== 'dir' || isCacheName(entry.name)) continue
65    const path = `${root}/${entry.name}`
66    const workspace = await workspaceOf($, path)
67    const owner = workspace === null ? null : ownerOf(workspace, paths)
68    if (owner === null) continue
69    found.set(owner, [...(found.get(owner) ?? []), { name: entry.name, path, kb: await sizeKb($, path) }])
70  }
71
72  return found
73}
74
75async function dirtyOf($: EngineInterface, path: string): Promise<number> {
76  try {
77    const ran = await $.process.run(['git', '-C', path, 'status', '--porcelain'], { timeoutMs: QUICK_MS })
78
79    return ran.exitCode === 0 ? dirtyCount(ran.stdout) : 0
80  } catch {
81    return 0
82  }
83}
84
85async function snapshot($: EngineInterface): Promise<{ list: WorktreeRow[]; problem: string | null }> {
86  try {
87    const ran = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: await $.session.cwd(), timeoutMs: QUICK_MS })
88    const entries = ran.exitCode === 0 ? parsePorcelain(ran.stdout) : []
89    if (entries.length === 0) return { list: [], problem: NOT_A_REPO }
90    const root = await derivedRoot($)
91    const derived = root === null ? new Map<string, DerivedFolder[]>() : await derivedByWorktree($, root, entries.map(one => one.path)).catch(() => new Map())
92    const list: WorktreeRow[] = []
93    for (const [index, entry] of entries.entries()) {
94      const missing = !(await $.fs.exists(entry.path).catch(() => true))
95      const dirty = missing || entry.bare ? 0 : await dirtyOf($, entry.path)
96      list.push({ ...entry, main: index === 0, missing, dirty, derived: derived.get(entry.path) ?? [] })
97    }
98
99    return { list, problem: null }
100  } catch {
101    return { list: [], problem: 'git unavailable' }
102  }
103}
104
105// Reloads the list; a refresh always resets a pending confirm.
106async function refresh($: EngineInterface, message: string | null = null) {
107  const { list, problem } = await snapshot($)
108  await update($, rows, () => list)
109  await update($, error, () => problem)
110  await update($, confirm, () => null)
111  await update($, notice, () => message)
112
113  return problem
114}
115
116// Deletes the worktree's DerivedData folders; only called once git removed the worktree.
117async function removeDerived($: EngineInterface, row: WorktreeRow): Promise<{ count: number; kb: number }> {
118  const root = await derivedRoot($)
119  let count = 0
120  let kb = 0
121  if (root === null) return { count, kb }
122  for (const folder of row.derived) {
123    if (!isSafeTarget(root, folder.path) || !(await $.fs.exists(folder.path).catch(() => false))) continue
124    const workspace = await workspaceOf($, folder.path)
125    if (workspace === null || ownerOf(workspace, [row.path]) === null) continue
126    try {
127      await $.process.run(['rm', '-rf', folder.path], { timeoutMs: RM_MS })
128    } catch {
129      continue
130    }
131    if (!(await $.fs.exists(folder.path).catch(() => true))) {
132      count += 1
133      kb += folder.kb
134    }
135  }
136
137  return { count, kb }
138}
139
140async function removeWorktree($: EngineInterface, path: string) {
141  const row = (await read($, rows)).find(one => one.path === path)
142  if (row === undefined || row.main) return
143  let failure: string | null = null
144  try {
145    const ran = await $.process.run(['git', 'worktree', 'remove', path], { cwd: await $.session.cwd(), timeoutMs: RM_MS })
146    if (ran.exitCode !== 0) failure = firstLine(ran.stderr === '' ? ran.stdout : ran.stderr)
147  } catch {
148    failure = 'git unavailable'
149  }
150  if (failure !== null) {
151    await refresh($, `git: ${failure}`)
152
153    return
154  }
155  const { count, kb } = await removeDerived($, row).catch(() => ({ count: 0, kb: 0 }))
156  const message = removedToast(baseName(path), count, kb)
157  await refresh($, message)
158  $.ui.toast(message)
159}
160
161async function pressRemove($: EngineInterface, path: string) {
162  if ((await read($, confirm)) === path) await removeWorktree($, path)
163  else await update($, confirm, () => path)
164}
165
166export const register: Register = on => {
167  on('session.start', async ($, e, next) => {
168    await $.command.register({
169      name: 'worktrees',
170      description: 'Show the git worktrees with their DerivedData in a pane (open | close | refresh)',
171    })
172
173    return next(e)
174  })
175
176  on('command.run', { command: 'worktrees' }, async ($, e) => {
177    const arg = e.args.trim()
178    if (arg === 'close' || arg === 'off') {
179      await $.ui.close({ id: PANE })
180      return { text: 'Worktrees pane closed.' }
181    }
182    const problem = await refresh($)
183    await $.ui.open({ id: PANE, title: TITLE })
184    const count = (await read($, rows)).length
185
186    return { text: problem ?? `Worktrees pane ${arg === 'refresh' ? 'refreshed' : 'opened'} (${count} worktree${count === 1 ? '' : 's'}).` }
187  })
188
189  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
190    const { Box, Button, Text } = $.ui.resolve(e)
191    const list: WorktreeRow[] = await read($, rows)
192    const problem = await read($, error)
193    const pending = await read($, confirm)
194    const message = await read($, notice)
195    const home = (await $.env.get('HOME').catch(() => undefined)) ?? null
196    const mainPath = list[0]?.path ?? ''
197
198    if (problem !== null) return <Text dimColor>{problem}</Text>
199
200    return (
201      <Box flexDirection="column">
202        <Text bold>{`${list.length} worktree${list.length === 1 ? '' : 's'}`}</Text>
203        {message !== null && <Text>{message}</Text>}
204        {list.map(row => (
205          <Box flexDirection="column">
206            <Text bold={row.main}>{rowLabel(row, mainPath, home)}</Text>
207            {derivedLabel(row) !== null && <Text dimColor>{`  ${derivedLabel(row)}`}</Text>}
208            {!row.main && <Button key={`remove:${row.path}`} label={pending === row.path ? 'Confirm remove?' : 'Remove'} onPress={() => pressRemove($, row.path)} />}
209          </Box>
210        ))}
211        <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
212      </Box>
213    )
214  })
215}
216
hooks/worktrees.ts 123 lines
1import type { WorktreeRow } from '../types'
2
3export const DERIVED_SUBPATH = 'Library/Developer/Xcode/DerivedData'
4export const NOT_A_REPO = 'not a git repository'
5
6export type Entry = { path: string; branch: string | null; bare: boolean; locked: boolean; prunable: boolean }
7
8// `git worktree list --porcelain`: blocks separated by a blank line, first line `worktree <path>`, then
9// `HEAD <sha>`, `branch refs/heads/<name>` | `detached` | `bare`, optional `locked [reason]` / `prunable [reason]`.
10// Reasons are localised: only the keywords are read. The first block is the main worktree.
11export function parsePorcelain(stdout: string): Entry[] {
12  const entries: Entry[] = []
13  for (const block of stdout.split(/\r?\n\r?\n/)) {
14    const lines = block.split(/\r?\n/).filter(line => line !== '')
15    const first = lines[0]
16    if (first === undefined || !first.startsWith('worktree /')) continue
17    const keyword = (word: string) => lines.some(line => line === word || line.startsWith(`${word} `))
18    const branch = lines.find(line => line.startsWith('branch '))
19
20    entries.push({
21      path: first.slice('worktree '.length),
22      branch: branch === undefined ? null : branch.slice('branch '.length).replace(/^refs\/heads\//, ''),
23      bare: keyword('bare'),
24      locked: keyword('locked'),
25      prunable: keyword('prunable'),
26    })
27  }
28
29  return entries
30}
31
32// `plutil -extract WorkspacePath raw -o -` prints the bare path; anything else is not one.
33export function parseWorkspacePath(stdout: string): string | null {
34  const path = stdout.trim()
35
36  return path.startsWith('/') && !path.includes('\n') && !path.includes('\0') ? path : null
37}
38
39// `du -sk` prints "<kb>\t<path>"; 0 when unreadable.
40export function parseDuKb(stdout: string): number {
41  const kb = Number.parseInt(stdout.trim().split(/\s+/)[0] ?? '', 10)
42
43  return Number.isFinite(kb) && kb >= 0 ? kb : 0
44}
45
46export const dirtyCount = (stdout: string) => stdout.split('\n').filter(line => line.trim() !== '').length
47
48// A folder name never to touch: Xcode's shared caches (`*.noindex`) and hidden entries.
49export const isCacheName = (name: string) => name.endsWith('.noindex') || name.startsWith('.')
50
51// True only for `<root>/<one plain name>`: never the root, a deeper path, `..`, or a path outside the root.
52export function isSafeTarget(root: string, path: string): boolean {
53  if (root === '' || !root.startsWith('/') || path.includes('\0')) return false
54  const prefix = root.endsWith('/') ? root : `${root}/`
55  if (!path.startsWith(prefix)) return false
56  const name = path.slice(prefix.length)
57
58  return name !== '' && name !== '.' && name !== '..' && !name.includes('/')
59}
60
61// The trailing-slash guard keeps /a/wt1 from matching /a/wt10.
62export function isInside(path: string, root: string): boolean {
63  const base = root.replace(/\/+$/, '')
64
65  return base !== '' && (path === base || path.startsWith(`${base}/`))
66}
67
68// The worktree a workspace belongs to: the deepest one containing it (a linked worktree can sit inside the main one).
69export function ownerOf(workspace: string, worktrees: string[]): string | null {
70  let best: string | null = null
71  for (const path of worktrees) {
72    if (isInside(workspace, path) && (best === null || path.length > best.length)) best = path
73  }
74
75  return best
76}
77
78export const baseName = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? path
79
80// Relative to the main worktree when inside it, `~/…` under HOME, else as is.
81export function shortPath(path: string, mainPath: string, home: string | null): string {
82  if (path === mainPath) return baseName(path)
83  if (isInside(path, mainPath)) return `${baseName(mainPath)}/${path.slice(mainPath.replace(/\/+$/, '').length + 1)}`
84  if (home !== null && home !== '' && isInside(path, home) && path !== home) return `~/${path.slice(home.replace(/\/+$/, '').length + 1)}`
85
86  return path
87}
88
89export function formatKb(kb: number): string {
90  if (kb >= 1024 * 1024) return `${(kb / (1024 * 1024)).toFixed(1)} GB`
91  if (kb >= 1024) return `${Math.round(kb / 1024)} MB`
92
93  return `${Math.round(kb)} KB`
94}
95
96export const totalKb = (row: WorktreeRow) => row.derived.reduce((sum, folder) => sum + folder.kb, 0)
97
98export function rowLabel(row: WorktreeRow, mainPath: string, home: string | null): string {
99  const parts = [shortPath(row.path, mainPath, home), row.bare ? 'bare' : (row.branch ?? 'detached')]
100  if (row.main) parts.push('main')
101  if (row.missing) parts.push('missing')
102  else if (row.prunable) parts.push('prunable')
103  if (row.locked) parts.push('locked')
104  if (row.dirty > 0) parts.push(`${row.dirty} dirty`)
105
106  return parts.join(' · ')
107}
108
109export function derivedLabel(row: WorktreeRow): string | null {
110  const n = row.derived.length
111
112  return n === 0 ? null : `DerivedData: ${n} folder${n === 1 ? '' : 's'} · ${formatKb(totalKb(row))}`
113}
114
115export const removedToast = (name: string, count: number, kb: number) => `Removed ${name} + ${count} DerivedData (${formatKb(kb)})`
116
117// First non-empty line of git's message, kept short for the pane.
118export function firstLine(text: string): string {
119  const line = text.split('\n').find(one => one.trim() !== '') ?? 'git worktree remove failed'
120
121  return line.trim().slice(0, 200)
122}
123
types/index.d.ts 25 lines
1export type DerivedFolder = { name: string; path: string; kb: number }
2
3export type WorktreeRow = {
4  path: string
5  branch: string | null
6  main: boolean
7  bare: boolean
8  locked: boolean
9  prunable: boolean
10  missing: boolean
11  dirty: number
12  derived: DerivedFolder[]
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'worktree-pane': {
18      rows: WorktreeRow[]
19      error: string | null
20      confirm: string | null
21      notice: string | null
22    }
23  }
24}
25