pristine-cli 0.1.0

A language-agnostic reclaimable-space finder and cleaner.
Documentation

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

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

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

$ 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:

$ 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:

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 owns the toolchain; mise install gets you rust, node and pnpm at the versions CI uses.

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 for how the two stay in lockstep.

License

MIT