pristine-cli 0.1.0

A language-agnostic reclaimable-space finder and cleaner.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# pristine

A language-agnostic reclaimable-space finder and cleaner.

`du` tells you where the bytes are. It cannot tell you which of them you are allowed to delete.
pristine answers the second question: it finds build artifacts and vendored dependency directories
across every ecosystem on the machine, shows you what each costs, and names what each one is before
you decide.

`node_modules` is one ecosystem's answer to a question every ecosystem answers. The same disk is also
carrying `target/`, `.venv/`, `bin/`, `obj/`, `_build/`, `.gradle/` and `vendor/`, all equally
reclaimable and all invisible to a tool that only knows about npm.

> **Status: early.** The parallel walker, both detection tiers, the deleter, both modes and the
> rollup tree all work and are tested. Not published yet. The design lives outside this repo.

## Using it

```sh
pristine ~/repos                       # the rollup tree: drill in, mark subtrees, delete
pristine ~/repos > out.txt             # not a terminal, so the listing instead
pristine ~/repos --no-tui              # ...or ask for the listing at a terminal
pristine ~/repos --breakdown           # the listing, with a number on every claim
pristine ~/repos --breakdown-under ~/repos/nx    # ...or one subtree, at that subtree's price
pristine ~/repos --dry-run             # the plan it would execute, and what it would refuse
pristine ~/repos --delete --yes --older-than 30d

pristine repo                          # one checkout: ask what to clean, then clean it
pristine repo --untracked --ignored --dry-run
pristine repo --reset=hard --untracked --ignored --yes
```

`pristine sweep ~/repos` is the same thing as `pristine ~/repos`, spelled out for when it stands
next to `pristine repo`.

## The tree

At a terminal, `pristine <path>` opens the filesystem tree with **reclaimable bytes rolled up into
every ancestor** — pruned to the paths that lead somewhere reclaimable, collapsed by default,
drilled into on demand.

```
 /Users/agentender/repos  151.9 GiB reclaimable in 10524 directories · 75 out of view (default)
directory                                            size ↓        age  what it is
[▁] ▾ /Users/agentender/repos                     151.9 GiB         0h
[▇]   ▾ definitely-typed                           22.2 GiB        3mo
[x]     ▸ types                                    21.8 GiB        3mo
[x]       node_modules                            320.3 MiB        3mo  Node Dependencies
[x]     ▸ scripts                                  29.2 MiB        3mo
[ ]   ▸ craigory-dev                               14.7 GiB         2h
[ ]   ▸ brain                                      14.3 GiB         0h
[ ]   ▸ nx                                       > 14.0 GiB         4h
[ ]   ▸ oss-secrets-requestor                      10.8 GiB        10d
 marked 21.9 GiB in 9092 directories · space mark · x delete · f view (default) · / filter · s sort (size) · ? help
```

A row's number is not "how big is this directory" — that is `du`'s question — but **"how much do I
get back by emptying this subtree"**. A directory with nothing reclaimable under it never appears.

That is what the marker on `types` is worth: **one keystroke, 9,092 directories, 21.8 GiB**, on a
row that is still closed. Every other tool in this space is a flat list or a prompt, so the same
selection is 9,092 decisions — which is why kondo's own README describes it as "essentially
`rm -rf` with a prompt", and why npkill grew a range-select that is a tree approximated without
one.

- `space` marks a row's whole subtree, and you see it happen: the mark runs up the ancestors, so
  "this took everything underneath" is shown rather than inferred on a row that is still closed.
  A partly-marked ancestor carries a block filled in proportion to how much of it is spoken for —
  `[▁]` for a sliver, `[▇]` for nearly all of it — and unmarking one row out of a marked subtree
  spares just that row.
- Rows appear as the walk finds them, and prices land behind them. A new row is briefly lit,
  totals count up rather than jumping, and a row a pricing thread is *inside right now* shimmers
  where its size will go — so the dashes being worked on are distinguishable from the ones still
  queued. An ancestor whose children are still being priced reads `> 14.0 GiB`, which is a floor
  and true the whole time it is up. Marking a directory marks whatever arrives under it
  afterwards, because a mark is a statement about a subtree rather than about the rows that
  happened to exist when you pressed the key.
- `f` walks four views: **default** (what a rule could put a name to), **dependencies**,
  **all-ignored** (dependencies *plus* the gitignore fallback) and **all**. Underneath they are two
  independent axes — which *tier*, and which *kind* — and each step of the cycle moves exactly one
  of them, carrying the other forward. Each axis also has a key of its own: `t` moves the tier
  axis, and `u`, `d`, `b`, `c` and `n` toggle the five kinds. So a combination nobody anticipated
  is still one a reader can ask for: "every cache a rule named" is `d` then `b` from the default
  view, and no preset has to exist for it.

  Gitignored **files** are a third axis, on `i`, and no preset touches it — a real `~/repos` holds
  44,703 of them against 71 gitignored directories, so an unasked-for sweep would bury the 40 GB
  `node_modules` under `.DS_Store` rows.

  `default` narrows — it leaves out the gitignored tier, and files start out of view — so the
  header says how many claims that is, beside the number it qualifies. A filter that is on without
  saying what it dropped is the same failure as one that silently keeps something back.
- **Changing the view never changes what is selected**, by any of those keys. Hiding a row is not
  unselecting it, and a mark keeps meaning the view it was made through: mark a directory under
  `dependencies`, widen to `all`, and the build artefacts beside them are still unmarked.
  The box on a partly-marked ancestor is filled against *the view you are looking through*, so what
  it says is a statement about this screen rather than a global fact contradicting the rows
  underneath it.
- `x` deletes what is marked, after a confirmation that **lists the whole batch** — grouped by what
  each thing is, marked plainly where the current view is hiding one, with the safety model's
  refusals said here rather than in the report afterwards, and `space` on any line to take it out.
  Deleting acts on everything selected and never only the visible part, which is why the box shows
  you the rest. It opens on **cancel**. A row does not blink out: it **empties** as the bytes
  actually leave the disk — the deleter reports its progress and the row's number is what is left
  of it — then dims once it reaches zero, then collapses away. The freed total in the footer climbs
  on the same reports the reclaimable total falls on, so the two are one number read from each end.
  The cursor follows the *directory* it was on rather than the row number, and a target that could
  not be finished keeps its row, worth what survived and labelled with why it was left alone —
  calmly rather than as an error, because the safety model refusing a nested checkout is the tool
  working.
- Sorting is per level, because children have to stay under their parent. `/` filters on a regex
  over the whole path, and a filtered row's number counts only what the filter shows.
- `?` lists every key and every mouse gesture, generated from the tables that dispatch them
  rather than maintained beside them.
- **The mouse works**, and it is a pointer rather than a link: click a row's box to mark it, its
  `` to open it, its name to put the cursor there; click a column heading to sort by it and again
  to reverse it; wheel to scroll. A click happens when the button comes *up* on the thing it went
  down on, so dragging out a terminal selection never re-sorts the tree on its way. Double-click a
  row to price that subtree — the one expensive thing worth asking for on one directory, which is
  `--breakdown-under` reached by pointing at it.
- **A treemap sits beside the tree** on a terminal that reads the kitty graphics protocol
  (Ghostty, kitty, WezTerm), showing the subtree the cursor is in with each rectangle's area
  proportional to what it is worth. `m` turns it off. It answers "where are the bytes" in one
  glance, and it is honest about what nobody has measured yet: unpriced claims are never drawn as
  small rectangles among priced ones — they get a hatched region of their own, counted in
  directories rather than in bytes, which shrinks as the prices land. It also needs the terminal
  to say how big one cell is in pixels, which **tmux and screen do not pass on** — so inside a
  multiplexer there is no map however capable the terminal outside it is, and `m` says so rather
  than doing nothing. Anywhere else there is simply no pane: nothing probes the terminal, nothing
  is sent to one that might not decode it, no image is ever sized from a guess, and the tree
  alone is the whole interface.
- A long sweep stays readable from outside the terminal: the window title carries the live figure
  (`pristine — pricing 41%`, `pristine — freed 41.2 GiB`), the dock or taskbar carries a real
  progress bar, and a run that took long enough raises one notification if you have looked away.
  Frames go out as one synchronized update, so a redraw does not tear over ssh. All of it is put
  back on the way out, including the error path. Each part is sent only to a terminal known to
  read it — the title only where the old one can be handed back, since a title nobody can clear is
  worse than none — so an unrecognised terminal gets the tear-free redraw and nothing else, and a
  pipe gets not one escape byte.

The keys are deliberately close to [pua](https://github.com/AgentEnder/pua)'s, which is the same
shape of tool pointed at processes.

## The listing

Send the output anywhere but a terminal and you get the flat listing instead, which is what a
script wants and what the rest of this section describes.

```console
$ pristine ~/repos/pua > pua.txt
  59.1 MiB  .nx                       Gitignored, kind unknown
         —  dist                      Nx Build Artifacts
         —  docs-site/node_modules    Node Dependencies
         —  node_modules              Node Dependencies
         —  target                    Rust Build Artifacts

5 directories reclaimable, 59.1 MiB priced, 4 not priced
not priced: nothing looked inside. --breakdown prices every claim, --breakdown-under <PATH> just
one subtree; both walk what they price.
fallback tier: 1 directory found in 1 work tree above a 10.0 MiB floor (directories only;
--ignored-files claims gitignored files too)
```

A dash is not a zero: nothing looked inside, because a matched directory is never enumerated by
the scan that found it. Pruning at `node_modules` and then walking it to size it would give back
everything the pruning saved. No platform offers a recursive directory size — a directory inode's
block count describes its own entry table, not the tree below it, which is why `du` walks — so a
price is a full enumeration and it costs what it costs. The fallback tier's rows do carry a size,
because that tier cannot claim a directory without walking it anyway.

`--breakdown` pays that price for every claim. Over one real `~/repos`: **4.6 s and 14.0 GiB
priced without it, 55.8 s and 165.1 GiB with**. The default is not caution, it is that most runs
do not need all 165 GiB accounted for to a byte — but the tenfold gap between the two numbers is
why the flag has to exist.

`--breakdown-under <PATH>` prices one subtree instead, and the rest of the scan still appears,
still unpriced. The same tree scoped to a single repository takes 3.5 s — a default scan, plus
that repository. It is how you ask "how much is in *here*" without paying for everywhere else.

Pricing does not hold the scan up. Claims are published as they are found and a pool of threads
prices them behind, so on that same tree the last row is known at 7.5 s while the numbers keep
landing for another minute. Measured on the walker thread instead, the last row does not exist
until 60.1 s — nothing to show, for the whole run. This listing still waits, because it sorts by
size and paints once. **The tree is what the split was for**, which is also why the tree prices
everything by default and the listing does not: unpriced, a rollup has nothing to roll up, and a
tree of dashes answers no question at all.

A scan that could not read everything it was pointed at says `scan incomplete` and exits
non-zero, so a listing that is a lower bound never looks — to a script — like the whole truth.

## How it finds things

Two tiers.

**A curated marker ruleset.** Detection is marker-anchored, never name-anchored, because directory
names collide across ecosystems in ways that matter. `target/` is Rust's build output and also
Maven's. `vendor/` belongs to Go, Composer and Bundler. `build/` is Gradle's output, Dart's output,
and in a CMake project it is ordinary source that must never be touched. So a rule is a pair: a
directory name plus a marker file that has to be present in its parent. `node_modules` next to a
`package.json` is reclaimable. A `build` directory next to nothing in particular is not.

**A gitignore fallback.** Inside a git work tree, a directory that is ignored, contains no tracked
file at any depth, holds no git checkout, and exceeds a size floor (10 MiB, `--min-size`) is
reclaimable by inference even when no rule names it. This is what makes the tool genuinely
language-agnostic rather than agnostic across whichever ecosystems happened to get a rule written.
On one real machine it is what turns up `dist/`, `tmp/`, `artifacts/`, `playwright-report/`,
`.angular/cache` and a downloaded `Godot.app` — none of which any ruleset names.

The last two conditions are the safety properties, and both are guarantees `git clean` enforces:
it will not remove a directory holding a tracked file, and it skips rather than collapses one
holding a checkout. Outside a git work tree the tier is **inert**, and says so rather than
reporting an empty result. With no repository the only signal left would be the directory's name,
and a name is not evidence — guessing from one is how a cleaner deletes somebody's source.

**Gitignored files, with `--ignored-files`.** The same tier, on leaves. A file that is ignored and
untracked is a candidate whatever it is — the `.env` sitting in a repo you are about to archive is
a real thing to want gone, and no competitor finds it either. It is a different job from the rest
of the sweep and is spelled as one: clearing fifty env files reclaims kilobytes, so the value is
hygiene rather than space, the size floor does not apply, and a file is always priced because one
`lstat` is the exact answer. The rollup tree finds them either way and keeps them behind `i`.

A tier-one hit is named — the ecosystem it belongs to and what kind of directory it is, from a
closed vocabulary **ordered by what it costs to lose**: **Unrecoverable**, **Dependencies**,
**Build Artifacts**, **Cache**, **Noise**. That is the fact that prices the decision without
pretending to know your machine: a cache is free to lose, an output is a compile, dependencies are
a network fetch. A tier-two directory says `Gitignored, kind unknown`, which is all it honestly
knows; a gitignored file can say more, because its *name* is sometimes evidence — `.env` and
`id_rsa` are the only copy of something, `.DS_Store` and `*.log` are the copy of nothing. The
asymmetry against a named row is the point: it tells you which deletions are cheap.

### What separates a secret from a cache is which lens shows it

An `Unrecoverable` file inverts the premise of everything else here: nothing brings it back. The
safety that follows from it lives in **what a view shows**, and nowhere else — there is no special
deletion path, no extra confirmation and no second flag:

- **Nothing shows one unless you ask.** A sweep does not claim a gitignored file without
  `--ignored-files`, and no lens displays one until `i` says so. That toggle is the gate, exactly
  as `default` already hides the gitignored tier. A narrowed view never keeps something back
  quietly: the header counts what is out of sight, from the first frame.
- **A mark then behaves normally.** `space` on a directory takes its whole subtree, precious rows
  included — a mark is a statement about a subtree and the partial-mark glyph on an ancestor has
  to stay a true reading of it. Marks carry the lens they were made through, so widening the view
  later never reaches back and adds one.
- **The confirmation still names them.** The batch is listed with the unrecoverable entries first,
  counted in a line of their own, and each one can be taken out with `space` — the same key that
  drops any other line.

The label is doing naming, not gating: `Unrecoverable` on a row tells you what you are looking at
and changes what no key does.

There is deliberately no field for the command that rebuilds a directory. `npm install` is a guess
about a package manager nothing checked — the repo may use pnpm or yarn — and half the ruleset could
only fill that field in with a sentence ("rebuilt on the next import"), which is not a command
anybody can run. A label is a fact; a rebuild command was two things at once and reliably neither.

## Two modes

**sweep** — a bare `pristine [PATH]` — walks a directory tree for "my disk is full". Every project
underneath, sorted by size. Everything above describes it.

**`pristine repo`** cleans one git checkout, and replaces `git clean -fdx`. It enumerates nothing
itself: `git clean -n -d` lists the untracked files and `git clean -n -d -X` lists the ignored ones,
so nested ignore files, negations, `info/exclude`, your global excludes and the refusal to touch a
nested repository are inherited exactly rather than reimplemented. The two lists are disjoint, which
is why each is a separate choice.

```console
$ pristine repo --untracked --ignored --dry-run
         —  .nx/workspace-data
         —  dist
         —  target

plan: 3 paths, 0 B priced, 3 not priced
excluded: 1 vendored path (--node-modules includes them)
skipped: 1 nested repository git will not clean
  sandboxes/work

dry run: nothing was reset and nothing was removed
```

| Flag | Meaning |
|---|---|
| `--untracked` | remove untracked files |
| `--ignored` | remove ignored files |
| `--reset[=worktree\|hard]` | discard tracked changes; bare `--reset` is `hard` |
| `--node-modules[=BOOL]` | include vendored dependency directories (off) |
| `--env[=BOOL]` | include `*.env*` files (off) |
| `--dry-run` | print the plan, change nothing |
| `--yes`, `-y` | answer the final confirmation |

Reset first, then the removal. `--reset=worktree` is `git restore -- .` and keeps the index;
`--reset=hard` is `git reset --hard HEAD` and does not.

A reset moves the index, and the index is what makes a path untracked — so **the enumeration does
not outlive it**. After the reset the work tree is asked again, and the second answer is narrowed
to what you were shown and confirmed. Both halves matter. Re-asking is what stops a file the reset
made *tracked* from being deleted: `git rm --cached committed.txt` leaves it on disk and out of the
index, so `git clean` offers it, and `--reset=hard` then puts it back. Narrowing is what stops a
directory the reset made git *collapse* from being removed without ever appearing on a plan — which
would take the vendor and env files you were told had been held back. Anything withdrawn that way
is named, and a second run shows it honestly.

With no action flag it asks — reset, untracked, ignored, then vendor and env — and every question
defaults to the answer that changes nothing, so a run with nothing on its standard input does
nothing. With *any* action flag it does not ask, so nothing in CI hangs on a prompt.

`--yes` gates the final confirmation and nothing else. It selects nothing, so `pristine repo --yes`
on its own does nothing, and `pristine repo --ignored` in a script still refuses to delete without
it. It does count as the command line resolving the plan, so it makes the run non-interactive too —
otherwise `--yes` would let you be asked what to clean and then never asked to confirm it, which
turns "I consent to what I asked for" into "I consent to whatever I am about to be asked".

Vendor and env are held back even from a list you did ask for, in both lists rather than only in
the ignored one. `node_modules` costs minutes and a network to get back, and nothing at all
regenerates a `.env` — least of all one that is untracked rather than ignored, which is the copy
git is not even hiding.

**That applies to what an entry hides as well as to what it is.** `git clean` offers a whole
directory whenever everything inside it is removable, so a row is not a description of its own
contents: `docker/` arrives as one line and may hold `docker/.env`. A directory that hides
something you did not ask to remove is held back whole, and named:

```console
$ pristine repo --untracked --ignored --yes
         —  scratch.txt

plan: 1 path, 0 B priced, 1 not priced
held back: 3 paths, because git offered them whole and they hold something you did not ask to remove
  docker  —  holds docker/.env, which is an env file (--env includes it)
  pkg  —  holds pkg/node_modules, which is vendored (--node-modules includes it)
  build  —  holds build/.env, which is an env file (--env includes it)
```

Held back whole rather than cleaned around, because cleaning around it would mean deciding for
ourselves what inside it is removable — the reimplementation of `git clean` this mode exists to
avoid. A directory that cannot be read is held back on the same rule: "I could not look" is not
"there was nothing there".

Both modes share one deleter and the whole of the safety model below.

## Safety

Deletion is by `unlink`, not by moving to the platform trash. Trash is a move, and across filesystems
a copy, which is exactly the wrong thing to do to a 40 GB tree. The checks below carry the weight
instead, and every one of them is a test rather than a promise.

- Every target's path is resolved — `..` and symlinked ancestors and all — and proved to be under the
  scan root before any unlink. The scan root itself is never a target.
- Nothing is removed by name. The scan root is opened once and every entry beneath it is reached by
  `openat` from an already-open parent with `O_NOFOLLOW`, then removed by `unlinkat` against that
  same descriptor. Re-pointing a directory mid-run — even while the removal is inside it — can make
  the removal fail and say so, but it cannot redirect one out of the root.
- The scan root is the one name that still has to be resolved, so it is checked twice: its final
  component is opened without following a symlink, and the descriptor is then matched against the
  device and inode recorded when the plan was built. A root renamed away and replaced — even by an
  ordinary directory on the same disk, laid out to match — is reported rather than swept.
- Symlinks are never followed out of the root. A symlinked target is unlinked as a link, and so is
  every link found inside one.
- A filesystem boundary is not crossed unless `--one-file-system=false`.
- A directory holding a git checkout is refused and reported rather than swept up, at any depth. It
  stops that subtree, and everything above the refusal is left standing.
- `--older-than <duration>` keeps anything touched recently, because a `node_modules` you used this
  morning is not reclaimable in any useful sense. Off by default, and worth turning on.
- `--dry-run` prints the plan and deletes nothing. The final confirmation defaults to no, and so does
  end of input — a script consents with `--yes` or not at all.
- Failures never abort the batch; they are collected, reported, and set a non-zero exit. So does a
  scan that could not read everything it was pointed at.

## Install

Not yet published. When it is:

```sh
brew install pristine
npx @agentender/pristine
cargo install pristine-cli    # the crate is `pristine-cli`; the binary is `pristine`
```

The npm route is a wrapper over the same binaries the crate builds, with no postinstall download,
so it works offline and where install scripts are disabled. It carries prebuilt binaries for
`darwin-arm64`, `darwin-x64`, `linux-x64-gnu` and `linux-arm64-gnu`; anywhere else, `cargo install`
builds from source.

## Development

[mise](https://mise.jdx.dev) owns the toolchain; `mise install` gets you rust, node and pnpm at the
versions CI uses.

```sh
cargo clippy --all-targets --all-features -- -D warnings
cargo test
```

The same commands are wrapped as Nx targets (`pnpm nx run-many -t fmt-check lint test`), which is how
the npm wrapper shares a graph — and a version — with the crate.

The crate is `packages/pristine` and its published name is `pristine-cli`; the binary is `pristine`.
The npm wrapper and its per-platform binary packages are under `npm/`; see
[docs/releasing.md](docs/releasing.md) for how the two stay in lockstep.

## License

MIT