dotfiles
Boot and storage

Disk hygiene: a space alarm plus trash expiry

Edit on GitHub

modules/home/services/disk-hygiene.nix.

Why this exists, and why it is NOT more GC

The Nix GC is already automatic (modules/nixos/core/core.nix) and it works, but MEASURED on 30/07 it covers 9% of the disk: /nix/store held 58 GiB against 626 GiB used. REMEASURED on 30/08 the GC had done its job on its own, taking the store down to 46 GiB, and the share it covers fell to 7% of 664 GiB used.

The other 93% is games and media, and NONE of that can be deleted automatically. Nobody should delete somebody's game on their own.

So the right answer to "do not let the disk fill up" is not deleting more, it is WARNING with enough data for me to decide.

Two things of different natures, together

They are the same task (keeping the disk under control) and both are user timers.

  • disk-watch, the alarm: it notifies when free space drops, ALREADY WITH the biggest consumers in the message. The request was "to evaluate what I want to remove", and for that the notification has to say WHAT grew.
  • trash-expire: the trash was the only REAL garbage found in the measurement, 1.7 GiB sitting there that nobody expired and that restic already excludes from the backup, so it was pure waste.

The two-phase design

du over the whole tree takes MINUTES on this machine, measured. Running that every 30 min would be absurd.

So the timer only does the CHEAP check (df, instant), and the EXPENSIVE sweep only happens when the disk is actually low, which is the moment when spending a few minutes is exactly what you want. nice plus ionice so it does not compete with the session.

Anti-spam

A notification repeating every 30 min becomes noise and starts being ignored, the same mistake as the timers that drowned the journal (see bb8690c). It re-warns at most once every 12 h per severity, but IMMEDIATELY if the severity goes up (warn to crit). The state lives in $XDG_RUNTIME_DIR, which resets at boot.

LogLevelMax = "warning" on both units for the same reason: the scripts already exit silently, but that cuts the "Starting…/Finished…" SYSTEMD logs on its own, which is where those 2148 lines/day came from.

The owner (rule 15)

A systemd --user timer tied to graphical-session, because it needs the session: what delivers the notification is Quickshell, the org.freedesktop.Notifications daemon. The same message also goes to the phone through notify (ntfy, pkgs/notify), see below.

The panel

Absolute GiB and not a percentage: what matters is whether the NEXT game or patch fits, and that is an absolute number.

150 and no longer 100. The original 100 was set when 243 GiB were free and the reasoning was "room to decide". What changed is the size of one decision: the biggest single consumer here is now an ~88 GiB game, so 100 GiB free is barely one install of headroom. A warning that arrives with room for exactly one more thing is not a choice, it is a notice.

watchPaths is deliberately NOT a full du /: sweeping everything would take extra minutes and bring noise (/proc, /sys, network mounts). If a new consumer shows up outside this list, it is 1 line, and filelight and czkawka exist precisely to discover it.

The list drifted, which is the lesson

The 30/07 list was a month old and already lying. /srv/media was off by 3x, and ~/.config had become the 4th biggest consumer on the machine without ever being on the list, so the alarm was structurally blind to 27 GiB. A hardcoded ranking rots; that is why disk-insight.nix logs a trend instead of trusting a snapshot.

Path30/0730/08
~/.local/share/bottles319 GiB316 GiB (Battlenet 181, CS-II 85, Ascension 46)
~/Games47 GiB46 GiB (one 46 GiB PS3 ISO)
/nix/store58 GiB46 GiB, handled by the GC, not deletable by hand
/srv/media132 GiB45 GiB, the Jellyfin library
~/Downloads2.5 GiB35 GiB
~/.confignot listed27 GiB (Claude vm_bundles 14, Chrome 6)
~/Projectsnot listed18 GiB
~/.cache3.9 GiB9 GiB
~/.local/share/Steam8 GiB8 GiB
~/Documentsnot listed4.5 GiB
~/.local/share/Trash1.7 GiB0.1 GiB, the timer below is working

It names the FILES too

A directory ranking answers WHERE and stops there. MEASURED on 30/08: ~/Downloads showed up at 35 GiB and 17.4 of those were a single world.rar. The folder is never the thing you delete, so the alarm lists the biggest individual files under it.

The find only runs in phase 2, for the same reason the du does: it is minutes of work that is only worth spending once the disk is actually short.

It drifted again, and the bubble was not enough

On 24/10/2026 the disk was at 87% and the alarm HAD been firing, every 12 h, as a desktop bubble that nobody read. Two lessons, both fixed in the script:

  • The list was blind again. The macOS lab VM (101 GiB under ~/Projects) and Darling (48 GiB) were new, and ~/.darling was not listed at all. The list was remeasured and grew those entries. It must stay SIBLINGS ONLY: du drops a path nested under another one passed in the same run, so adding ~/Projects/Local next to ~/Projects would have made it vanish without a trace.
  • A bubble is not a channel. The message now goes to the phone too, with -p high for warn and -p urgent for crit. The 12 h anti-spam throttles both channels, and notify exits 0 when its secret is missing, so the push can never take the alarm down.

It says what GREW

After a month the ranking gives the same answer every time, so it stopped being news. The alarm now also lists the 3 biggest deltas between the live du and the last weekly snapshot in trend.tsv (my.disk.trendFile, owned by disk-insight.nix). The comparison is scripts/disk-growth.awk, the same file disk-report uses, so the two never disagree on what "grew" means. ONE du feeds both the ranking and the delta.

It points at btdu when du cannot explain df

Space that only the btrbk snapshots still hold is invisible to every du. When df used is more than unlistedGiB (100) above what the list adds up to, the alarm says so and names sudo btdu /. MEASURED on the first run, 24/10: 163 GiB outside the list. Not all of it is snapshots (the swapfile and unlisted paths count too), which is why the line says "likely".

What churns gets its own subvolume

The alarm says the disk is full. This is what stops the snapshots from making it full. btrbk snapshots @home every hour and keeps 48h 7d 4w, so every extent a VM image or a game patch rewrites stays pinned for up to four weeks. The live tree never shows it, and on 24/10/2026 the gap between df and the listed paths was 163 GiB. A snapshot does not descend into a nested subvolume, so a path that is its own subvolume costs its live size and nothing more.

my.disk.subvolumes (modules/home/services/home-subvolumes.nix) lists those paths: the whole Steam library (steamapps), the macOS lab's VM state and the Overwatch folder that was made a subvolume by hand on 18/10. ~/.cache predates this and stays a root tmpfiles v rule in btrfs.nix.

A rename across a subvolume boundary fails with EXDEV, and that decides where the line goes. The first version made steamapps/common the subvolume, but Steam downloads into steamapps/downloading and renames into common, so the boundary has to be at the library root. An overlay whose workdir sits outside the path is ruled out the same way, since every copy-up renames from the workdir into the upper layer. The macOS lab qualifies because its tools write the images in place and never move a file into state/.

  • The activation CREATES, never converts. A missing path becomes a subvolume, as the user, so the ownership is right. A root tmpfiles v would create missing PARENTS as root inside $HOME, which is why the deep paths are not there. An existing plain directory only gets a warning on rebuild, naming the command below.
  • The nodatacow flag has to be carried by hand. The VM images are +C, and cp does not copy that flag. btrfs refuses to clone between a nodatacow file and a normal one, so the first real run (24/10) failed with EINVAL on every file of the VM directory. The script recreates the directories with their +C before copying, so each file inherits it at creation. A +C FILE in a directory without it still fails, loudly, and the half-made copy is removed.
  • convert-to-subvolume PATH converts in place with no sudo. It refuses while a process has a file open under the path or something is mounted there. It then copies with cp -a --reflink=always, which costs no space and fails loudly instead of falling back to a real copy, and checks path and size per file. The swap leaves the original as PATH.pre-subvol. Deleting that is the user's call, after the app opened. TESTED on 24/10 on a scratch directory: the conversion, a second run doing nothing, and the refusal while a shell sat inside it.
  • The old extents are still in the snapshots taken before the conversion. The space comes back as those age out, or at once with a purge, the same trade games-disk.md measured.

The cache expiry, and the two generic approaches that are both wrong here

~/.cache was 9 GiB with no policy at all. What survived scrutiny was ONE command, because the two obvious generic reapers are actively harmful on this machine:

find -atime: meaningless. The filesystem is mounted noatime (hosts/ex-b560m-v5/disko.nix), so every access time under ~/.cache is frozen at the day the file landed on this disk. An age-by-access rule sweeps everything or nothing. This is the same noatime fact that sent disk-insight.nix to /proc instead of the filesystem.

find -mtime -delete: corrupting. ~/.cache/nix/tarball-cache-v2 is 1.2 GiB and is a bare GIT OBJECT STORE (it has objects/, refs/ and a HEAD). Deleting loose objects out of it by age does not trim the cache, it breaks it, and nix would only find out when a flake input failed to resolve.

So the prune goes through the tool that knows what is still referenced. MEASURED on 30/08: 23230 files and 1.1 GiB on the first run.

pnpm is deliberately NOT in there even though ~/.cache/pnpm exists. It is a devShell tool and never on the global PATH, so a user timer could not call it anyway, and a branch that can never run is the dead code rule 16 is about.

It fails by staying quiet

MEASURED the same day: an ad-hoc uv run ... serve was holding the cache lock, and the default 300 s wait ends in exit 2. A weekly unit that goes red to report "somebody was building" is a unit you learn to ignore, so the timeout drops to 60 s and losing the race exits 0. --force exists and stays off: it ignores the in-use check, which is how a prune pulls wheels out from under a live build.

Weekly and not daily, unlike the trash: a pruned cache costs a redownload on the next build, and that is not worth paying every day for a ~1 GiB drip. It runs at 05:15, ahead of the 05:30 trend snapshot, so the log measures the result instead of the backlog.

Small script details

  • df --output=avail -BG comes out as " 123G", so strip the G and the space. If df fails (the fs disappeared?), exit rather than invent an alarm.
  • The sweep uses MiB so it can be sorted numerically, and the output is formatted in GiB. The || true is there because a nonexistent path or one without permission cannot take the alarm down.
  • The awk splits on -F'\t' and NOT on whitespace. du separates with a tab, and every Windows game path has spaces in it, so the default split silently truncates the name at the first one.
  • trash-empty -f: the default only asks with -i, but in a timer it is better to be explicit, since a unit waiting for an answer hangs forever.