DEV Community

Cover image for Why your disk cleanup tool shouldn't use rm -rf
Anindya Chatterjee
Anindya Chatterjee

Posted on AI-assisted

Why your disk cleanup tool shouldn't use rm -rf

A developer machine accumulates build output the way a workshop accumulates sawdust. A target/ here, a node_modules/ there, a build/ in every Flutter app you've ever touched. None of it is precious. All of it is invisible until df -h says otherwise. Add up enough projects and it's routinely tens of gigabytes, sitting on an SSD you can't upgrade.

The obvious fix is a one-liner everyone eventually writes for themselves:

find . -name node_modules -type d -prune -exec rm -rf {} +
Enter fullscreen mode Exit fullscreen mode

Swap the name for target, build, dist, .venv, whatever you're hunting that week, and it mostly works. I ran variations on this for years. It's fast, it needs nothing installed, and it does exactly what it says: finds a directory with a given name and deletes it. The trouble is in that last clause. It deletes a directory because of its name, not because of what's actually in it — and a name is not a fact about contents. That gap is narrow, but it's exactly wide enough to lose real work.

Failure mode one: the name is a coincidence

target/ is Cargo's build directory, until it isn't. Plenty of non-Rust projects use target for something else entirely — a Maven module, a symlink farm, a folder someone named after where the deployment target lives. build/ is even more overloaded: Gradle uses it, Flutter uses it, and no small number of people use it as an actual source directory for hand-built assets. dist/ gets checked in on purpose more often than you'd think, usually because a deploy pipeline expects it to already exist and nobody wanted to teach the pipeline otherwise.

A glob matching a name can't distinguish "Cargo's build directory" from "a directory that happens to be called target." Only the build tool that created it knows the difference, and the glob never asks.

Failure mode two: git is tracking it

This is the one that actually cost me something. Some repositories commit their build output deliberately — not by accident, not because someone forgot a .gitignore entry, but because the build isn't reproducible on every machine that needs to run it, or because a downstream consumer expects the artifact to already be there without a build step. I hit exactly this on a project where build/ was checked in for a legacy consumer that had no toolchain of its own. rm -rf build/ came back instantly with a full disk's worth of relief, and a git status a few minutes later that was considerably less relieving. A rebuild did not bring it back, because there was nothing to rebuild it from on that machine — the checked-in copy was the only copy.

rm -rf has no way to know this. It has no concept of git at all. But the information already exists, two commands away: git ls-files will tell you, for any path, whether it's tracked. Nothing about deleting-by-name ever consults it.

Failure mode three: the symlink

~/projects/foo/node_modules pointing at /Volumes/External/shared/node_modules is a completely reasonable thing to set up if you're short on local disk and sharing a dependency cache across projects. rm -rf will follow that symlink without pausing to ask, and delete through it. Now you've cleaned a directory you never actually scanned, on a volume you may not have even meant to touch, and the "project" you thought you were tidying up is now missing its shared dependency cache for every other project pointing at the same place.

What the toolchains already know

Here's the thing that made me stop writing globs: every one of these build tools already knows exactly what it put on disk, because it's the one that put it there.

cargo clean doesn't guess. It reads Cargo's own metadata about what's in target/ and removes what Cargo is responsible for — nothing more, nothing that a rebuild can't immediately reproduce because Cargo itself is the thing doing the reproducing. flutter clean understands .dart_tool/ and the platform build directories as Flutter artifacts, not as a folder name to pattern-match. ./gradlew clean respects whatever the build script actually configured as output, which is not always the default build/ you'd assume. This information is not hidden or hard to get at. It's sitting right there, free, behind a command you already have installed because you already have the toolchain installed.

Running cargo clean instead of deleting target/ by hand doesn't require any cleverness on your part. It requires asking the tool that made the mess to clean up its own mess, instead of doing it yourself with a blunter instrument.

The catch, stated honestly

None of this is free of trade-offs, and it's worth saying so plainly rather than pretending there's no downside.

It's slower. cargo clean starts Cargo, which takes real wall-clock time, especially across a few hundred crates — a glob-and-delete is instantaneous by comparison. It also only works if the relevant SDK is actually installed; a glob doesn't care whether you have Rust on this machine, and a clean command absolutely does. And it typically reclaims less space than an outright delete, on purpose — clean commands are usually careful to leave behind caches that a subsequent rebuild can reuse, because the tool's authors optimized for "get back to a working rebuild quickly," not "reclaim the maximum possible bytes right now."

That last point deserves to be stated as a feature and not hidden as a limitation: if you actually want a rebuild to be fast afterward, keeping those caches is exactly what you want, and it's a better default than deciding for you that speed doesn't matter.

When rm -rf is genuinely the right answer

Deleting by name isn't wrong in every case — it's wrong as a default, unexamined behavior. It's a reasonable fallback under a specific, narrow set of conditions: the relevant SDK genuinely isn't installed on this machine, so there's no clean command to run in the first place; the directory name is on an explicit, per-toolchain allow-list of names that are safe to delete outright for that stack; and a person has actually confirmed, for that specific run, that deleting it is fine. All three, together, every time — not "it's probably fine" applied silently by default.

That's the shape of a fallback that's safe to have: narrow, explicit, and opt-in, rather than the default path every deletion takes.

Closing

This is the whole argument behind Kruftle, a desktop tool I built that walks a directory tree, works out what each project underneath it is actually built with — including nested projects, like the Rust crate sitting inside a Flutter app that flutter clean will never see — and runs each one's own clean command instead of guessing from a directory name. It falls back to a raw delete only under the three conditions above, and it reports what was genuinely freed afterward rather than what it predicted beforehand. Free, GPL-3.0, no telemetry, for macOS, Windows and Linux.

If you've been maintaining your own version of that find | rm -rf one-liner for years the way I was, it's worth asking your build tools what they already know before you reach for it again.

Top comments (0)