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

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.
/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.git worktree remove <path> (no --force). Any other press or a refresh resets the confirmation.Removed <name> + N DerivedData (X GB).not a git repository.| Command | Effect |
|---|---|
/worktrees | Reload and open the pane |
/worktrees refresh | Reload and reset any pending confirmation |
/worktrees close | Close the pane |
claude --plugin-dir /path/to/ModsTools/mods/worktree-pane
~/Library/Developer/Xcode/DerivedData, is not an Xcode cache (*.noindex), and its info.plist still points inside the removed worktree.plutil, du); requires HOME to be set for the DerivedData part.claude plugin validate mods/worktree-pane
claude plugin test mods/worktree-pane # 17 testshooks/register.tsx 216 lines1import { 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}
216hooks/worktrees.ts 123 lines1import 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}
123types/index.d.ts 25 lines1export 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