disk-reduction
GitHub用于清理 Rust 构建残留以释放磁盘空间。处理工作树私有 target 目录和孤儿共享目标桶,提供安全清理脚本及 df/du 差异说明,解决 ENOSPC 问题。
Trigger Scenarios
Install
npx skills add nubjs/nub --skill disk-reduction -g -y
SKILL.md
Frontmatter
{
"name": "disk-reduction",
"description": "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 `target\/` dirs and orphaned content-hashed shared-target buckets under `~\/.cache\/nub`), the safety gates that keep a live bucket and the installed `nub-dev` binary alive, and why `du` and `df` disagree by tens of GB on APFS. For CPU load, orphaned processes, or a build hung on a target lock, use `cpu-reduction`; for not creating the residue, `rust-build-hygiene`."
}
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.
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 (vendor/aube and crates, excluding the leaves nub-cli/nub-native/nub-phantom). 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/aubeor a non-leaf crate strands the previous bucket.
rust-build.sh has its own GC, but it only retires a bucket after 14 days of untouched mtime, so a fortnight of churn accumulates 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 — but it returns almost nothing, so do it for tidiness and stop there.
4. Gotchas that each cost a round trip
- 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. - Never map buckets by running
--print-targetin every worktree. It is not a read: itmkdirs andtouches the target (the liveness signal), CoW-seeds a private target for an isolated worktree — recreating the multi-GB dirs you just deleted — and fires the 14-day GC. 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.
Version History
- b140840 Current 2026-08-16 20:09


