SLOPSHOPPER

disk-janitor

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…

newpanecommandstatusprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · disk-janitor
│ ┃ Build artifacts ✕ › fix the failing auth test and add an audit log call │ ┃ not in a git repository, or not measured yet │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /disk-janitor │ ⎿ disk-janitor: pane open: Enter picks a row, the delete button as │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Build artifacts
not in a git repository, or not measured yet
README

disk-janitor

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.

What it does

  1. At session start, every 60 s in an interactive session, and after a turn once the last measurement is 10 minutes old, it runs 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.
  2. It sorts each git-ignored directory by its name and content:
  3. certain: 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.
  4. unsure: dist, build, out, bin, obj, vendor, .cache, coverage. Listed with (unsure) and never picked in advance.
  5. data: 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.
  6. Any other name is not listed.
  7. It measures the listed directories with one du -sk call, by argv, in the background, so no prompt waits for it.
  8. The status line shows the total from 5 GB on, and more loudly from 20 GB on:

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.

The pane

/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

Command

/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.

Install

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.

After installing

  1. Restart Claude Code. The mod needs no key and no setting.
  2. Start Claude Code inside a git repository; the first measurement runs at session start.

What it can reach

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.

  1. Reads: the repository's git-ignored directory names; the marker files of a listed directory; the entries one level inside a data directory; the resolved path of a directory before its deletion
  2. Runs: git rev-parse, git ls-files and git check-ignore, read-only; du -sk; rm -rf -- on a directory you picked twice in the pane or named with /disk-janitor delete; all by argv, no shell
  3. Sends: nothing; the /disk-janitor output row is read by the model as any command output is
  4. Persists: nothing; the last measurement and your picks live in memory
  5. Hostile input: a directory name comes from git and the disk, never from the model; a deletion needs your key press or your typed command, and a path must pass every check again right before it

Limits

  • Only directories git ignores are listed. A build output that is committed, or ignored nowhere, is not.
  • A name outside the three lists is not listed, even when it is a build output.
  • An ignored directory inside another ignored directory is not listed, unless the outer one is a data directory (one level only).
  • 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).
  • At most 500 directories are listed.

Development

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

Source 4 files
hooks/register.tsx 340 lines
1import 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}
340
hooks/classify.ts 119 lines
1/** 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}
119
hooks/report.ts 60 lines
1/** 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}
60
hooks/scan.ts 12 lines
1/** 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