Disk hygiene: a space alarm plus trash expiry
Edit on GitHubmodules/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.
| Path | 30/07 | 30/08 |
|---|---|---|
~/.local/share/bottles | 319 GiB | 316 GiB (Battlenet 181, CS-II 85, Ascension 46) |
~/Games | 47 GiB | 46 GiB (one 46 GiB PS3 ISO) |
/nix/store | 58 GiB | 46 GiB, handled by the GC, not deletable by hand |
/srv/media | 132 GiB | 45 GiB, the Jellyfin library |
~/Downloads | 2.5 GiB | 35 GiB |
~/.config | not listed | 27 GiB (Claude vm_bundles 14, Chrome 6) |
~/Projects | not listed | 18 GiB |
~/.cache | 3.9 GiB | 9 GiB |
~/.local/share/Steam | 8 GiB | 8 GiB |
~/Documents | not listed | 4.5 GiB |
~/.local/share/Trash | 1.7 GiB | 0.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~/.darlingwas not listed at all. The list was remeasured and grew those entries. It must stay SIBLINGS ONLY:dudrops a path nested under another one passed in the same run, so adding~/Projects/Localnext to~/Projectswould have made it vanish without a trace. - A bubble is not a channel. The message now goes to the phone too, with
-p highfor warn and-p urgentfor crit. The 12 h anti-spam throttles both channels, andnotifyexits 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
vwould create missing PARENTS as root inside$HOME, which is why the deep paths are not there. An existing plain directory only gets a warning onrebuild, naming the command below. - The nodatacow flag has to be carried by hand. The VM images are
+C, andcpdoes 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+Cbefore copying, so each file inherits it at creation. A+CFILE in a directory without it still fails, loudly, and the half-made copy is removed. convert-to-subvolume PATHconverts 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 withcp -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 asPATH.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 -BGcomes out as" 123G", so strip the G and the space. Ifdffails (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
|| trueis there because a nonexistent path or one without permission cannot take the alarm down. - The awk splits on
-F'\t'and NOT on whitespace.duseparates 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.