/worktrees prints each linked git worktree's size, last commit, dirty files, ledger and PR

A Claude Code multi-agent setup. It gives the top-level session a contract: plan the work, delegate it to subagents, review the result, and keep the state in a file instead of in the conversation.
It ships five things:
/ledger, /ledger:task and /ledger:update. /ledger prints each task ledger's goal, current step and next step. /ledger:task starts a separate task with its own worktree and ledger. /ledger:update writes the latest results to the current task's ledger./worktrees. It prints a table of the repo's linked git worktrees, largest first. /worktrees prune <name> lists that worktree's build folders, and --yes deletes them. It keeps node_modules and Pods, so the worktree still builds. /worktrees derived-data groups Xcode's build folders by the project they were built from. /worktrees prune-orphans --yes deletes the ones whose project no longer exists.One number drives the design. In eight weeks of one operator's sessions, 86% of the spend was re-reading context, not generating output — 257 tokens read for every one written. Everything you read stays in the session and is paid for again on every later turn.
So the setup pushes verbose work out of the main session. A build log goes to runner, which returns a verdict. A wide code search goes to researcher, which returns a short report. Implementation goes to an implementer, which returns a diff and a report. The main session stays small, and it stays the place where planning, routing, and review happen.
git clone https://github.com/chrfalch/agent-config.git
cd agent-config
bin/deploy --dry-run # print every change, write nothing
bin/deploy
You need Claude Code, Node.js 20.12 or later, and bash. Deploy runs on Node. bash runs the git hook dispatcher that deploy installs. Python is no longer needed. The first run of bin/deploy runs npm ci, which installs dependencies into node_modules and writes the npm cache in ~/.npm. Codex CLI, the argent MCP server, and the gh stack extension are optional; deploy has a flag to skip each one.
Run bin/deploy with no flags in a terminal to start an interactive wizard. Flags work as before and skip the wizard.
Claude Code reads the plugins in mods/ directly from this checkout, not from a copy. After a plugin change is merged, run git pull here, then restart Claude Code or run /reload-plugins. You do not need to run bin/deploy again.
Then add three variables to your shell profile:
export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=30
export BASH_MAX_OUTPUT_LENGTH=10000
export MAX_MCP_OUTPUT_TOKENS=10000
That is the happy path. docs/installation.md has the flags, what deploy owns, what it never touches, and how to undo it.
Check that the install worked before you start real work. Open a new terminal so the shell variables load, then start Claude Code in any repo.
ls ~/.claude/agents/. The list should include researcher.md, runner.md, reviewer.md, and opus-implementer.md. The Codex and argent agents appear only if you did not skip them at deploy./ledger and then /worktrees. Both commands should run. If Claude Code does not know them, the mods did not install.What are we working on?. It should say that no task is open, because this repo has no ledger yet.Start a new task: … with a sentence describing it. You should get a plan and a question asking you to approve it. Answer yes and follow along.If step 1 or step 2 fails, run bin/deploy --dry-run again and read what it would change.
Open Claude Code in your repo and describe the task. The orchestrator explores, writes a plan, and asks you to approve it. On approval it creates a worktree and a ledger, then runs the pipeline:
investigate -> plan -> ledger -> red tests -> implement -> verify -> review -> clear point -> accept
Two things you take part in: you approve the plan, and you run /clear when the orchestrator says so.
Every non-trivial task gets a file at .claude/ledger.md in its worktree. It holds the goal, the approved plan with a status per step, decisions and their reasons, and one line naming the exact next action. The orchestrator writes to it at every step boundary.
The ledger exists because the conversation is expensive and the file is cheap. A session transcript is re-read on every turn. The ledger is read once, when a session starts.
## Next step
Delegate step 3 to opus-implementer at high effort: make the failing
tests in src/sync/__tests__/queue.test.ts pass.
/clear and goWhen one implement-and-review cycle finishes and more work remains, the orchestrator stops and says:
Clear point. Ledger updated. Run
/clear, then say: go.
You type /clear, then go. A hook reads the ledger into the fresh session, so the new session knows the goal, what is done, and what is next. It continues from there. You type nothing else.
This is the core loop. A cold start from the ledger costs a few thousand tokens once. Carrying the finished cycle into the next one costs that on every turn for the rest of the task.
You write plain sentences, and the wording does not have to match these examples.
| You type | What happens |
|---|---|
Start a new task: items in the offline queue disappear if the app is killed during a sync. | The orchestrator investigates, writes a plan, and asks you to approve it. Only a small single-file change is made without a plan. |
yes | Approves the plan. The orchestrator creates the worktree and the ledger and starts on step 1. |
Change step 2: keep the old API and add the new one next to it. | The orchestrator rewrites the plan and asks again. |
What are we working on? | It answers from the ledger: the goal, what is done, and the next step. If several tasks are open, it lists each one. |
go | It continues from the next step in the ledger. Use this after /clear, or in a new session the next day. |
go offline-queue-loss | The same as go, when several tasks are open and you want to name one. The session moves into that task's worktree first. |
Why did we drop the retry counter? | It answers from the decisions recorded in the ledger, with the reason for each. |
Explain that in more detail. | Answers start as a short summary. This asks for file paths, function names, and what was ruled out. |
Commit and open a PR. | The orchestrator does not commit on its own. When the last review passes it asks you, and this is the answer. |
docs/usage.md walks through a full task, with the ledger at each stage.
Two commands print state directly. Use them when you want a quick look instead of a full answer.
| You type | What happens |
|---|---|
/ledger | Prints each task ledger's goal, current step and next step. |
/ledger <name> | Prints only the ledger with that name. |
/worktrees | Prints a table of the repo's linked git worktrees, largest first, with size, last commit, changed files, ledger and PR. |
/worktrees prune <name> | Lists the build folders of that worktree and their sizes. It deletes nothing. |
/worktrees prune <name> --yes | Deletes those build folders. It keeps node_modules, Pods, .claude and the worktree itself. |
/worktrees derived-data | Lists every folder in ~/Library/Developer/Xcode/DerivedData with its size. It groups them by where the project is: the main checkout, a linked worktree, another location, or gone. |
/worktrees prune-orphans | Lists the DerivedData folders whose project no longer exists. It deletes nothing. |
/worktrees prune-orphans --yes | Deletes those folders. It never deletes shared caches or folders without a recorded project. |
A build folder whose project is on a disk that is not connected goes in its own group, Disk not connected, and is never deleted.
To start a separate task, type /ledger:task <what the task is>. Claude Code puts the plugin name in front of every command that a plugin writes as a markdown file, so a plain /task does not exist. The agent picks a short name and writes the goal. It then runs bin/cc-task-new, which creates a branch, a worktree and a ledger with the goal filled in. The reply tells you to type /clear, then go <name>. The fresh session moves into the new worktree and starts from its ledger. You do not open a new terminal.
To save the latest results before a /clear, type /ledger:update. In a task worktree, the agent writes the step status, the subagent reports, the decisions and the next step to the ledger. The reply tells you to type /clear, then go. With no ledger in the working directory, the agent does not create one there. It starts a task with bin/cc-task-new instead, the same way /ledger:task does.
To remove a finished worktree, use bin/cc-worktree-retire <worktree>. It archives the ledger first, then removes the worktree and its Xcode build folders.
| Document | What it covers |
|---|---|
| docs/usage.md | The daily loop, a worked example, the ledger, clear points |
| docs/installation.md | Prerequisites, every flag, what deploy owns, revert |
| docs/fleet.md | The eight agents, their models, and the routing rules |
| docs/customizing.md | Changing models, gating rules, adding an agent, tests |
| CONTRIBUTING.md | Forking, what to send back, what must never be committed |
| DECISIONS.md | Why the setup is shaped this way |
rules/ the orchestrator contract, one file per topic
agents/ the eight subagents
docs/ this documentation
ledger.template.md copied per task to .claude/ledger.md
notes/ rationale, not rules
DECISIONS.md why the setup is shaped this way
settings.policy.json Claude Code keys this repo asserts
codex/policy.toml Codex keys this repo asserts
mods/ledger/ the /ledger, /ledger:task and /ledger:update commands (a Claude Code plugin)
mods/worktrees/ the /worktrees command (a Claude Code plugin)
tools/ the settings merge and rule filter, plus their tests
bin/deploy installs everything above
bin/cc-dd-path prints the shared Xcode build folder for a worktree
bin/cc-task-new starts a task: a new branch, its worktree and its ledger
bin/cc-worktree-retire archives a worktree's ledger, then removes the worktree
Agents you add under ~/.claude/agents/ that are not in this repo are left alone by deploy. Keep private or employer-specific agents there, not here.
No secrets belong in this repo. Nothing here reads a key or a token.
hooks/register.ts 276 lines1import type { EngineInterface, FsStat, Register } from 'claude-code'
2
3import {
4 basename,
5 classifyDerivedData,
6 derivedDataPrefix,
7 dirtyCount,
8 folderLabel,
9 formatDerivedData,
10 formatOrphanPlan,
11 formatPruned,
12 formatPrunedOrphans,
13 formatPrunePlan,
14 formatTable,
15 ignoredBuildFolders,
16 isDirectChild,
17 isDiskConnected,
18 isEmptyFolder,
19 isOrphan,
20 isOwnXcodeFolder,
21 parseCommand,
22 parseMainWorktree,
23 parsePrs,
24 parseWorktrees,
25 prLabel,
26 pruneBase,
27 realPathProblem,
28 shortDate,
29 sizeGb,
30 USAGE,
31 volumeOf,
32 workspacePathOf,
33 type DerivedDataEntry,
34 type DerivedDataFolder,
35 type PruneFolder,
36 type PruneScope,
37 type PullRequest,
38 type RepoLayout,
39 type Worktree,
40 type WorktreeRow,
41} from './worktrees'
42
43type Ran = { stdout: string; failure?: string }
44
45type PrLookup = { prs: PullRequest[] | null; error?: string }
46
47async function run($: EngineInterface, argv: string[], cwd: string): Promise<Ran> {
48 try {
49 const { exitCode, stdout, stderr } = await $.process.run(argv, { cwd })
50 return exitCode === 0 ? { stdout } : { stdout, failure: `failed (exit ${exitCode}): ${stderr.trim()}` }
51 } catch (error) {
52 return { stdout: '', failure: `did not run: ${error instanceof Error ? error.message : String(error)}` }
53 }
54}
55
56const okStdout = ({ stdout, failure }: Ran): string | null => (failure === undefined ? stdout : null)
57
58async function lookUpPrs($: EngineInterface, branch: string, cwd: string): Promise<PrLookup> {
59 if (branch === '') return { prs: [] }
60 const { stdout, failure } = await run($, ['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--json', 'state,number'], cwd)
61 if (failure !== undefined) return { prs: null, error: `gh pr list ${failure}` }
62 const prs = parsePrs(stdout)
63 return prs ? { prs } : { prs: null, error: 'gh pr list printed something that is not a JSON list' }
64}
65
66async function isFile($: EngineInterface, path: string): Promise<boolean> {
67 try {
68 return (await $.fs.stat(path)).kind === 'file'
69 } catch {
70 return false
71 }
72}
73
74async function probe($: EngineInterface, { path, branch }: Worktree, cwd: string): Promise<{ row: WorktreeRow; prError?: string }> {
75 const [du, status, log, hasLedger, { prs, error }] = await Promise.all([
76 run($, ['du', '-sk', path], cwd),
77 run($, ['git', '-C', path, 'status', '--porcelain'], cwd),
78 run($, ['git', '-C', path, 'log', '-1', '--format=%cs'], cwd),
79 isFile($, `${path}/.claude/ledger.md`),
80 lookUpPrs($, branch, cwd),
81 ])
82 const statusStdout = okStdout(status)
83 const logStdout = okStdout(log)
84 const row = {
85 name: basename(path),
86 // du exits non-zero when it cannot read some file, yet still prints the total of what it could.
87 sizeGb: sizeGb(du.stdout),
88 lastCommit: logStdout === null ? null : shortDate(logStdout),
89 dirty: statusStdout === null ? null : dirtyCount(statusStdout),
90 hasLedger,
91 pr: prLabel(prs),
92 }
93 return { row, prError: error }
94}
95
96async function linkedWorktrees($: EngineInterface, cwd: string): Promise<RepoLayout & { error?: string }> {
97 const list = await run($, ['git', 'worktree', 'list', '--porcelain', '-z'], cwd)
98 if (list.failure !== undefined) return { main: null, worktrees: [], error: `Could not list worktrees. git worktree list ${list.failure}` }
99 const listed = parseWorktrees(list.stdout)
100 const present = await Promise.all(listed.map(({ path }) => $.fs.exists(path)))
101 return { main: parseMainWorktree(list.stdout), worktrees: listed.filter((_, index) => present[index]) }
102}
103
104async function table($: EngineInterface, worktrees: Worktree[], cwd: string): Promise<string> {
105 const probes = await Promise.all(worktrees.map(worktree => probe($, worktree, cwd)))
106 return formatTable(
107 probes.map(({ row }) => row),
108 probes.find(({ prError }) => prError !== undefined)?.prError,
109 )
110}
111
112async function derivedDataPath($: EngineInterface): Promise<string | null> {
113 const home = await $.env.get('HOME')
114 return home?.startsWith('/') ? `${home}/Library/Developer/Xcode/DerivedData` : null
115}
116
117async function pruneScope($: EngineInterface, root: string): Promise<PruneScope> {
118 return { root, derivedData: await derivedDataPath($), prefix: await derivedDataPrefix(root) }
119}
120
121async function xcodeBuildFolders($: EngineInterface, { derivedData, prefix }: PruneScope): Promise<string[]> {
122 if (derivedData === null) return []
123 try {
124 const entries = await $.fs.list(derivedData)
125 return entries.filter(({ name, kind }) => kind === 'dir' && isOwnXcodeFolder(name, prefix)).map(({ name }) => `${derivedData}/${name}`)
126 } catch {
127 return []
128 }
129}
130
131const resolve = ($: EngineInterface, path: string): Promise<FsStat | undefined> => $.fs.stat(path, { resolve: true }).catch(() => undefined)
132
133/** Why `rm -rf` must not touch the path, judged on where it really lands rather than how it is spelled; undefined when it may. */
134async function deletionProblem($: EngineInterface, path: string, scope: PruneScope): Promise<string | undefined> {
135 const base = pruneBase(path, scope)
136 if (base === null) return 'it is not a build folder of this worktree.'
137 const [own, allowed] = await Promise.all([resolve($, path), resolve($, base)])
138 if (own?.isLink) return 'it is a symbolic link.'
139 if (own?.realPath === undefined) return 'its real path could not be resolved.'
140 if (allowed?.realPath === undefined) return `the real path of ${base} could not be resolved.`
141 return realPathProblem(own.realPath, allowed.realPath)
142}
143
144async function deleteFolder($: EngineInterface, folder: PruneFolder, problem: string | undefined, cwd: string): Promise<string | undefined> {
145 if (problem !== undefined) return `Skipped ${folder.path}: ${problem}`
146 const { failure } = await run($, ['rm', '-rf', '--', folder.path], cwd)
147 return failure === undefined ? undefined : `Could not delete ${folder.label}: rm ${failure}`
148}
149
150async function prune($: EngineInterface, worktrees: Worktree[], name: string, confirmed: boolean, cwd: string): Promise<string> {
151 const [match, ...others] = worktrees.filter(({ path }) => basename(path) === name)
152 if (match === undefined) {
153 const names = worktrees.map(({ path }) => basename(path)).join(', ') || 'none'
154 return `No linked worktree named "${name}". Worktrees: ${names}`
155 }
156 if (others.length > 0) return `Two or more linked worktrees are named "${name}": ${[match, ...others].map(({ path }) => path).join(', ')}`
157
158 const scope = await pruneScope($, match.path)
159 const [ignored, xcodePaths] = await Promise.all([
160 run($, ['git', '-C', match.path, 'status', '--ignored', '--porcelain'], cwd),
161 xcodeBuildFolders($, scope),
162 ])
163 if (ignored.failure !== undefined) return `Could not read the ignored files of ${name}. git status ${ignored.failure}`
164 const sized = await Promise.all(
165 [...ignoredBuildFolders(ignored.stdout, match.path), ...xcodePaths].map(async path => ({
166 path,
167 label: folderLabel(path, match.path),
168 sizeGb: sizeGb((await run($, ['du', '-sk', path], cwd)).stdout),
169 })),
170 )
171 const folders = sized.filter(folder => !isEmptyFolder(folder))
172 if (!confirmed || folders.length === 0) return formatPrunePlan(name, folders)
173
174 const failures = await Promise.all(folders.map(async folder => deleteFolder($, folder, await deletionProblem($, folder.path, scope), cwd)))
175 return formatPruned(
176 name,
177 folders.filter((_, index) => failures[index] === undefined),
178 failures.filter(failure => failure !== undefined),
179 )
180}
181
182async function isVolumeConnected($: EngineInterface, volume: string, cwd: string): Promise<boolean> {
183 // An absolute path, because GNU coreutils can come first on PATH, and its stat -f reports on the file system instead.
184 const [own, volumes, link] = await Promise.all([
185 run($, ['/usr/bin/stat', '-L', '-f', '%d', volume], cwd),
186 run($, ['/usr/bin/stat', '-f', '%d', '/Volumes'], cwd),
187 resolve($, volume),
188 ])
189 return isDiskConnected({ device: okStdout(own), volumesDevice: okStdout(volumes), linkTarget: link?.isLink ? (link.realPath ?? null) : null })
190}
191
192async function readDerivedDataEntry($: EngineInterface, derivedData: string, name: string, cwd: string): Promise<DerivedDataEntry> {
193 // plutil reads the binary and the XML form Xcode writes alike, and fails when the file or the key is missing.
194 const plist = await run($, ['plutil', '-extract', 'WorkspacePath', 'raw', '-o', '-', `${derivedData}/${name}/info.plist`], cwd)
195 const workspacePath = workspacePathOf(okStdout(plist))
196 if (workspacePath === null) return { name, workspacePath, exists: false, diskConnected: true }
197 const volume = volumeOf(workspacePath)
198 const [exists, diskConnected] = await Promise.all([$.fs.exists(workspacePath), volume === null || isVolumeConnected($, volume, cwd)])
199 return { name, workspacePath, exists, diskConnected }
200}
201
202async function derivedDataFolders($: EngineInterface, derivedData: string | null, layout: RepoLayout, cwd: string): Promise<DerivedDataFolder[]> {
203 if (derivedData === null) return []
204 const entries = await $.fs.list(derivedData).catch(() => [])
205 return Promise.all(
206 entries
207 .filter(({ kind }) => kind === 'dir')
208 .map(async ({ name }) => {
209 const path = `${derivedData}/${name}`
210 const [entry, du] = await Promise.all([readDerivedDataEntry($, derivedData, name, cwd), run($, ['du', '-sk', path], cwd)])
211 return { path, label: name, sizeGb: sizeGb(du.stdout), group: classifyDerivedData(entry, layout) }
212 }),
213 )
214}
215
216async function derivedDataReport($: EngineInterface, layout: RepoLayout, cwd: string): Promise<string> {
217 return formatDerivedData(await derivedDataFolders($, await derivedDataPath($), layout, cwd))
218}
219
220/** Why `rm -rf` must not touch the folder: it must really be a folder directly inside DerivedData, and still be an orphan now; undefined when it may. */
221async function orphanDeletionProblem($: EngineInterface, path: string, derivedData: string, layout: RepoLayout, cwd: string): Promise<string | undefined> {
222 if (!isDirectChild(path, derivedData)) return `it is not directly inside ${derivedData}.`
223 const [own, allowed] = await Promise.all([resolve($, path), resolve($, derivedData)])
224 if (own?.isLink) return 'it is a symbolic link.'
225 if (own?.realPath === undefined) return 'its real path could not be resolved.'
226 if (allowed?.realPath === undefined) return `the real path of ${derivedData} could not be resolved.`
227 if (!isDirectChild(own.realPath, allowed.realPath)) return `it resolves to ${own.realPath}, which is not directly inside ${allowed.realPath}.`
228 const entry = await readDerivedDataEntry($, derivedData, basename(path), cwd)
229 return isOrphan(entry, layout) ? undefined : 'it is no longer an orphan.'
230}
231
232async function pruneOrphans($: EngineInterface, layout: RepoLayout, confirmed: boolean, cwd: string): Promise<string> {
233 const derivedData = await derivedDataPath($)
234 const orphans = (await derivedDataFolders($, derivedData, layout, cwd)).filter(({ group }) => group.kind === 'orphan')
235 if (!confirmed || derivedData === null || orphans.length === 0) return formatOrphanPlan(orphans)
236
237 const failures = await Promise.all(
238 orphans.map(async folder => deleteFolder($, folder, await orphanDeletionProblem($, folder.path, derivedData, layout, cwd), cwd)),
239 )
240 return formatPrunedOrphans(
241 orphans.filter((_, index) => failures[index] === undefined),
242 failures.filter(failure => failure !== undefined),
243 )
244}
245
246export const register: Register = on => {
247 on('session.start', async ($, e, next) => {
248 await $.command.register({
249 name: 'worktrees',
250 description:
251 "Show each linked git worktree's size, last commit, dirty files, ledger and PR, largest first. `prune <name>` lists a worktree's build folders; add `--yes` to delete them. `derived-data` groups Xcode's DerivedData folders by the project they were built from. `prune-orphans` lists the folders whose project is gone; add `--yes` to delete them.",
252 argumentHint: '[prune <name> [--yes] | derived-data | prune-orphans [--yes]]',
253 })
254 return next(e)
255 })
256
257 on('command.run', { command: 'worktrees' }, async ($, e) => {
258 // Typed as always a string, but `claude plugin test` leaves it undefined when the caller omits it.
259 const command = parseCommand(e.args ?? '')
260 if (command.kind === 'usage') return { text: USAGE }
261 const cwd = await $.session.cwd()
262 const { error, ...layout } = await linkedWorktrees($, cwd)
263 if (error !== undefined) return { text: error }
264 switch (command.kind) {
265 case 'table':
266 return { text: await table($, layout.worktrees, cwd) }
267 case 'prune':
268 return { text: await prune($, layout.worktrees, command.name, command.confirmed, cwd) }
269 case 'derived-data':
270 return { text: await derivedDataReport($, layout, cwd) }
271 case 'prune-orphans':
272 return { text: await pruneOrphans($, layout, command.confirmed, cwd) }
273 }
274 })
275}
276hooks/worktrees.ts 362 lines1export type Worktree = { path: string; branch: string }
2
3export type PullRequest = { state: string; number: number }
4
5export type WorktreeRow = {
6 name: string
7 sizeGb: number | null
8 lastCommit: string | null
9 dirty: number | null
10 hasLedger: boolean
11 pr: string
12}
13
14/** Reads `git worktree list --porcelain -z`: fields end in NUL and an empty field ends a block, so a path is never quoted. */
15const worktreeBlocks = (porcelain: string): string[][] =>
16 porcelain
17 .split('\0\0')
18 .map(block => block.split('\0'))
19 .filter(lines => lines.some(line => line.startsWith('worktree ')))
20
21const worktreePath = (lines: string[]): string => lines.find(line => line.startsWith('worktree '))?.slice('worktree '.length) ?? ''
22
23export function parseWorktrees(porcelain: string): Worktree[] {
24 return worktreeBlocks(porcelain)
25 .slice(1)
26 .filter(lines => !lines.some(line => line === 'prunable' || line.startsWith('prunable ')))
27 .map(lines => ({
28 path: worktreePath(lines),
29 branch: lines.find(line => line.startsWith('branch refs/heads/'))?.slice('branch refs/heads/'.length) ?? '',
30 }))
31}
32
33/** git lists the main checkout first. */
34export function parseMainWorktree(porcelain: string): string | null {
35 const [main] = worktreeBlocks(porcelain)
36 return main === undefined ? null : worktreePath(main)
37}
38
39export const basename = (path: string): string => path.split('/').at(-1) ?? path
40
41export function parsePrs(stdout: string): PullRequest[] | null {
42 let parsed: unknown
43 try {
44 parsed = JSON.parse(stdout)
45 } catch {
46 return null
47 }
48 return Array.isArray(parsed) ? parsed.filter(isPullRequest) : null
49}
50
51function isPullRequest(value: unknown): value is PullRequest {
52 if (typeof value !== 'object' || value === null) return false
53 const { state, number } = value as Record<string, unknown>
54 return typeof state === 'string' && typeof number === 'number'
55}
56
57export function prLabel(prs: PullRequest[] | null): string {
58 if (prs === null) return '?'
59 const [pr] = prs
60 return pr ? `${pr.state} #${pr.number}` : 'none'
61}
62
63export function shortDate(stdout: string): string | null {
64 return stdout.trim().match(/^\d{4}-(\d\d-\d\d)$/)?.[1] ?? null
65}
66
67export function sizeGb(duStdout: string): number | null {
68 const kib = duStdout.match(/^\d+/)?.[0]
69 return kib === undefined ? null : (Number(kib) * 1024) / 1e9
70}
71
72export function dirtyCount(statusStdout: string): number {
73 return statusStdout.split('\n').filter(line => line !== '').length
74}
75
76const cell = (value: string | number | null): string => (value === null ? '?' : String(value))
77
78export function formatTable(rows: WorktreeRow[], prError?: string): string {
79 if (rows.length === 0) return 'No linked worktrees.'
80 const sorted = [...rows].sort((a, b) => (b.sizeGb ?? -1) - (a.sizeGb ?? -1))
81 const totalGb = rows.reduce((sum, row) => sum + (row.sizeGb ?? 0), 0)
82 const count = rows.length === 1 ? '1 worktree' : `${rows.length} worktrees, largest first`
83 return [
84 `**Worktrees** · ${count} · ${totalGb.toFixed(1)} GB total`,
85 '',
86 '| Worktree | Size GB | Last commit | Dirty | Ledger | PR |',
87 '|---|---:|---|---:|---|---|',
88 ...sorted.map(row => {
89 const cells = [row.name, cell(row.sizeGb?.toFixed(1) ?? null), cell(row.lastCommit), cell(row.dirty), row.hasLedger ? 'yes' : 'no', row.pr]
90 return `| ${cells.join(' | ')} |`
91 }),
92 ...(prError ? ['', `PR is ? because ${prError}`] : []),
93 ].join('\n')
94}
95
96export type WorktreesCommand =
97 | { kind: 'table' }
98 | { kind: 'prune'; name: string; confirmed: boolean }
99 | { kind: 'derived-data' }
100 | { kind: 'prune-orphans'; confirmed: boolean }
101 | { kind: 'usage' }
102
103export const USAGE = 'Usage: `/worktrees`, `/worktrees prune <name> [--yes]`, `/worktrees derived-data` or `/worktrees prune-orphans [--yes]`'
104
105export function parseCommand(args: string): WorktreesCommand {
106 const trimmed = args.trim()
107 if (trimmed === '') return { kind: 'table' }
108 if (trimmed === 'derived-data') return { kind: 'derived-data' }
109 const orphans = trimmed.match(/^prune-orphans(\s+--yes)?$/)
110 if (orphans !== null) return { kind: 'prune-orphans', confirmed: orphans[1] !== undefined }
111 const words = trimmed.match(/^prune\s+(.+)$/s)?.[1]
112 if (words === undefined) return { kind: 'usage' }
113 const confirmedName = words.match(/^(.+?)\s+--yes$/s)?.[1]
114 const name = confirmedName ?? words
115 return name === '--yes' ? { kind: 'usage' } : { kind: 'prune', name, confirmed: confirmedName !== undefined }
116}
117
118const BUILD_FOLDER_NAMES = new Set(['.build', '.expo-prebuild', '.cache', 'build', '.DerivedData'])
119
120/** Nothing in these is ever pruned: `.claude` holds agent state, and keeping `node_modules` and `Pods` lets a pruned worktree build without reinstalling. */
121const KEPT_FOLDER_NAMES = new Set(['.claude', 'node_modules', 'Pods'])
122
123export function ignoredBuildFolders(porcelain: string, root: string): string[] {
124 return porcelain
125 .split('\n')
126 .filter(line => line.startsWith('!! ') && line.endsWith('/'))
127 .map(line => line.slice('!! '.length, -1))
128 // git quotes a path with special characters in it; such a path is left alone rather than unquoted.
129 .filter(path => !path.startsWith('"'))
130 .filter(path => {
131 const segments = path.split('/')
132 return BUILD_FOLDER_NAMES.has(segments.at(-1) ?? '') && !segments.some(segment => KEPT_FOLDER_NAMES.has(segment))
133 })
134 .map(path => `${root}/${path}`)
135}
136
137/** The name prefix bin/cc-dd-path gives this worktree's Xcode build folders: `cc-<basename>-<sha1 of the path, 6 hex>-`. */
138export async function derivedDataPrefix(root: string): Promise<string> {
139 const digest = new Uint8Array(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(root)))
140 const hex = [...digest].map(byte => byte.toString(16).padStart(2, '0')).join('')
141 return `cc-${basename(root)}-${hex.slice(0, 6)}-`
142}
143
144/**
145 * Whether a DerivedData folder name is this worktree's: it starts with `prefix`, and what follows has no
146 * `<hex6>-` of its own, which would make it another worktree's whose name starts with `<basename>-<hex6>`.
147 */
148export const isOwnXcodeFolder = (name: string, prefix: string): boolean =>
149 name.startsWith(prefix) && !/(^|-)[0-9a-f]{6}-/.test(name.slice(prefix.length))
150
151export type PruneScope = { root: string; derivedData: string | null; prefix: string }
152
153/** The folder a path may be deleted from: the worktree for a folder strictly inside it and outside any kept folder, DerivedData for one of its own Xcode build folders, else null. */
154export function pruneBase(path: string, { root, derivedData, prefix }: PruneScope): string | null {
155 const segments = path.split('/')
156 if (segments.includes('..') || segments.includes('.')) return null
157 if (path.startsWith(`${root}/`)) {
158 const inside = path.slice(root.length + 1).split('/')
159 return inside.every(segment => segment !== '' && !KEPT_FOLDER_NAMES.has(segment)) ? root : null
160 }
161 if (derivedData === null || !path.startsWith(`${derivedData}/`)) return null
162 const name = path.slice(derivedData.length + 1)
163 return !name.includes('/') && isOwnXcodeFolder(name, prefix) ? derivedData : null
164}
165
166export const isPrunable = (path: string, scope: PruneScope): boolean => pruneBase(path, scope) !== null
167
168/** Why a folder whose real path is `realPath` may not be deleted from the folder whose real path is `realBase`; undefined when it may. */
169export function realPathProblem(realPath: string, realBase: string): string | undefined {
170 const inside = realPath.startsWith(`${realBase}/`) ? realPath.slice(realBase.length + 1) : ''
171 if (inside === '') return `it resolves to ${realPath}, which is not inside ${realBase}.`
172 const kept = inside.split('/').find(segment => KEPT_FOLDER_NAMES.has(segment))
173 if (kept !== undefined) return `it resolves to ${realPath}, inside a ${kept} folder.`
174 return undefined
175}
176
177export type PruneFolder = { path: string; label: string; sizeGb: number | null }
178
179/** Whether the folder's size prints as 0.0; an unknown size does not count as empty. */
180export const isEmptyFolder = ({ sizeGb }: PruneFolder): boolean => sizeGb !== null && sizeGb < 0.05
181
182const DERIVED_DATA_LABEL = '~/Library/Developer/Xcode/DerivedData'
183
184export const folderLabel = (path: string, root: string): string =>
185 path.startsWith(`${root}/`) ? path.slice(root.length + 1) : `${DERIVED_DATA_LABEL}/${basename(path)}`
186
187const gb = (folders: PruneFolder[]): string => folders.reduce((sum, { sizeGb }) => sum + (sizeGb ?? 0), 0).toFixed(1)
188
189const folderCount = (folders: PruneFolder[]): string => (folders.length === 1 ? '1 folder' : `${folders.length} folders`)
190
191function pruneTable(folders: PruneFolder[]): string[] {
192 const sorted = [...folders].sort((a, b) => (b.sizeGb ?? -1) - (a.sizeGb ?? -1))
193 return ['| Folder | Size GB |', '|---|---:|', ...sorted.map(({ label, sizeGb }) => `| ${label} | ${cell(sizeGb?.toFixed(1) ?? null)} |`)]
194}
195
196export function formatPrunePlan(name: string, folders: PruneFolder[]): string {
197 if (folders.length === 0) return `Nothing to prune in ${name}.`
198 return [
199 `**Prune ${name}** · dry run · ${gb(folders)} GB in ${folderCount(folders)}`,
200 '',
201 ...pruneTable(folders),
202 '',
203 'Folders inside a `.claude` folder are never deleted.',
204 '`node_modules` and `Pods` folders are kept, so the worktree still builds.',
205 `Run \`/worktrees prune ${name} --yes\` to delete them.`,
206 ].join('\n')
207}
208
209export function formatPruned(name: string, deleted: PruneFolder[], failures: string[]): string {
210 return [
211 `**Pruned ${name}** · ${gb(deleted)} GB freed in ${folderCount(deleted)}`,
212 ...(deleted.length > 0 ? ['', ...pruneTable(deleted)] : []),
213 ...(failures.length > 0 ? ['', ...failures] : []),
214 ].join('\n')
215}
216
217/**
218 * `workspacePath` is the WorkspacePath of the folder's info.plist as `workspacePathOf` reads it, null when it has none;
219 * `exists` says whether that path is on disk, and `diskConnected` whether the disk that holds it is mounted.
220 */
221export type DerivedDataEntry = { name: string; workspacePath: string | null; exists: boolean; diskConnected: boolean }
222
223/** `main` is the main checkout, null when git did not name one. */
224export type RepoLayout = { main: string | null; worktrees: Worktree[] }
225
226export type DerivedDataGroup =
227 | { kind: 'main' }
228 | { kind: 'worktree'; path: string }
229 | { kind: 'other' }
230 | { kind: 'disk-not-connected' }
231 | { kind: 'orphan' }
232 | { kind: 'no-source' }
233 | { kind: 'cache' }
234
235export type DerivedDataFolder = PruneFolder & { group: DerivedDataGroup }
236
237/**
238 * Reads the stdout of `plutil -extract WorkspacePath raw`, null when plutil failed. plutil ends the value with a newline;
239 * only an absolute path counts, and its `.` and `..` segments are resolved so a prefix match cannot be fooled by them.
240 */
241export function workspacePathOf(plutilStdout: string | null): string | null {
242 if (plutilStdout === null) return null
243 const path = plutilStdout.endsWith('\n') ? plutilStdout.slice(0, -1) : plutilStdout
244 return path.startsWith('/') ? resolveAbsolute(path) : null
245}
246
247/** What `path.resolve` returns for an absolute path; a hooks module may not import `node:path`. */
248function resolveAbsolute(path: string): string {
249 const segments: string[] = []
250 for (const segment of path.split('/')) {
251 if (segment === '..') segments.pop()
252 else if (segment !== '' && segment !== '.') segments.push(segment)
253 }
254 return `/${segments.join('/')}`
255}
256
257/** The `/Volumes/<name>` folder macOS mounts a disk at, when the path is inside one. */
258export const volumeOf = (path: string): string | null => /^\/Volumes\/[^/]+(?=\/.)/.exec(path)?.[0] ?? null
259
260export type VolumeProbe = {
261 /** `/usr/bin/stat -L -f %d /Volumes/<name>` output; null when stat failed. */
262 device: string | null
263 /** `/usr/bin/stat -f %d /Volumes` output; null when stat failed. */
264 volumesDevice: string | null
265 /** The folder's real path when it is a symbolic link, else null. */
266 linkTarget: string | null
267}
268
269const deviceNumber = (statStdout: string | null): string | null => /^(\d+)\n?$/.exec(statStdout ?? '')?.[1] ?? null
270
271/**
272 * A mounted disk sits on another device than `/Volumes`; an unmount can leave an empty folder behind on the `/Volumes`
273 * device itself. The boot disk is the exception: macOS links it there to `/`, which shares the `/Volumes` device.
274 */
275export function isDiskConnected({ device, volumesDevice, linkTarget }: VolumeProbe): boolean {
276 if (linkTarget === '/') return true
277 const [own, volumes] = [deviceNumber(device), deviceNumber(volumesDevice)]
278 return own !== null && volumes !== null && own !== volumes
279}
280
281/** Groups by the folder's WorkspacePath, never its name; a workspace belongs to the linked worktree with the longest matching root, since linked worktrees can sit inside the main checkout and inside each other. */
282export function classifyDerivedData({ name, workspacePath, exists, diskConnected }: DerivedDataEntry, { main, worktrees }: RepoLayout): DerivedDataGroup {
283 if (name.endsWith('.noindex') || name === 'SDKExplicitPrecompiledModules') return { kind: 'cache' }
284 if (workspacePath === null) return { kind: 'no-source' }
285 if (!exists) return diskConnected ? { kind: 'orphan' } : { kind: 'disk-not-connected' }
286 const [worktree] = worktrees.filter(({ path }) => workspacePath.startsWith(`${path}/`)).sort((a, b) => b.path.length - a.path.length)
287 if (worktree !== undefined) return { kind: 'worktree', path: worktree.path }
288 return main !== null && workspacePath.startsWith(`${main}/`) ? { kind: 'main' } : { kind: 'other' }
289}
290
291export const isOrphan = (entry: DerivedDataEntry, layout: RepoLayout): boolean => classifyDerivedData(entry, layout).kind === 'orphan'
292
293export function isDirectChild(path: string, parent: string): boolean {
294 const name = path.startsWith(`${parent}/`) ? path.slice(parent.length + 1) : ''
295 return name !== '' && name !== '.' && name !== '..' && !name.includes('/')
296}
297
298const GROUP_ORDER: DerivedDataGroup['kind'][] = ['main', 'worktree', 'other', 'disk-not-connected', 'orphan', 'no-source', 'cache']
299
300const GROUP_LABELS = {
301 main: 'Main checkout',
302 other: 'Other location',
303 'disk-not-connected': 'Disk not connected',
304 orphan: 'Orphan',
305 'no-source': 'No source recorded',
306 cache: 'Shared caches',
307} as const
308
309const groupLabel = (group: DerivedDataGroup): string => (group.kind === 'worktree' ? `Linked worktree ${basename(group.path)}` : GROUP_LABELS[group.kind])
310
311const groupKey = (group: DerivedDataGroup): string => (group.kind === 'worktree' ? `worktree ${group.path}` : group.kind)
312
313const byGroup = (a: DerivedDataFolder, b: DerivedDataFolder): number =>
314 GROUP_ORDER.indexOf(a.group.kind) - GROUP_ORDER.indexOf(b.group.kind) ||
315 groupLabel(a.group).localeCompare(groupLabel(b.group)) ||
316 groupKey(a.group).localeCompare(groupKey(b.group))
317
318const ORPHAN_NOTE = 'An orphan was built from a project that no longer exists on disk.'
319
320const DISK_NOT_CONNECTED_NOTE = 'A folder here was built from a project on a disk that is not connected, so it is never deleted.'
321
322function groupLines(members: DerivedDataFolder[]): string[] {
323 const { group } = members[0]
324 return [
325 '',
326 `**${groupLabel(group)}** · ${gb(members)} GB in ${folderCount(members)}`,
327 '',
328 ...pruneTable(members),
329 ...(group.kind === 'disk-not-connected' ? ['', DISK_NOT_CONNECTED_NOTE] : []),
330 ]
331}
332
333export function formatDerivedData(folders: DerivedDataFolder[]): string {
334 if (folders.length === 0) return `No folders in ${DERIVED_DATA_LABEL}.`
335 const groups = new Map<string, DerivedDataFolder[]>()
336 for (const folder of [...folders].sort(byGroup)) {
337 const key = groupKey(folder.group)
338 groups.set(key, [...(groups.get(key) ?? []), folder])
339 }
340 return [
341 `**${DERIVED_DATA_LABEL}** · ${gb(folders)} GB in ${folderCount(folders)}`,
342 ...[...groups.values()].flatMap(groupLines),
343 ...(groups.has('orphan') ? ['', ORPHAN_NOTE, 'Run `/worktrees prune-orphans --yes` to delete the orphans.'] : []),
344 ].join('\n')
345}
346
347const ORPHANS = `orphans in ${DERIVED_DATA_LABEL}`
348
349export function formatOrphanPlan(orphans: PruneFolder[]): string {
350 if (orphans.length === 0) return `Nothing to prune: no ${ORPHANS}.`
351 return [
352 `**Prune ${ORPHANS}** · dry run · ${gb(orphans)} GB in ${folderCount(orphans)}`,
353 '',
354 ...pruneTable(orphans),
355 '',
356 ORPHAN_NOTE,
357 'Run `/worktrees prune-orphans --yes` to delete them.',
358 ].join('\n')
359}
360
361export const formatPrunedOrphans = (deleted: PruneFolder[], failures: string[]): string => formatPruned(ORPHANS, deleted, failures)
362