Finds Xcode DerivedData folders whose workspace is gone (removed worktrees), shows them in a status line and cleans them on an explicit command

Finds Xcode DerivedData folders whose workspace no longer exists (removed git worktrees...) and deletes them on explicit request.
~/Library/Developer/Xcode/DerivedData at session start: for each folder it reads WorkspacePath from info.plist (via plutil) and flags it as an orphan when that path is gone. Sizes come from du -sk.DerivedData: 3 orphans · 2.4 GB (hidden when there are none).git worktree remove run by the main agent, rescans and shows a toast for newly orphaned folders./deriveddata-janitor clean removes folders.| Command | Effect |
|---|---|
/deriveddata-janitor or status | Rescan and list orphans, largest first, with their former workspace |
/deriveddata-janitor clean | Rescan, re-check each folder, delete the orphans, report freed space and skipped folders |
claude --plugin-dir /path/to/ModsTools/mods/deriveddata-janitor
/Volumes/ and relative workspace paths are never judged (the disk may just be unmounted).*.noindex) and hidden entries are never touched; clean only removes a direct child of the DerivedData folder and re-verifies it is still orphaned.plutil, du); requires HOME to be set.git worktree remove commands run by the main agent.claude plugin validate mods/deriveddata-janitor
claude plugin test mods/deriveddata-janitor # 13 testshooks/register.ts 133 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { DERIVED_SUBPATH, isCacheName, isJudgeable, isSafeTarget, parseDuKb, parseWorkspacePath, report, statusLine, totalKb, formatKb } from './janitor'
4import type { Orphan } from './janitor'
5
6const QUICK_MS = 10_000
7const DU_MS = 20_000
8const RM_MS = 120_000
9const WORKTREE_REMOVE = /\bgit\s+(?:-C\s+\S+\s+)?worktree\s+remove\b/
10
11// Orphans seen by the last scan, to tell new ones after a `git worktree remove`.
12let known = new Set<string>()
13
14async function derivedRoot($: EngineInterface): Promise<string | null> {
15 const home = await $.env.get('HOME')
16
17 return home === undefined || !home.startsWith('/') ? null : `${home.replace(/\/$/, '')}/${DERIVED_SUBPATH}`
18}
19
20async function workspaceOf($: EngineInterface, folder: string): Promise<string | null> {
21 try {
22 const ran = await $.process.run(['plutil', '-extract', 'WorkspacePath', 'raw', '-o', '-', `${folder}/info.plist`], { timeoutMs: QUICK_MS })
23
24 return ran.exitCode === 0 ? parseWorkspacePath(ran.stdout) : null
25 } catch {
26 return null
27 }
28}
29
30async function sizeKb($: EngineInterface, folder: string): Promise<number> {
31 try {
32 const ran = await $.process.run(['du', '-sk', folder], { timeoutMs: DU_MS })
33
34 return ran.exitCode === 0 ? parseDuKb(ran.stdout) : 0
35 } catch {
36 return 0
37 }
38}
39
40// Folders under the root whose info.plist names a workspace that no longer exists.
41async function scan($: EngineInterface, root: string): Promise<Orphan[]> {
42 const entries = await $.fs.list(root).catch(() => [])
43 const found: Orphan[] = []
44 for (const entry of entries) {
45 if (entry.kind !== 'dir' || isCacheName(entry.name)) continue
46 const path = `${root}/${entry.name}`
47 const workspace = await workspaceOf($, path)
48 if (workspace === null || !isJudgeable(workspace) || (await $.fs.exists(workspace).catch(() => true))) continue
49 found.push({ name: entry.name, path, workspace, kb: await sizeKb($, path) })
50 }
51
52 return found
53}
54
55async function refresh($: EngineInterface): Promise<Orphan[]> {
56 const root = await derivedRoot($)
57 const orphans = root === null ? [] : await scan($, root).catch(() => [])
58 known = new Set(orphans.map(o => o.path))
59 $.ui.status(statusLine(orphans))
60
61 return orphans
62}
63
64async function clean($: EngineInterface): Promise<string> {
65 const root = await derivedRoot($)
66 if (root === null) return 'HOME is not set: cannot locate DerivedData.'
67 const fresh = await scan($, root).catch(() => [])
68 let freed = 0
69 let removed = 0
70 const skipped: string[] = []
71 for (const orphan of fresh) {
72 const workspace = await workspaceOf($, orphan.path)
73 const stillGone = workspace !== null && isJudgeable(workspace) && !(await $.fs.exists(workspace).catch(() => true))
74 if (!isSafeTarget(root, orphan.path) || !stillGone) {
75 skipped.push(orphan.name)
76 continue
77 }
78 try {
79 await $.process.run(['rm', '-rf', orphan.path], { timeoutMs: RM_MS })
80 } catch {
81 skipped.push(orphan.name)
82 continue
83 }
84 if (await $.fs.exists(orphan.path).catch(() => true)) skipped.push(orphan.name)
85 else {
86 removed += 1
87 freed += orphan.kb
88 }
89 }
90 await refresh($)
91 const head = removed === 0 ? 'Nothing deleted.' : `Deleted ${removed} orphan folder${removed === 1 ? '' : 's'}, freed ${formatKb(freed)}.`
92
93 return skipped.length === 0 ? head : `${head} Skipped: ${skipped.join(', ')}.`
94}
95
96async function afterWorktreeRemove($: EngineInterface) {
97 const before = known
98 const orphans = await refresh($)
99 const added = orphans.filter(o => !before.has(o.path))
100 if (added.length > 0) $.ui.toast(`DerivedData: ${added.length} new orphan${added.length === 1 ? '' : 's'} (${formatKb(totalKb(added))}) — /deriveddata-janitor clean`)
101}
102
103export const register: Register = on => {
104 on('session.start', async ($, e, next) => {
105 await $.command.register({
106 name: 'deriveddata-janitor',
107 description: 'List (status) or delete (clean) Xcode DerivedData folders whose workspace is gone',
108 })
109 $.clock.after(0, () => void refresh($))
110
111 return next(e)
112 })
113
114 on('command.run', { command: 'deriveddata-janitor' }, async ($, e) => {
115 const arg = e.args.trim()
116 if (arg === 'clean') return { text: await clean($) }
117 if (arg !== '' && arg !== 'status') return { text: 'Usage: /deriveddata-janitor [status|clean]' }
118 const root = await derivedRoot($)
119 if (root === null) return { text: 'HOME is not set: cannot locate DerivedData.' }
120
121 return { text: report(await refresh($)) }
122 })
123
124 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
125 const ran = await next(e)
126 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true && WORKTREE_REMOVE.test(e.command)) {
127 await afterWorktreeRemove($).catch(() => undefined)
128 }
129
130 return ran
131 })
132}
133hooks/janitor.ts 53 lines1export type Orphan = { name: string; path: string; workspace: string; kb: number }
2
3export const DERIVED_SUBPATH = 'Library/Developer/Xcode/DerivedData'
4
5// `plutil -extract WorkspacePath raw -o -` prints the bare path; anything else is not one.
6export function parseWorkspacePath(stdout: string): string | null {
7 const path = stdout.trim()
8
9 return path.startsWith('/') && !path.includes('\n') && !path.includes('\0') ? path : null
10}
11
12// Only a path that is gone can be judged: a relative one is unreadable, and a path on /Volumes may just be unmounted.
13export const isJudgeable = (workspace: string) => workspace.startsWith('/') && !workspace.startsWith('/Volumes/')
14
15// A folder name never to touch: Xcode's shared caches (`*.noindex`) and hidden entries.
16export const isCacheName = (name: string) => name.endsWith('.noindex') || name.startsWith('.')
17
18// True only for `<root>/<one plain name>`: never the root, a deeper path, `..`, or a path outside the root.
19export function isSafeTarget(root: string, path: string): boolean {
20 if (root === '' || !root.startsWith('/') || path.includes('\0')) return false
21 const prefix = root.endsWith('/') ? root : `${root}/`
22 if (!path.startsWith(prefix)) return false
23 const name = path.slice(prefix.length)
24
25 return name !== '' && name !== '.' && name !== '..' && !name.includes('/')
26}
27
28export function formatKb(kb: number): string {
29 if (kb >= 1024 * 1024) return `${(kb / (1024 * 1024)).toFixed(1)} GB`
30 if (kb >= 1024) return `${Math.round(kb / 1024)} MB`
31
32 return `${Math.round(kb)} KB`
33}
34
35export const totalKb = (orphans: Orphan[]) => orphans.reduce((sum, orphan) => sum + orphan.kb, 0)
36
37export const statusLine = (orphans: Orphan[]): string | undefined =>
38 orphans.length === 0 ? undefined : `DerivedData: ${orphans.length} orphan${orphans.length === 1 ? '' : 's'} · ${formatKb(totalKb(orphans))}`
39
40export function report(orphans: Orphan[]): string {
41 if (orphans.length === 0) return 'No orphan DerivedData folders.'
42 const lines = [...orphans].sort((a, b) => b.kb - a.kb).map(o => `${formatKb(o.kb).padStart(8)} ${o.name} <- ${o.workspace}`)
43
44 return [`${statusLine(orphans)}`, ...lines, '', 'Run /deriveddata-janitor clean to delete them.'].join('\n')
45}
46
47// `du -sk` prints "<kb>\t<path>"; 0 when unreadable.
48export function parseDuKb(stdout: string): number {
49 const kb = Number.parseInt(stdout.trim().split(/\s+/)[0] ?? '', 10)
50
51 return Number.isFinite(kb) && kb >= 0 ? kb : 0
52}
53