Disk reduction — reclaim space from Rust build residue
SkillAI & modelsLets your agent free up disk space on the Mac by clearing Rust build residue and other space hogs.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Disk reduction — reclaim space from Rust build residue skill
About this capability
Reclaim disk on the maintainer's Mac when the volume is full or filling — ENOSPC, "no space left on device", a failed build or agent harness, or a routine sweep of Rust build residue. Invoke whenever free space is the problem. Covers the two families that actually hold the space (worktree-private `t
What this skill tells your AI
The instructions your AI receives, as published by nubjs/nub in .claude/skills/disk-reduction/SKILL.md and read by ahel’s review.
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.shsends it to a private$root/targetinstead — so most feature branches reference no bucket, and a bucket's referrers are only ever the worktrees sitting at some exact content state. mainadvancing moves the key. Every merge that touchesvendor/aube, a non-leaf crate, orruntime/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-devsymlink — 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-lldprocess runs or a.seedingclone 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.pyused a bareshutil.rmtree, which raisesFileNotFoundErroron exactly that race and aborted the whole run with ~70 GiB of candidates still pending; it now deletes viarm -rfand reports afailed=count so a real permission error is still visible. du -sh -d1silently prints nothing. BSDdurejects-swith-dand writes the usage error to stderr, so a2>/dev/nullscan for big directories returns an empty list that reads as "nothing here". Usedu -h -d1.- This shell is zsh;
rust-build.shis#!/bin/sh. zsh does not word-split an unquoted$var, so lifting the$leavespathspec 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-targetis now a pure read on both branches — it exits before the seeding, themkdir/touchliveness 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 ofrust-build.sh, use it once, in the main tree, as the control.)- You cannot fake a
cargoprocess by copying a system binary.cp /bin/sleep /tmp/cargobreaks 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 syntheticpsoutput instead. grep -E 'lld'matchesinstalld. macOS runsinstalld,system_installdandappinstalldcontinuously, so a loose linker grep reports builds on a completely idle machine. Match the executable basename exactly.- Read
ps -Ao comm=, notcommand=, when hunting build processes. An argv listing includes your own command line, so any check that mentionscargomatches 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.
Signals
- GitHub stars
- 4k
- Forks
- 60
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
disk-reduction- Source
- github.com/nubjs/nub