Disk reduction — reclaim space from Rust build residue
The dev Mac has a 1.8 TiB data volume that fills with Rust build output. Measured 2026-08-14: the volume hit 340 MiB free — every agent tool call failed ENOSPC before it could run — and a sweep took it to 189 GiB without deleting one line of source.
Most of this now prunes itself. scripts/target-gc.sh runs hourly from every rust-build.sh build: orphaned buckets go after a day, anything idle 14 days goes, and a low-disk pressure pass (under 100 GiB free) collects idle state aggressively while sparing the newest. make target-gc shows what it would take right now. The manual sweeps below remain the tool for IMMEDIATE reclaim — the collector never touches a dir written within 2h, never the newest rlib seed, never nub-dev's bucket — and for the families it does not scan (merged worktree checkouts).
Judge every reclaim by df, never du. scripts/rust-build.sh CoW-clones buckets with cp -c, so APFS bills the same physical blocks to every referencing path. Summed du sizes overstate what you get back — measured, a set of buckets du called 135 G returned 73 GiB of df. A du -sh over ~/.cache/nub also takes minutes; df is instant.
df -h /System/Volumes/Data
Where the space actually is
Ranked by what a sweep returns, with the measured 2026-08-14 figures:
| Family |
Reclaimed |
Tool |
Worktree-private target/ dirs |
101 GiB |
cpu-reduction's clean-worktree-targets.py |
Orphaned shared-target-<key> buckets |
87 GiB |
clean-shared-buckets.py (this skill) |
| Merged worktree checkouts |
~1 GiB |
git worktree remove |
Build output is the whole story; checkouts are not. A worktree checkout is CoW-cloned source — removing four of them returned about a gigabyte. Never delete a checkout to recover space.
1. Worktree-private targets — the biggest single win
A worktree that diverges a depended-on crate builds into a private $root/target, and these are what dominate. The cleaner lives in the sibling skill; do not duplicate it here.
python3 .claude/skills/cpu-reduction/scripts/clean-worktree-targets.py # audit
python3 .claude/skills/cpu-reduction/scripts/clean-worktree-targets.py --apply
It protects the entire target set of any worktree with uncommitted work, refuses while a Rust build runs, and never touches a shared bucket.
2. Orphaned shared buckets — the win nothing else covers
python3 .claude/skills/disk-reduction/scripts/clean-shared-buckets.py # audit
python3 .claude/skills/disk-reduction/scripts/clean-shared-buckets.py --apply
Why buckets orphan so fast. A bucket is ~/.cache/nub/shared-target-<key>, where <key> hashes the content of the depended-on crates plus runtime/ (vendor/aube, crates excluding the leaves nub-cli/nub-native/nub-phantom, and runtime). Two facts compound:
- Only a NON-diverged worktree resolves to a bucket at all. The moment a branch touches a depended-on crate,
rust-build.sh sends it to a private $root/target instead — so most feature branches reference no bucket, and a bucket's referrers are only ever the worktrees sitting at some exact content state.
main advancing moves the key. Every merge that touches vendor/aube, a non-leaf crate, or runtime/ strands the previous bucket.
scripts/target-gc.sh (run hourly by rust-build.sh) retires an orphaned bucket after a day and anything idle after 14; before it existed, the only GC was a 14-day mtime sweep, so a fortnight of churn accumulated first. On 2026-08-14, 13 of 15 buckets proved orphaned — 12 in the first pass, holding ~135 G by du and returning 73 GiB of df, plus a 15 G bucket that orphaned the moment its last worktree was removed. Re-run the audit after removing any worktree.
What the script never deletes:
- the bucket owning the installed
nub-dev / nubx-dev symlink — deleting it breaks the dev binary with no error until you run it;
- the newest bucket, which
newest_bucket() CoW-clones as the seed for every isolated worktree — lose it and the next fresh worktree pays a cold build;
- anything, while a
cargo/rustc/rust-lld process runs or a .seeding clone is in flight — deleting a bucket mid-build truncates an rlib.
The built-in positive control. The key computation is duplicated from rust-build.sh and can drift, so the script recomputes the main tree's key and compares it against what scripts/rust-build.sh --print-target actually resolves. --apply refuses unless that control passes. Read the control: line before trusting any verdict.
3. Merged worktrees — hygiene, not reclaim
Remove only worktrees that are clean AND merged. git worktree remove refuses a dirty tree; never force it.
gh pr list --state merged --limit 400 --json headRefName --jq '.[].headRefName' | sort -u > /tmp/merged.txt
git worktree list --porcelain | awk '/^worktree /{print $2}' # then check each: dirty? merged?
git worktree remove ~/.cache/nub/worktrees/<slug> && git worktree prune
git branch --merged origin/main misses most of them — this repo squash-merges, which leaves no ancestry link, so a squash-merged branch reports unmerged. Ask GitHub for merged PR head refs and check ancestry only as a fallback for detached-HEAD worktrees.
Removing a clean worktree is lossless — the branch and its commits stay in the repo — and the CHECKOUT itself returns almost nothing. The reclaim is indirect: a removed worktree can be the last referrer pinning a bucket. Measured 2026-08-17, removing three clean+merged worktrees flipped one bucket from REFERENCED to ORPHANED and returned 16 GiB of df. So always re-run the §2 bucket audit afterwards rather than stopping here.
4. Gotchas that each cost a round trip
- Expect a CONCURRENT sweeper, and never read the audit as a stable plan. Dozens of agent sessions share this host, so another one is often mid-sweep: measured 2026-08-17, free space climbed 14 → 87 GiB during two read-only audits, five targets listed as eligible were already gone by the apply pass, and one worktree flipped dirty→clean between the two runs. Consequences: re-audit rather than trusting a list you printed minutes ago, and treat a vanished path as success.
clean-worktree-targets.py used a bare shutil.rmtree, which raises FileNotFoundError on exactly that race and aborted the whole run with ~70 GiB of candidates still pending; it now deletes via rm -rf and reports a failed= count so a real permission error is still visible.
du -sh -d1 silently prints nothing. BSD du rejects -s with -d and writes the usage error to stderr, so a 2>/dev/null scan for big directories returns an empty list that reads as "nothing here". Use du -h -d1.
- This shell is zsh;
rust-build.sh is #!/bin/sh. zsh does not word-split an unquoted $var, so lifting the $leaves pathspec idiom into an interactive shell collapses three :(exclude) pathspecs into one bogus one and yields a different hash. Every bucket then looks orphaned, including the live one. Write the pathspecs as separate literal arguments, or drive git from Python where argv is explicit.
--print-target is now a pure read on both branches — it exits before the seeding, the mkdir/touch liveness signal, and the 14-day GC, so mapping buckets by running it per worktree no longer recreates the dirs you just deleted. (It was a write until 2026-08-27; on an older checkout of rust-build.sh, use it once, in the main tree, as the control.)
- You cannot fake a
cargo process by copying a system binary. cp /bin/sleep /tmp/cargo breaks the code signature, and arm64 macOS kills the copy on exec — so a "is a build running?" negative control silently tests nothing and reports the guard as broken. Test the parser against synthetic ps output instead.
grep -E 'lld' matches installd. macOS runs installd, system_installd and appinstalld continuously, so a loose linker grep reports builds on a completely idle machine. Match the executable basename exactly.
- Read
ps -Ao comm=, not command=, when hunting build processes. An argv listing includes your own command line, so any check that mentions cargo matches itself and blocks forever.
5. Verify
df -h /System/Volumes/Data # the only number that counts
nub-dev --version # proves the surviving bucket still owns the binary
ls -dt ~/.cache/nub/shared-target-* | head -1 # the seed for the next isolated worktree
A sweep that leaves nub-dev broken has not succeeded. Re-run make install-dev if the bucket it pointed at is gone.