Measures the build artifacts of the repository, shows them on the status line from 5 GB, and deletes the ones you pick in the /disk-janitor pane; data…

Build tools never clean up after themselves: node_modules, Rust's target and Python's .venv grow quietly until the disk is full. This mod measures the build artifacts of the session's repository, shows the total once it passes 5 GB, and deletes the directories you pick in the /disk-janitor pane. A data directory is never listed and never deleted.
git ls-files --others --ignored --exclude-standard --directory -z in the repository of the session's directory. That directory is the one the session started in, read once at the start, because a Bash cd moves the session's own directory and would point the measurement at another repository. Outside a git repository it does nothing. The 60 s measurement shows a build or a deletion made in another window while this session is idle. It waits while the pane's delete button is armed, because a measurement disarms the button, and it is skipped while the last measurement still runs.node_modules (with .package-lock.json, .modules.yaml, .yarn-integrity or .yarn-state.yml inside), target (with CACHEDIR.TAG or .rustc_info.json), .venv and venv (with pyvenv.cfg), __pycache__, .pytest_cache, .mypy_cache, .ruff_cache, .phpunit.cache, .next, .nuxt, .turbo, .parcel-cache, .gradle, DerivedData, Pods. A certain name without its marker file counts as unsure.dist, build, out, bin, obj, vendor, .cache, coverage. Listed with (unsure) and never picked in advance.docker-data, data, training, dataset, datasets, models, uploads, media, storage, db, database, pgdata, volumes, backup, backups, dump, dumps, logs, git-clone, release, releases, artifacts, cache. Never listed. The mod looks one level inside and lists an artifact there (training/.venv), never the data directory itself.du -sk call, by argv, in the background, so no prompt waits for it.disk-janitor: artifacts 7.4 GB · /disk-janitor disk-janitor: over 20 GB: artifacts 23.1 GB · /disk-janitor
With the sidebar open, that line goes there instead as a build artifacts section that stays for the session, and the status line stays clear. Only the size is coloured, yellow from 5 GB and red from 20 GB, and · /disk-janitor is faint; below 5 GB the section goes away. A second, faint line holds the last deletion: what went in green, N skipped in yellow, N failed in red. A clean up button opens the pane:
disk-janitor: build artifacts artifacts 7.4 GB · /disk-janitor deleted 2 dir(s), 2.5 GB [ clean up ]
The button runs /disk-janitor, which opens the pane (and closes it if it is open). It deletes nothing by itself: the picks and the two presses stay in the pane.
Without the sidebar, the status line is drawn as above.
/disk-janitor opens the pane, and running it again closes it. The pane takes the keys, and Esc closes it.
/Users/you/app · 6.5 GB [x] node_modules 2.0 GB [x] target 4.0 GB [ ] dist 1 MB (unsure) [x] data/venv 512 MB [ Delete selected (6.5 GB) ] kept, data: data
Enter on a row picks it or drops it. The first Enter on the delete button turns it into Press again to delete 3 dir(s), 6.5 GB; the second one deletes. Your picks are kept across later measurements.
Right before each deletion the mod checks the directory again: it must still be a directory and not a link, lie inside the repository (by its resolved path), still be git-ignored (git check-ignore), and still be of the class it was listed with. A directory that fails a check is skipped and named. The deletion is rm -rf -- <absolute path> by argv, without a shell.
One transcript line then says what went and what stayed, naming the data directories:
disk-janitor: deleted 1 dir(s), 3 MB: node_modules (3 MB) · kept, data: data
/disk-janitor open or close the pane /disk-janitor list the listed directories as text, for a surface without the pane /disk-janitor rescan measure again now /disk-janitor delete <path> delete one listed directory, with the same checks as the pane
delete runs only for a command you typed at the prompt or through the bridge. A command a plugin runs is refused, so the model cannot delete anything.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install disk-janitor@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.288:
❯ ./register.tsx hooks: session.start, turn.complete, command.run{command=disk-janitor}, ui.render{component=Pane}, ui.close ❯ ./register.tsx calls: $.clock.after (via refreshInBackground), $.clock.every, $.clock.now, $.command.register, $.fs.exists (via hasAnyMarker), $.fs.list (via insideData), $.fs.stat (via staleReason), $.process.run (via findArtifacts, measure, removeDir, repoRoot, staleReason), $.session.cwd (via refresh), $.sidebar.clear (via toSidebar), $.sidebar.isOpen (via toSidebar), $.sidebar.set (via toSidebar), $.ui.close (via openPane), $.ui.invalidate (via pressDelete, refresh, toggle), $.ui.log (via pressDelete, refreshInBackground), $.ui.open (via openPane), $.ui.panes (via openPane), $.ui.resolve, $.ui.status (via showTotal)
Reach L2: it runs processes and deletes directories.
du on a very large tree can take a while. The measurement runs in the background with a 60-second limit, and the pane shows measuring… until the first one ends. An interactive session runs it every 60 s, so a repository with hundreds of thousands of files under its artifact directories reads them that often (measured: du -sk over the 42 git-ignored directories of this mod's repository, 231 MB, took 0.14 s).make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.tsx 340 lines1import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
2import { baseName, classOfRule, ignoredDirs, nameRule, parseDu, sizeText, statusLine, type Class, type Line } from './classify.ts'
3import { carriedSelection, deletedShort, listText, reportText, rowText, totalKb, type Outcome } from './report.ts'
4import { MAX_FOUND, type Found, type Scan } from './scan.ts'
5
6const PANE_ID = 'disk-janitor'
7
8/** A new measurement starts at most this often after a turn. */
9const SCAN_EVERY_MS = 10 * 60 * 1000
10
11/** How often an interactive session measures again, so a build or a deletion in another window shows without a turn. */
12const TICK_MS = 60_000
13
14const USAGE = 'expects nothing (the pane), list, rescan, or delete <path>'
15
16const BUSY = 'a measurement or a deletion is running; try again when it ends'
17
18/** Only a person may delete: the prompt's Enter or the bridge, never a plugin or the model. */
19const PERSON: ReadonlySet<PromptOrigin['kind']> = new Set(['composer', 'bridge'])
20
21type Elements = ReturnType<EngineInterface['ui']['resolve']>
22
23/**
24 * The last scan, the paths picked in the pane, whether the delete button was
25 * pressed once, whether a scan or a deletion runs, the last background error,
26 * and the directory the session started in. The repository is looked for under
27 * that directory, not under `$.session.cwd()`, because a Bash `cd` moves the
28 * session's directory and would point the scan at another repository.
29 */
30type State = { scan?: Scan; selected: Set<string>; confirm: boolean; busy: boolean; scannedAt: number; lastError?: string; cwd?: string; event?: Line }
31
32/** The section this mod owns in the shared sidebar. */
33const SECTION = { consumer: 'disk-janitor', key: 'artifacts' }
34
35/** The section's button: `/disk-janitor`, which opens the pane or closes it. */
36const PANE_BUTTON = { label: 'clean up', command: 'disk-janitor' }
37
38function errorText(err: unknown): string {
39 return err instanceof Error ? err.message : String(err)
40}
41
42/** The repository's top level, or undefined outside git. */
43async function repoRoot($: EngineInterface, cwd: string): Promise<string | undefined> {
44 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 10_000 })
45 return r.exitCode === 0 ? r.stdout.trim() : undefined
46}
47
48async function hasAnyMarker($: EngineInterface, dir: string, markers: readonly string[]): Promise<boolean> {
49 for (const m of markers) if (await $.fs.exists(`${dir}/${m}`)) return true
50 return false
51}
52
53/** The class of a directory by its name and, for a certain name, its marker files. */
54async function classOf($: EngineInterface, abs: string): Promise<Class | undefined> {
55 const rule = nameRule(baseName(abs))
56 if (rule === undefined) return undefined
57 return classOfRule(rule, rule.markers.length > 0 && (await hasAnyMarker($, abs, rule.markers)))
58}
59
60/** The artifact directories one level inside a data directory (`training/.venv`); a data directory there is left alone. */
61async function insideData($: EngineInterface, root: string, rel: string): Promise<Found[]> {
62 const found: Found[] = []
63 for (const entry of await $.fs.list(`${root}/${rel}`)) {
64 if (entry.kind !== 'dir' || entry.isLink) continue
65 const path = `${rel}/${entry.name}`
66 const cls = await classOf($, `${root}/${path}`)
67 if (cls !== undefined && cls !== 'data') found.push({ path, cls })
68 }
69 return found
70}
71
72/** The git-ignored directories of the repository, sorted into artifacts and data. */
73async function findArtifacts($: EngineInterface, root: string): Promise<{ found: Found[]; data: string[] }> {
74 const r = await $.process.run(['git', 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: root, timeoutMs: 20_000 })
75 if (r.exitCode !== 0) throw new Error(`git ls-files failed: ${r.stderr.trim().slice(0, 200)}`)
76 const found: Found[] = []
77 const data: string[] = []
78 for (const rel of ignoredDirs(r.stdout)) {
79 if (found.length >= MAX_FOUND) break
80 const cls = await classOf($, `${root}/${rel}`)
81 if (cls === 'data') {
82 data.push(rel)
83 found.push(...(await insideData($, root, rel)))
84 } else if (cls !== undefined) found.push({ path: rel, cls })
85 }
86 return { found: found.slice(0, MAX_FOUND), data }
87}
88
89/** Kilobytes per relative path; `du` exits 1 over an unreadable file and still prints the rest. */
90async function measure($: EngineInterface, root: string, found: readonly Found[]): Promise<Map<string, number>> {
91 if (found.length === 0) return new Map()
92 const r = await $.process.run(['du', '-sk', '--', ...found.map(f => `${root}/${f.path}`)], { cwd: root, timeoutMs: 60_000 })
93 if (r.exitCode !== 0 && r.stdout.trim() === '') throw new Error(`du failed: ${r.stderr.trim().slice(0, 200)}`)
94 const byAbs = parseDu(r.stdout)
95 return new Map(found.map(f => [f.path, byAbs.get(`${root}/${f.path}`) ?? 0]))
96}
97
98/** Finds and measures the artifacts of the repository at `root`. */
99async function scanRepo($: EngineInterface, root: string): Promise<Scan> {
100 const { found, data } = await findArtifacts($, root)
101 return { root, found, data, sizes: await measure($, root, found) }
102}
103
104/**
105 * Why a listed directory may no longer be deleted, or undefined when it may:
106 * it must still be a real directory inside the repository, not a link, still
107 * git-ignored, and still of the class it was listed with.
108 */
109async function staleReason($: EngineInterface, root: string, f: Found): Promise<string | undefined> {
110 const abs = `${root}/${f.path}`
111 const rootReal = (await $.fs.stat(root, { resolve: true })).realPath
112 const st = await $.fs.stat(abs, { resolve: true })
113 if (st.isLink || st.kind !== 'dir') return 'no longer a directory'
114 if (rootReal === undefined || st.realPath?.startsWith(`${rootReal}/`) !== true) return 'outside the repository'
115 const ignored = await $.process.run(['git', 'check-ignore', '-q', '--', f.path], { cwd: root, timeoutMs: 10_000 })
116 if (ignored.exitCode !== 0) return 'no longer git-ignored'
117 return (await classOf($, abs)) === f.cls ? undefined : 'its contents changed'
118}
119
120/** Deletes one directory by argv, no shell; answers the error, or undefined. */
121async function removeDir($: EngineInterface, root: string, f: Found): Promise<string | undefined> {
122 const r = await $.process.run(['rm', '-rf', '--', `${root}/${f.path}`], { cwd: root, timeoutMs: 300_000 })
123 return r.exitCode === 0 ? undefined : r.stderr.trim().slice(0, 200) || `rm exited ${r.exitCode}`
124}
125
126/**
127 * Writes the total into the shared sidebar and answers whether it took it. Under 5 GB there is
128 * nothing to show and the section goes down. The section carries the last deletion under the total,
129 * faint. A closed sidebar, and a sidebar mod that is not installed, both answer false, so the status
130 * line is drawn instead.
131 */
132async function toSidebar($: EngineInterface, state: State, line: Line | undefined): Promise<boolean> {
133 try {
134 if (line === undefined) {
135 await $.sidebar.clear(SECTION)
136 return await $.sidebar.isOpen()
137 }
138 const lines = [line, ...(state.event === undefined ? [] : [state.event])]
139 // The button opens the pane, where the person picks and deletes; a command a plugin runs never deletes.
140 return await $.sidebar.set({ ...SECTION, title: 'build artifacts', lines, buttons: [PANE_BUTTON], until: 'session', order: 20 })
141 } catch {
142 // The sidebar mod is not installed.
143 return false
144 }
145}
146
147/** Shows the measured total on the one channel that takes it. */
148async function showTotal($: EngineInterface, state: State): Promise<void> {
149 const line = state.scan === undefined ? undefined : statusLine(totalKb(state.scan))
150 $.ui.status((await toSidebar($, state, line)) ? undefined : line?.text)
151}
152
153/** Measures the session's repository, shows the total and redraws the pane; a running measurement or deletion is left to finish. */
154async function refresh($: EngineInterface, state: State): Promise<void> {
155 if (state.busy) return
156 state.busy = true
157 try {
158 const root = await repoRoot($, state.cwd ?? (await $.session.cwd()))
159 const before = state.scan
160 state.scan = root === undefined ? undefined : await scanRepo($, root)
161 // Stamped after the measurement, so a failed one is tried again at the next turn.
162 state.scannedAt = await $.clock.now()
163 state.selected = carriedSelection(before?.root === state.scan?.root ? before : undefined, state.selected, state.scan)
164 state.confirm = false
165 await showTotal($, state)
166 } finally {
167 state.busy = false
168 $.ui.invalidate('ui.render')
169 }
170}
171
172/**
173 * A background scan has no hook to fail, so its error is logged; the same error once. The scan runs
174 * on a timer, not in the calling dispatch: since 2.1.288 a process call still in flight when the
175 * dispatch closes is aborted, and a scan aborted at its start never finishes.
176 */
177function refreshInBackground($: EngineInterface, state: State): void {
178 $.clock.after(0, () => {
179 refresh($, state).then(
180 () => {
181 state.lastError = undefined
182 },
183 (err: unknown) => {
184 const text = errorText(err)
185 if (text !== state.lastError) $.ui.log(`cannot measure the artifacts: ${text}`)
186 state.lastError = text
187 },
188 )
189 })
190}
191
192/**
193 * The timed measure. It waits while the pane's delete button is armed, because a measure disarms it and
194 * the person's second press would then arm it again instead of deleting.
195 */
196function tick($: EngineInterface, state: State): void {
197 if (!state.confirm) refreshInBackground($, state)
198}
199
200/** Deletes each picked directory that still passes every check, and answers what went and what stayed. */
201async function deletePicked($: EngineInterface, state: State, picked: readonly Found[]): Promise<string> {
202 const scan = state.scan
203 if (scan === undefined) return 'nothing measured yet'
204 if (state.busy) return BUSY
205 state.busy = true
206 const o: Outcome = { deleted: [], skipped: [], failed: [], freedKb: 0 }
207 try {
208 for (const f of picked) await deleteOne($, scan, f, o)
209 } finally {
210 state.busy = false
211 }
212 // The measurement that follows redraws the section, so the line is kept before it starts.
213 state.event = deletedShort(o)
214 refreshInBackground($, state)
215 return reportText(o, scan.data)
216}
217
218async function deleteOne($: EngineInterface, scan: Scan, f: Found, o: Outcome): Promise<void> {
219 const reason = await staleReason($, scan.root, f)
220 if (reason !== undefined) {
221 o.skipped.push(`${f.path} (${reason})`)
222 return
223 }
224 const error = await removeDir($, scan.root, f)
225 if (error !== undefined) {
226 o.failed.push(`${f.path} (${error})`)
227 return
228 }
229 const kb = scan.sizes.get(f.path) ?? 0
230 o.freedKb += kb
231 o.deleted.push(`${f.path} (${sizeText(kb)})`)
232}
233
234/** The first press arms the button, the second deletes. */
235function pressDelete($: EngineInterface, state: State): void {
236 const picked = state.scan?.found.filter(f => state.selected.has(f.path)) ?? []
237 if (picked.length === 0) return
238 if (!state.confirm) {
239 state.confirm = true
240 $.ui.invalidate('ui.render')
241 return
242 }
243 deletePicked($, state, picked).then(
244 text => $.ui.log(text),
245 (err: unknown) => $.ui.log(`delete stopped: ${errorText(err)}`),
246 )
247}
248
249function toggle($: EngineInterface, state: State, path: string): void {
250 if (!state.selected.delete(path)) state.selected.add(path)
251 state.confirm = false
252 $.ui.invalidate('ui.render')
253}
254
255/** `/disk-janitor delete <path>`, for a surface without the pane; the path must be one the last scan listed. */
256async function deleteByCommand($: EngineInterface, state: State, path: string, origin: PromptOrigin): Promise<string> {
257 if (!PERSON.has(origin.kind)) return 'refused: only you can delete, from the prompt or the pane'
258 const f = state.scan?.found.find(x => x.path === path.replace(/\/+$/, ''))
259 if (f === undefined) return `not listed: ${path}; /disk-janitor list shows what can be deleted`
260 return deletePicked($, state, [f])
261}
262
263async function openPane($: EngineInterface, state: State): Promise<string> {
264 if ((await $.ui.panes()).some(p => p.id === PANE_ID)) {
265 await $.ui.close({ id: PANE_ID })
266 return 'pane closed'
267 }
268 refreshInBackground($, state)
269 const rows = Math.min((state.scan?.found.length ?? 0) + 5, 24)
270 await $.ui.open({ id: PANE_ID, title: 'Build artifacts', focus: true, closeOnEscape: true, holdToasts: true, rows })
271 return 'pane open: Enter picks a row, the delete button asks twice, Esc closes'
272}
273
274async function runCommand($: EngineInterface, state: State, args: string, origin: PromptOrigin): Promise<string> {
275 const [word = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
276 if (word === '') return openPane($, state)
277 if (word === 'list') return listText(state.scan)
278 if (word === 'rescan') {
279 if (state.busy) return BUSY
280 await refresh($, state)
281 return listText(state.scan)
282 }
283 return word === 'delete' && rest.length > 0 ? deleteByCommand($, state, rest.join(' '), origin) : USAGE
284}
285
286function paneTree(els: Elements, state: State, onToggle: (path: string) => void, onDelete: () => void) {
287 const { Box, Button, Text } = els
288 const scan = state.scan
289 if (scan === undefined) return <Text dimColor>{state.busy ? 'measuring…' : 'not in a git repository, or not measured yet'}</Text>
290 const picked = scan.found.filter(f => state.selected.has(f.path))
291 const pickedKb = picked.reduce((sum, f) => sum + (scan.sizes.get(f.path) ?? 0), 0)
292 const label = state.confirm ? `Press again to delete ${picked.length} dir(s), ${sizeText(pickedKb)}` : `Delete selected (${sizeText(pickedKb)})`
293 return (
294 <Box flexDirection="column">
295 <Text dimColor>{`${scan.root} · ${sizeText(totalKb(scan))}${state.busy ? ' · working…' : ''}`}</Text>
296 {scan.found.map((f, i) => (
297 <Button key={`row:${f.path}`} plain {...(i === 0 ? { autoFocus: true as const } : {})} label={`${state.selected.has(f.path) ? '[x]' : '[ ]'} ${rowText(scan, f)}`} onPress={() => onToggle(f.path)} />
298 ))}
299 {scan.found.length === 0 ? <Text>No build artifact found.</Text> : <Button key="delete" label={label} onPress={onDelete} />}
300 {scan.data.length === 0 ? null : <Text dimColor>{`kept, data: ${scan.data.join(', ')}`}</Text>}
301 </Box>
302 )
303}
304
305export const register: Register = on => {
306 const state: State = { selected: new Set(), confirm: false, busy: false, scannedAt: -Infinity }
307
308 on('session.start', async ($, e, next) => {
309 const r = await next(e)
310 state.cwd = e.cwd
311 await $.command.register({
312 name: 'disk-janitor',
313 description: 'Build artifacts of this repository: the pane, list, rescan, delete <path> (disk-janitor)',
314 argumentHint: '[list | rescan | delete <path>]',
315 })
316 refreshInBackground($, state)
317 if (e.isInteractive) $.clock.every(TICK_MS, () => tick($, state))
318 return r
319 })
320
321 on('turn.complete', async ($, e, next) => {
322 const r = await next(e)
323 if (e.agentId === undefined && (await $.clock.now()) - state.scannedAt >= SCAN_EVERY_MS) refreshInBackground($, state)
324 return r
325 })
326
327 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
328 on('command.run', { command: 'disk-janitor' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? ''), e.origin) }))
329
330 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
331 if (e.requestId !== PANE_ID) return next(e)
332 return paneTree($.ui.resolve(e), state, path => toggle($, state, path), () => pressDelete($, state))
333 })
334
335 on('ui.close', async (_, e, next) => {
336 if (e.id === PANE_ID) state.confirm = false
337 return next(e)
338 })
339}
340hooks/classify.ts 119 lines1/** Which git-ignored directories are build artifacts, which may be, and which hold data. */
2
3/**
4 * `certain`: a build reproduces it. `unsure`: often a build output, sometimes
5 * not; listed, never preselected. `data`: never listed, never deleted.
6 */
7export type Class = 'certain' | 'unsure' | 'data'
8
9/** Names that hold data no build reproduces; the directory is never listed. */
10const DATA_NAMES: ReadonlySet<string> = new Set([
11 'docker-data', 'data', 'training', 'dataset', 'datasets', 'models', 'uploads', 'media', 'storage',
12 'db', 'database', 'pgdata', 'volumes', 'backup', 'backups', 'dump', 'dumps', 'logs', 'git-clone',
13 'release', 'releases', 'artifacts', 'cache',
14])
15
16/** Names that are often build output, and sometimes a hand-made or committed directory. */
17const UNSURE_NAMES: ReadonlySet<string> = new Set(['dist', 'build', 'out', 'bin', 'obj', 'vendor', '.cache', 'coverage'])
18
19/**
20 * Names a build reproduces. A name with markers is certain only when one of
21 * them is inside (a `target` with no `CACHEDIR.TAG` may be anything); without
22 * one it is unsure.
23 */
24const CERTAIN: Readonly<Record<string, readonly string[]>> = {
25 node_modules: ['.package-lock.json', '.modules.yaml', '.yarn-integrity', '.yarn-state.yml'],
26 target: ['CACHEDIR.TAG', '.rustc_info.json'],
27 '.venv': ['pyvenv.cfg'],
28 venv: ['pyvenv.cfg'],
29 __pycache__: [],
30 '.pytest_cache': [],
31 '.mypy_cache': [],
32 '.ruff_cache': [],
33 '.phpunit.cache': [],
34 '.next': [],
35 '.nuxt': [],
36 '.turbo': [],
37 '.parcel-cache': [],
38 '.gradle': [],
39 DerivedData: [],
40 Pods: [],
41}
42
43/** What a name alone says: its class, and the marker files that decide a certain one. */
44export type NameRule = { cls: Class; markers: readonly string[] }
45
46export function nameRule(name: string): NameRule | undefined {
47 if (DATA_NAMES.has(name)) return { cls: 'data', markers: [] }
48 const markers = CERTAIN[name]
49 if (markers !== undefined) return { cls: 'certain', markers }
50 return UNSURE_NAMES.has(name) ? { cls: 'unsure', markers: [] } : undefined
51}
52
53/** The class of a directory from its rule and whether one of its markers is inside. */
54export function classOfRule(rule: NameRule, hasMarker: boolean): Class {
55 if (rule.cls !== 'certain' || rule.markers.length === 0) return rule.cls
56 return hasMarker ? 'certain' : 'unsure'
57}
58
59/** The directories in `git ls-files --others --ignored --exclude-standard --directory -z` output, without the trailing slash. */
60export function ignoredDirs(output: string): string[] {
61 return output.split('\0').filter(p => p.endsWith('/')).map(p => p.slice(0, -1)).filter(p => p !== '')
62}
63
64export function baseName(path: string): string {
65 return path.slice(path.lastIndexOf('/') + 1)
66}
67
68/** `du -sk` output: kilobytes per absolute path. */
69export function parseDu(output: string): Map<string, number> {
70 const sizes = new Map<string, number>()
71 for (const line of output.split('\n')) {
72 const m = /^(\d+)\t(.+)$/.exec(line)
73 if (m?.[1] !== undefined && m[2] !== undefined) sizes.set(m[2], Number(m[1]))
74 }
75 return sizes
76}
77
78const KB_PER_MB = 1024
79const KB_PER_GB = 1024 * 1024
80
81/** `3.4 GB`, `512 MB`, `12 KB`. */
82export function sizeText(kb: number): string {
83 if (kb >= KB_PER_GB) return `${(kb / KB_PER_GB).toFixed(1)} GB`
84 if (kb >= KB_PER_MB) return `${Math.round(kb / KB_PER_MB)} MB`
85 return `${kb} KB`
86}
87
88/** At this total the status line shows the artifacts, and at the second it says so louder. */
89export const WARN_KB = 5 * KB_PER_GB
90export const LOUD_KB = 20 * KB_PER_GB
91
92/** How the sidebar colours a line or a part of one. */
93export type Tone = 'ok' | 'warn' | 'error' | 'dim'
94export type Part = { text: string; kind?: Tone }
95/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
96export type Line = { text: string; kind?: Tone; parts?: Part[] }
97
98export const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
99
100/** A line made of parts, its `text` their texts joined. */
101export const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
102
103/** The colour of the total in the sidebar: red over 20 GB, yellow from 5 GB; under it no line is drawn. */
104export function statusTone(totalKb: number): 'warn' | 'error' {
105 return totalKb >= LOUD_KB ? 'error' : 'warn'
106}
107
108/** The status line for a total in parts, only the size coloured and the command faint, or undefined under 5 GB. */
109export function statusLine(totalKb: number): Line | undefined {
110 if (totalKb < WARN_KB) return undefined
111 const head = totalKb >= LOUD_KB ? 'over 20 GB: artifacts ' : 'artifacts '
112 return partsLine([part(head, undefined), part(sizeText(totalKb), statusTone(totalKb)), part(' · /disk-janitor', 'dim')])
113}
114
115/** The status line for a total, or undefined under 5 GB. */
116export function statusText(totalKb: number): string | undefined {
117 return statusLine(totalKb)?.text
118}
119hooks/report.ts 60 lines1/** The lines the user reads about a scan and a deletion. */
2import { part, partsLine, sizeText, type Line } from './classify.ts'
3import type { Found, Scan } from './scan.ts'
4
5export function totalKb(scan: Scan): number {
6 let total = 0
7 for (const kb of scan.sizes.values()) total += kb
8 return total
9}
10
11/** One pane or list row: `node_modules 1.2 GB (unsure)`. */
12export function rowText(scan: Scan, f: Found): string {
13 return `${f.path} ${sizeText(scan.sizes.get(f.path) ?? 0)}${f.cls === 'unsure' ? ' (unsure)' : ''}`
14}
15
16/** What happened to each directory the user picked. */
17export type Outcome = { deleted: string[]; skipped: string[]; failed: string[]; freedKb: number }
18
19/**
20 * The transcript line after a deletion; the data directories are named, because
21 * they stayed. One line: a log row draws a newline as a replacement glyph (measured on 2.1.278).
22 */
23export function reportText(o: Outcome, data: readonly string[]): string {
24 const parts = [o.deleted.length === 0 ? 'deleted nothing' : `deleted ${o.deleted.length} dir(s), ${sizeText(o.freedKb)}: ${o.deleted.join(', ')}`]
25 if (o.skipped.length > 0) parts.push(`skipped: ${o.skipped.join(', ')}`)
26 if (o.failed.length > 0) parts.push(`failed: ${o.failed.join(', ')}`)
27 parts.push(data.length === 0 ? 'no data directory found' : `kept, data: ${data.join(', ')}`)
28 return parts.join(' · ')
29}
30
31/**
32 * The sidebar's second line after a deletion: how much went, without the names the pane holds. What
33 * went is green, what was skipped yellow, what failed red, and the rest faint.
34 */
35export function deletedShort(o: Outcome): Line {
36 const parts = [o.deleted.length === 0 ? part('deleted nothing', 'dim') : part(`deleted ${o.deleted.length} dir(s), ${sizeText(o.freedKb)}`, 'ok')]
37 if (o.skipped.length > 0) parts.push(part(' · ', 'dim'), part(`${o.skipped.length} skipped`, 'warn'))
38 if (o.failed.length > 0) parts.push(part(' · ', 'dim'), part(`${o.failed.length} failed`, 'error'))
39 return partsLine(parts)
40}
41
42/**
43 * The picks after a new scan: a path listed before keeps the person's choice,
44 * a new one starts picked when it is certain.
45 */
46export function carriedSelection(before: Scan | undefined, selected: ReadonlySet<string>, after: Scan | undefined): Set<string> {
47 const known = new Set(before?.found.map(f => f.path) ?? [])
48 const keep = (f: Found) => (known.has(f.path) ? selected.has(f.path) : f.cls === 'certain')
49 return new Set(after?.found.filter(keep).map(f => f.path) ?? [])
50}
51
52/** The text /disk-janitor answers: every listed directory, and how to delete where the surface has no pane. */
53export function listText(scan: Scan | undefined): string {
54 if (scan === undefined) return 'not measured yet, or not in a git repository'
55 const rows = scan.found.map(f => rowText(scan, f))
56 const head = `${scan.root} · ${scan.found.length} artifact dir(s) · ${sizeText(totalKb(scan))}`
57 const data = scan.data.length === 0 ? [] : [`kept, data: ${scan.data.join(', ')}`]
58 return [head, ...rows, ...data].join('\n')
59}
60hooks/scan.ts 12 lines1/** What a scan of one repository finds. The functions that run it take `$`, so they live in register.tsx. */
2import type { Class } from './classify.ts'
3
4/** An artifact directory, relative to the repository root. */
5export type Found = { path: string; cls: Exclude<Class, 'data'> }
6
7/** What one scan found: the artifacts with their sizes in KB, and the data directories it left alone. */
8export type Scan = { root: string; found: Found[]; sizes: Map<string, number>; data: string[] }
9
10/** No more directories than this are listed, so a huge monorepo stays quick. */
11export const MAX_FOUND = 500
12