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 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.
-
spacemarks 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. -
fwalks 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:tmoves the tier axis, andu,d,b,candntoggle the five kinds. So a combination nobody anticipated is still one a reader can ask for: "every cache a rule named" isdthenbfrom 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~/reposholds 44,703 of them against 71 gitignored directories, so an unasked-for sweep would bury the 40 GBnode_modulesunder.DS_Storerows.defaultnarrows — 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 toall, 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. -
xdeletes 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, andspaceon 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-underreached 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.
mturns 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, andmsays 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 untilisays so. That toggle is the gate, exactly asdefaultalready 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.
spaceon 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
openatfrom an already-open parent withO_NOFOLLOW, then removed byunlinkatagainst 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 anode_modulesyou used this morning is not reclaimable in any useful sense. Off by default, and worth turning on.--dry-runprints the plan and deletes nothing. The final confirmation defaults to no, and so does end of input — a script consents with--yesor 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:
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.
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