macOS storage settings told me Documents was using over 150 GB. So I checked the folder:
du -sh ~/Documents
# 6.4M
6.4 megabytes. The category was off by four orders of magnitude, and it pointed at nothing I could act on.
That gap is not a bug, and it is the reason this is worth writing down. I eventually reclaimed about 78 GB — none of it from Documents. This post is where the space actually was, the commands that found it, and the three changes that stopped it coming back.
Why the "Documents" number is meaningless
macOS storage categories are a classification, not a directory listing. Photos, Mail, Music and Applications all have owners that report their own usage. Everything else — every file the operating system cannot attribute to a known app or media type — falls into Documents.
A node_modules tree is not a document. Neither is a .next build cache, a
Go module cache, a Python virtualenv, or a Docker disk image. All of them land
in the same bucket, and the bucket is named after the smallest thing in it.
So the rule is simple: the storage pane tells you that you have a problem, and nothing else. Stop reading it and start measuring.
Measure, don't guess
Four commands did all the useful work.
Count what you have before you look at sizes. A count tells you whether you're chasing one big thing or thousands of small ones:
find ~ -type d -name node_modules -prune 2>/dev/null | wc -l
# 3523
3,523 node_modules directories on one laptop. -prune matters — without it
find walks into every one of them and you'll wait a long time to count nested
copies you don't care about.
Then size the biggest offenders, sorted. du plus sort -h is the whole
technique:
du -sh ~/dev/*/{node_modules,.next} 2>/dev/null | sort -h | tail -20
The largest single .next directory was 10 GB. One build cache, for one
checkout, of one project.
Then ask git what it thinks it owns:
git worktree list
This is where the shape of the problem showed up. Across the machine there were
77 git worktrees carrying 43 GB of build artifacts between them —
node_modules and .next. 52 of those 77 were still registered with git,
meaning git believed they were live workspaces. The rest were orphans: the
directory still on disk, the registration long since stale.
Then Docker, which keeps its own books:
docker system df
31 GB — 23.9 GB of build cache and 7.3 GB of images nothing
referenced any more. None of that appears in any per-directory du you would
think to run, because it lives inside a single opaque VM disk image.
The sparse-file trap
While measuring Docker I nearly chased a number that did not exist:
# apparent size — what the file claims
ls -lh ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw
# 61G
# real size — blocks actually allocated
du -h ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw
# 10G
61 GB apparent, 10 GB real. The file is sparse: it declares a large logical
size and only allocates blocks it has written. ls reports the declaration,
du reports the allocation.
If you're hunting disk space, du is the number that matters. Any tool that
reads apparent size will hand you a phantom the size of the gap and send you
deleting things that were never taking up room.
Where the space actually went
The whole 78 GB, in two lines:
- 43 GB of regenerable build artifacts across 77 worktrees (52 still
registered with git), the largest single
.nextbeing 10 GB. - 31 GB of Docker build cache and dead images — 23.9 GB cache, 7.3 GB unused images.
Not one byte of it was a document, a photo, or anything I had authored. All of it was derived — output that some tool produced from source I still had, and then left behind.
The root cause: no layer owns eviction
It would be satisfying to blame one tool. You can't. Every layer here behaves exactly as designed:
- Git worktrees have no lifecycle.
git worktree addcreates a full working copy. Nothing in git ever decides that copy has outlived its purpose. There is no TTL, no "this branch merged, so this workspace is done." - npm copies dependencies per checkout instead of hardlinking them. Ten worktrees of one project means ten physically distinct copies of the same dependency tree, byte for byte identical.
- Build output has no retention policy. A framework's cache directory grows monotonically. It is a cache with an eviction policy of "never," which makes it a log.
- Docker's build-cache cap was set high. BuildKit garbage-collects against a ceiling. Set the ceiling generously and it will use the space you offered it — correctly, and forever.
Each decision is individually reasonable. Collectively they are unbounded, because eviction is nobody's job. Every layer is happy to create; no layer is responsible for destroying.
What turned that latent property into 78 GB was a change in how I work: agent-driven development. One worktree per task, several a day, each with its own install and its own build output, and none of them cleaned up after the branch merged. The old habit — two or three long-lived checkouts — hid the problem for years by keeping the multiplier small. Raise the multiplier and the missing eviction policy becomes a hardware problem.
Docker was a third of the total here, and most of it was build cache from images that were bigger than they needed to be. If you want to attack the upstream cause rather than the symptom, paste your Dockerfile into the Dockerfile & Cloud Run Auditor — image size and layer discipline are the same fight.
The three fixes that actually hold
Deleting 78 GB took an afternoon. Not needing to do it again took three changes.
1. A hardlinking package-manager store. Move off per-project copies to a content-addressable store that hardlinks into each checkout. One physical copy of a given package version, referenced from every worktree that needs it:
pnpm store path # one store on disk
pnpm install # hardlinks into this checkout, doesn't copy
The 43 GB figure is mostly the same bytes written 77 times. Hardlinks collapse that to roughly the size of the largest single install, and installs get faster as a side effect, because copying was the slow part.
2. A lower Docker build-cache cap. Don't prune on a schedule, lower the
ceiling — then BuildKit enforces it for you, permanently. In
~/.docker/daemon.json:
{
"builder": {
"gc": {
"enabled": true,
"defaultKeepStorage": "5GB"
}
}
}
Mine had been set to 20 GB, which is how 23.9 GB of cache accumulated without
anything complaining — BuildKit was doing its job against the ceiling I gave it.
Pick a number you can afford to lose and let garbage collection be automatic. A
cap you set once beats a docker system prune you have to remember.
3. A worktree lifecycle. This is the one no tool gave me, and the one that mattered most. The policy is one sentence: a worktree exists for a task, and stops existing when the task's branch is merged. Anything else is a workspace you are keeping by accident.
Enforcing that turned out to be harder than it sounds, because "is this
workspace still needed?" is a question about git state, not about bytes. A disk
analyser can tell you a directory is 10 GB; it cannot tell you whether deleting
it loses work. npkill finds every node_modules but knows nothing about
branches. docker system prune only knows about Docker. And git worktree prune
— which sounds exactly like the tool for this job — only removes registrations
whose directory is already gone. It never reclaims a byte.
So I wrote the thing that answers the question: swarfkit,
a zero-dependency CLI (swarf) that reads git state per worktree and reports
which ones are safe to delete — merged, clean, pushed, old enough — before it
deletes anything. It's MIT licensed and runs without installing:
npx swarfkit --root ~/dev
Plain swarf only prints a report; deletion needs a second word. The interesting
part was deciding what "merged" means when your platform squash-merges
everything — I got that wrong twice, and wrote it up in
deleting git worktrees safely.
The takeaway
Storage categories describe files; disk pressure comes from derived data. Documents, Photos and Music are the things you'd miss. The 78 GB was build output, dependency copies and cache — all of it reproducible, none of it owned by anything that would ever delete it.
If your Mac claims a huge Documents figure, don't open Documents. Count your
node_modules, list your worktrees, run docker system df, and use du rather
than ls on anything that might be sparse. Then fix the multiplier, not the
symptom.