2026-08-16 Nix Store Pinned by nix-env Profile Generations
What I set out to do
Answer a one-line question: how big is the nix store. It came back 115G on disk (107.3 GiB nar, 77,850 paths), and nix store gc --dry-run printed its two progress headers and then nothing at all.
What I actually did
The empty dry run was the real result, not a truncated one. nix-store --gc --print-dead confirmed zero dead paths. Everything in the store was rooted, so the question became: rooted by what.
Bucketing nix-store --gc --print-roots:
- 14,653 of 15,055 roots were
{temp:PID}entries from 9nix-daemonworkers alive ~7 days. Alarming-looking, but they covered 69,143 paths worth only 9.4 GiB, of which 68,052 were.drvfiles totalling 0.37 GiB. Not the lever. - The persistent roots closed over 8,707 paths / 97.9 GiB. Recomputing that closure with only the current generation kept dropped it to 17.9 GiB. So ~80 GiB was pinned purely by 93 superseded profile generations, dating back to Jun 9. Nine duplicate
litellmbuilds at ~2.86 GiB each dominated.
The cause: standalone Home Manager installs home-manager-path into the nix profile, so every hm switch mints a generation. The standing hm expire-generations '1h' was working perfectly and was simultaneously irrelevant, because doExpireGenerations globs home-manager-*-link only. Two generation namespaces share ~/.local/state/nix/profiles/; HM owns one (1 link, spotless), nix-env owns the other (93 links, untouched since June).
Then moved just out of imperative state and into home.packages in nix/home-manager/modules/packages.nix. It had been a nix-env -i install stuck at 1.43.0 while the repo devshell served 1.58.0, so the global and the devshell disagreed by 15 minor versions. Ran nix-env -e just before hm switch to avoid the bin/just collision between the profile entry and home-manager-path. Verified: profile now holds home-manager-path alone, and just resolves to 1.58.0 outside the devshell.
Swept for other drift while there. Clean: all 13 LaunchAgents are declared except Google’s (Chrome/Drive installers), and Homebrew is intentionally Tier 1.5. Undeclared leaks found: wscat and @earendil-works/pi-coding-agent in npm -g; claude-code-transcripts, copier-template-tester, huggingface-hub as uv tools. (I first counted ipython/jupyterlab/comfy-cli as duplicates too, which was wrong: python.nix declares them via its own uv-tool mechanism, so they are Tier-1 managed and just installed through uv rather than nixpkgs.)
Then checked whether any of it was actually used, against the atuin db (16,162 rows, 2026-02-28 to today) with a parser that compares command head tokens rather than substrings. wscat: zero, over a window starting three weeks before its Mar 21 install, so removed it. claude-code-transcripts: zero, though it predates the window. huggingface-hub: one use, hf auth login, on its install day in March. pi: three bare invocations across one night in July, nothing since. The sharpest result was copier-template-tester: zero uses of the installed ctt, because all seven real invocations went through uvx --from copier-template-tester --with copier --with copier-template-extensions ctt. The global copy is bypassed precisely because it lacks the extension. Also spotted a stale comment in packages.nix claiming claude-code is npm-installed, when claude actually resolves to ~/.local/share/claude/versions/2.1.233 from the native installer.
The part that made the rest moot
Ran nix store optimise at the end and it took the store from 115G to 51G, freeing ~64G, while deleting nothing. Path count went 77,850 to 78,006, nar total held at 107.4 GiB, and all 95 generations still roll back. A confirming second pass freed only 21.58 MiB, so the run had genuinely finished.
auto-optimise-store was false, so the store had essentially no dedup: 95 near-identical generations and 9 separate litellm builds were each stored in full. Hardlinking them into /nix/store/.links (now 792,613 entries) collapsed all of it.
This inverts the whole plan. The “~80 GiB pinned by old generations” figure came from summing nar sizes, and nar sizes are hardlink-blind. Now that the duplicate content is shared, deleting those 94 generations would free only the blocks unique to them, a small fraction of 80 GiB. The destructive option, the one that costs rollback, turned out to be the low-value one; the non-destructive option got nearly all of the win. Optimise first, always, before reasoning about what to delete.
Worth turning on auto-optimise-store so new paths dedup on arrival, but it’s a daemon setting on multi-user, so it goes in /etc/nix/nix.conf, not the HM-generated ~/.config/nix/nix.conf. base.nix:62-81 already documents that exact split for two other settings. The alternative is a periodic optimise timer alongside the existing logrotate and tldr-update agents, keeping the hashing cost off the build path.
What was striking
Three things I had backwards going in.
nix path-info -S is --closure-size, not nar size. Summing it reported 7,602 GiB against a 115G store, a 66x overcount, and I nearly reasoned from it. --json and .narSize is the honest measurement.
The nix-env fallback is self-perpetuating. home-environment.nix:729 tests for manifest.json and falls back to nix-env -i, which writes manifest.nix and so re-satisfies its own negative condition forever. Only a manual nix profile install ever breaks the cycle, and home.profileDirectory is readOnly = true. Nothing in HM will ever migrate you.
And migrating would fix nothing anyway: nix profile add mints a generation per switch exactly like nix-env -i. The only genuine escapes are adopting nix-darwin with useUserPackages = true (which routes packages to /etc/profiles/per-user and skips the profile entirely) or the discourse-18016 trick of mkForce-ing installPackages empty and symlinking .nix-profile at config.home.path.
Upstream is not moving on this. No PR proposes changing the standalone default, nix-command is still experimental as of Nix 2.32, and the one related open issue (nix-community/home-manager#9598) only affects the nix profile branch nobody on this host is taking.
Top 3 tomorrow
- Decide on a retention policy:
nix-env --delete-generations --profile ~/.local/state/nix/profiles/profile 30d, ideally driven from the HM config rather than run by hand. - Close the undeclared-install gaps, or consciously decide uv tools stay imperative.
- Fix the stale claude-code npm comment in
packages.nix.