feat(nixos): prune system+HM gens after 14d, keep ≥3 past (#128)
Some checks failed
Check / eval (push) Has been cancelled

Weekly nomarchy-gen-prune deletes only generations that are both older
than 14 days and beyond the three most recent past gens (current always
kept), for system and Home Manager profiles. Stock nix.gc no longer uses
--delete-older-than. checks.gen-prune covers selection self-test and
unit wiring; doctor points at the new tool when disk is tight.
This commit is contained in:
2026-07-15 11:59:26 +01:00
parent 1f6f21249e
commit e4800d7d8e
9 changed files with 344 additions and 45 deletions

View File

@@ -78,44 +78,6 @@ cable leaves. Capture steps in HARDWARE-QUEUE.
## NEXT
### 128. Auto-prune system + HM generations: >14 days, but keep ≥3 past
Bernardo 2026-07-15 (refined same day): automatic cleanup of **both**
NixOS (system) and Home Manager (per-user) generations so boot menu,
desktop rollback, and store do not grow without bound, with a safety floor:
1. **Age:** candidates older than **14 days** may be removed.
2. **Count floor:** always keep the **current** generation and at least
**3 past** generations — even if those three are older than 14 days.
3. Combined: delete only gens that are **both** older than 14 days **and**
older than the third-most-recent past generation. Gens within 14 days
stay regardless of how many there are (until they age out).
4. **Apply the same rule independently** to the system profile and to each
user's Home Manager profile (desktop rollback in Recovery Desktop).
**Why this shape:** pure age GC (`--delete-older-than`) can wipe a machine
that is rarely rebuilt down to one generation and remove useful rollback
targets; pure count limits (`configurationLimit`) drop recent work just
because the user iterates a lot. Age + floor keeps travel-laptop machines
safe and busy desktops tidy. 14d matches the existing store GC cadence
rather than a longer 30d hold.
**Today:** `nix.gc` is already weekly with `--delete-older-than 14d`
(`modules/nixos/default.nix`) — store GC, **no** min-generation floor, and
it is not framed as an explicit dual system+HM policy. This item should
replace/refine that (or add a dedicated pruner if stock GC cannot express
the floor on both profiles). Bootloader list
(`boot.loader.systemd-boot.configurationLimit`) may need to stay in sync
so removed system profiles do not leave dead boot entries.
Pass = weekly (or post-rebuild) cleanup enforces 14d + keep ≥3 past on
**system and HM** generations; a machine with only old gens never goes
below current+3 on either profile; docs/RECOVERY still matches boot-menu
and desktop rollback; doctor or a check asserts the timer/unit exists and
the prune script's dry-run logic. V3: box with many gens — boot menu +
`nix-env -p /nix/var/nix/profiles/system --list-generations` +
`home-manager generations` after a prune.
### 120. A netinstall ISO, next to the fat offline one
Bernardo 2026-07-14, after seeing the measured size: **keep the current ISO

View File

@@ -19,6 +19,17 @@ Template:
---
## 2026-07-15 — #128 generation prune (14d + keep ≥3 past, system+HM)
- **Task:** Implement auto-prune for system and Home Manager generations.
- **Did:** `pkgs/nomarchy-gen-prune` + `modules/nixos/gen-prune.nix` weekly
timer; pure select (current + 3 past always; else drop if age>14d); store
GC without `--delete-older-than`; bootloader refresh after system prune.
Doctor fix + RECOVERY note. Closed #128.
- **Verified:** V0/V1 — `checks.gen-prune` (self-test + dry-run + timer
assert + no age-only gc options). V3: run on a multi-gen box.
- **Pending:** V3 on real machine with many gens.
- **Next suggestion:** human #115/#120, or hardware queue.
## 2026-07-15 — File #128 generation auto-prune policy (no code)
- **Task:** Bernardo: auto-clean gens older than N days but always keep
≥3 past; include HM; prefer 14d over 30d.

View File

@@ -65,6 +65,11 @@ broken one. Make the fix stick from the working boot: revert the change
in `~/.nomarchy` (`git -C ~/.nomarchy revert …` or edit `system.nix`
back), then `nomarchy-rebuild`.
**Automatic prune (#128):** a weekly timer (`nomarchy-gen-prune`) removes
system *and* Home Manager generations that are **older than 14 days** and
**beyond the three most recent past gens** (current + ≥3 past always kept).
Manual: `sudo nomarchy-gen-prune` (or `--dry-run`).
## 4. Files went missing or wrong — snapshots (BTRFS installs)
With `nomarchy.system.snapper.enable` (the installer's default on

View File

@@ -427,6 +427,11 @@ Design/decision records and a running log of shipped work (items marked
default `$TERMINAL` / SUPER+Return, doctor + calendar + what-changed use
`kitty --class=com.nomarchy.*`. Ghostty module and install-time GL probe
removed. V3: Acer SUPER+Return + doctor float.
- ✓ **Generation prune (#128):** weekly `nomarchy-gen-prune` (system + HM):
delete only gens older than **14 days** that are also beyond the **3 most
recent past** gens (current always kept). Stock `nix.gc` no longer uses
`--delete-older-than` (floor-free). `checks.gen-prune` self-test + unit
wiring. Manual: `sudo nomarchy-gen-prune` / `--dry-run`.
- ✓ **Recovery + System IA + Control Center retired (#111 / #105 / #110):**
System menu is Connectivity / Devices / Recovery / Preferences (root still
six entries). Recovery is scope-first: Desktop generation (HM), System boot

View File

@@ -90,6 +90,7 @@
nomarchy-what-changed = final.callPackage ./pkgs/nomarchy-what-changed { };
nomarchy-detect-hw = final.callPackage ./pkgs/nomarchy-detect-hw { };
nomarchy-airplane = final.callPackage ./pkgs/nomarchy-airplane { };
nomarchy-gen-prune = final.callPackage ./pkgs/nomarchy-gen-prune { };
# pull / rebuild / home (+ legacy aliases) — also on HM so a home
# switch can refresh a broken system-package nomarchy-pull.
# Includes nomarchy-what-changed (generation readability, #82).
@@ -604,6 +605,36 @@
touch $out
'';
# Generation prune (#128): pure selection self-test (14d + keep ≥3
# past) baked into the script; assert timer+service on the distro.
gen-prune =
let
node = self.nixosConfigurations.nomarchy;
timerWanted =
builtins.elem "timers.target"
(node.config.systemd.timers.nomarchy-gen-prune.wantedBy or [ ]);
execStart =
node.config.systemd.services.nomarchy-gen-prune.serviceConfig.ExecStart or "";
gcOpts = node.config.nix.gc.options or "";
noAgeWipe =
gcOpts == ""
|| !(nixpkgs.lib.hasInfix "delete-older-than" gcOpts);
in
assert nixpkgs.lib.assertMsg timerWanted
"gen-prune: timer not wanted by timers.target";
assert nixpkgs.lib.assertMsg (execStart != "")
"gen-prune: service ExecStart empty";
assert nixpkgs.lib.assertMsg noAgeWipe
"gen-prune: nix.gc.options must not use --delete-older-than (got: ${gcOpts})";
pkgs.runCommand "nomarchy-gen-prune"
{ nativeBuildInputs = [ pkgs.bash ]; }
''
set -eu
${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune --self-test
${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune --dry-run >/dev/null
touch $out
'';
# Installer keyboard no-variant regression (item 91): gum's `(none)`
# row is display-only. Exercise it through the real template
# patcher, then build the exact us + empty-variant console keymap

View File

@@ -35,7 +35,11 @@ let
in if builtins.isBool v then v else null;
in
{
imports = [ ./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix ];
imports = [
./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix
./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix
./gen-prune.nix # #128 system+HM generation prune (14d, keep ≥3 past)
];
config = {
# Distro branding. distroName flows into /etc/os-release PRETTY_NAME,
@@ -512,11 +516,8 @@ in
# dirty" warning fires on every rebuild and is pure noise here.
warn-dirty = lib.mkDefault false;
};
gc = {
automatic = lib.mkDefault true;
dates = lib.mkDefault "weekly";
options = lib.mkDefault "--delete-older-than 14d";
};
# Generation age+floor policy + store GC: modules/nixos/gen-prune.nix (#128).
};
};
}

View File

@@ -0,0 +1,41 @@
# #128 — weekly generation prune: system + Home Manager profiles.
# Policy: drop gens older than 14 days only when they are beyond the
# three most recent *past* generations (current always kept).
{ config, lib, pkgs, ... }:
{
config = {
environment.systemPackages = [ pkgs.nomarchy-gen-prune ];
# Stock nix.gc --delete-older-than has no keep-N floor and would fight
# this policy. Keep weekly store GC for dead paths only; generation
# selection is nomarchy-gen-prune's job.
nix.gc = {
automatic = lib.mkDefault true;
dates = lib.mkDefault "weekly";
# Empty options → collect unreferenced store paths only (no age-based
# profile generation wipe).
options = lib.mkDefault "";
};
systemd.services.nomarchy-gen-prune = {
description = "Prune old NixOS and Home Manager generations (14d, keep 3 past)";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.nomarchy-gen-prune}/bin/nomarchy-gen-prune";
};
# After the stock store GC timer if both fire weekly.
after = [ "nix-gc.service" ];
};
systemd.timers.nomarchy-gen-prune = {
description = "Weekly Nix generation prune (Nomarchy #128)";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "weekly";
Persistent = true;
RandomizedDelaySec = "1h";
};
};
};
}

View File

@@ -60,7 +60,7 @@ for mp in / /boot /nix; do
avail=$(numfmt --to=iec $((availkb * 1024)))
if [ "$use" -ge 90 ]; then
bad "$mp is ${use}% full (${avail} free)" \
"sudo nix-collect-garbage --delete-older-than 14d (then sys-rebuild to prune old boot entries)"
"sudo nomarchy-gen-prune (14d + keep ≥3 past system/HM gens; then reclaims store)"
else
ok "$mp has space (${use}% used, ${avail} free)"
fi
@@ -127,6 +127,21 @@ else
skip "system generation age (no system profile)"
fi
# ── generation prune (#128: 14d + keep ≥3 past, system + HM) ─────────
if [ -n "$(systemctl list-unit-files nomarchy-gen-prune.timer --no-legend --plain 2>/dev/null)" ]; then
if systemctl is-enabled --quiet nomarchy-gen-prune.timer 2>/dev/null; then
ok "generation prune timer is enabled (14d, keep ≥3 past)"
else
bad "nomarchy-gen-prune.timer is installed but not enabled" \
"systemctl enable --now nomarchy-gen-prune.timer"
fi
elif command -v nomarchy-gen-prune >/dev/null 2>&1; then
warn "nomarchy-gen-prune is on PATH but no timer unit" \
"rebuild the system so gen-prune.nix ships the weekly timer"
else
skip "generation prune (package not on this machine)"
fi
# ── snapper timeline (when enabled) ──────────────────────────────────
if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then
if systemctl is-active --quiet snapper-timeline.timer; then

View File

@@ -0,0 +1,228 @@
{ lib
, writeShellScriptBin
, coreutils
, gnugrep
, gawk
, nix
, findutils
}:
# #128 — prune system + Home Manager generations:
# drop only if older than 14 days AND beyond the 3 most recent *past*
# gens (current + ≥3 past always kept). Store reclaim is a plain
# nix-collect-garbage after (no --delete-older-than — that would ignore
# the floor).
writeShellScriptBin "nomarchy-gen-prune" ''
set -euo pipefail
PATH=${lib.makeBinPath [ coreutils gnugrep gawk nix findutils ]}:$PATH
MAX_AGE_DAYS=''${NOMARCHY_GEN_PRUNE_MAX_AGE_DAYS:-14}
KEEP_PAST=''${NOMARCHY_GEN_PRUNE_KEEP_PAST:-3}
DRY=0
SELFTEST=0
usage() {
echo "usage: nomarchy-gen-prune [--dry-run] [--self-test]" >&2
echo " Prune NixOS system + Home Manager profile generations:" >&2
echo " delete only if older than ''${MAX_AGE_DAYS}d and beyond the" >&2
echo " ''${KEEP_PAST} most recent past gens (current always kept)." >&2
exit 64
}
for arg in "$@"; do
case "$arg" in
--dry-run) DRY=1 ;;
--self-test) SELFTEST=1 ;;
-h|--help) usage ;;
*) usage ;;
esac
done
log() { echo "nomarchy-gen-prune: $*" >&2; }
# Pure selection: stdin lines "NUM EPOCH [current]"
# stdout: generation numbers to delete (one per line).
# Always protect current + up to KEEP_PAST gens with number < current
# (highest first). Others delete only if EPOCH < now - MAX_AGE_DAYS.
select_deletions() {
local now keep_past max_age
now=$(date +%s)
keep_past=$KEEP_PAST
max_age=$MAX_AGE_DAYS
awk -v now="$now" -v keep_past="$keep_past" -v max_age="$max_age" '
NF < 2 { next }
{
num = $1 + 0
ts = $2 + 0
cur = (NF >= 3 && $3 == "current")
nums[n] = num; tss[num] = ts; if (cur) current = num; n++
}
END {
if (n == 0) exit 0
if (current == 0) {
# No marker: treat highest generation number as current.
current = nums[0]
for (i = 1; i < n; i++) if (nums[i] > current) current = nums[i]
}
# Past gens: number < current, sort desc.
pn = 0
for (i = 0; i < n; i++) {
if (nums[i] < current) { past[pn++] = nums[i] }
}
# bubble-sort past desc (small n)
for (i = 0; i < pn; i++)
for (j = i + 1; j < pn; j++)
if (past[j] > past[i]) { t = past[i]; past[i] = past[j]; past[j] = t }
protect[current] = 1
for (i = 0; i < pn && i < keep_past; i++) protect[past[i]] = 1
cutoff = now - (max_age * 86400)
for (i = 0; i < n; i++) {
g = nums[i]
if (protect[g]) continue
if (tss[g] < cutoff) print g
}
}
'
}
if [ "$SELFTEST" = 1 ]; then
# Fixture: now-fixed via epoch math in the data itself.
# current=10; past 9,8,7 protected; 6 is >14d old delete; 5 is young keep.
export KEEP_PAST=3 MAX_AGE_DAYS=14
now=$(date +%s)
old=$((now - 20 * 86400))
young=$((now - 2 * 86400))
got=$(printf '%s\n' \
"10 $young current" \
"9 $old" \
"8 $old" \
"7 $old" \
"6 $old" \
"5 $young" \
| select_deletions)
echo "$got" | grep -qx 6 || { echo "self-test: expected only 6, got: [$got]" >&2; exit 1; }
# Rarely rebuilt: only old gens delete none below floor (keep 10+9+8+7)
got=$(printf '%s\n' \
"10 $old current" \
"9 $old" \
"8 $old" \
"7 $old" \
"6 $old" \
| select_deletions)
echo "$got" | grep -qx 6 || { echo "self-test: floor failed, got: [$got]" >&2; exit 1; }
# Only 3 gens total, all old delete nothing
got=$(printf '%s\n' \
"3 $old current" \
"2 $old" \
"1 $old" \
| select_deletions)
[ -z "$got" ] || { echo "self-test: expected empty, got: [$got]" >&2; exit 1; }
log "self-test ok"
exit 0
fi
# Enumerate "NUM EPOCH [current]" for a profile symlink base.
# profile is the live link, e.g. /nix/var/nix/profiles/system
list_gens() {
local profile=$1
local dir base cur_base cur_num name num ts
[ -e "$profile" ] || [ -L "$profile" ] || return 0
dir=$(dirname -- "$profile")
base=$(basename -- "$profile")
cur_base=$(basename -- "$(readlink "$profile" 2>/dev/null || true)")
cur_num=
case "$cur_base" in
"$base"-*-link)
cur_num=''${cur_base#"$base"-}
cur_num=''${cur_num%-link}
;;
esac
# shellcheck disable=SC2035
for link in "$dir"/"$base"-*-link; do
[ -e "$link" ] || [ -L "$link" ] || continue
name=$(basename -- "$link")
case "$name" in
"$base"-*-link) ;;
*) continue ;;
esac
num=''${name#"$base"-}
num=''${num%-link}
case "$num" in *[!0-9]*|"") continue ;; esac
ts=$(stat -c %Y -- "$link" 2>/dev/null || echo 0)
if [ -n "$cur_num" ] && [ "$num" = "$cur_num" ]; then
printf '%s %s current\n' "$num" "$ts"
else
printf '%s %s\n' "$num" "$ts"
fi
done
}
prune_profile() {
local profile=$1 label=$2
local list dels
if [ ! -e "$profile" ] && [ ! -L "$profile" ]; then
log "skip $label (no profile at $profile)"
return 0
fi
list=$(list_gens "$profile" || true)
if [ -z "$list" ]; then
log "skip $label (no generation links)"
return 0
fi
dels=$(printf '%s\n' "$list" | select_deletions)
if [ -z "$dels" ]; then
log "$label: nothing to prune"
return 0
fi
# shellcheck disable=SC2086
set -- $dels
log "$label: delete generations $* (keep current + $KEEP_PAST past; age>$MAX_AGE_DAYS d)"
if [ "$DRY" = 1 ]; then
log "$label: dry-run not deleting"
return 0
fi
# nix-env needs a writeable profile; system requires root.
nix-env -p "$profile" --delete-generations "$@" \
|| log "$label: nix-env --delete-generations failed (need root for system?)"
}
prune_profile /nix/var/nix/profiles/system "system"
# Home Manager: per-user under /nix/var/nix/profiles and XDG state.
if [ -d /nix/var/nix/profiles/per-user ]; then
for u in /nix/var/nix/profiles/per-user/*; do
[ -d "$u" ] || continue
prune_profile "$u/home-manager" "home-manager($(basename "$u"))"
done
fi
# Standalone / modern HM state dir for real users' homes.
if [ -d /home ]; then
for h in /home/*; do
[ -d "$h" ] || continue
prune_profile "$h/.local/state/nix/profiles/home-manager" \
"home-manager-xdg($(basename "$h"))"
done
fi
# root's XDG state if present
prune_profile /root/.local/state/nix/profiles/home-manager "home-manager-xdg(root)"
if [ "$DRY" = 1 ]; then
log "dry-run done (no store GC)"
exit 0
fi
# Reclaim store paths freed by gen deletion no --delete-older-than
# (would re-introduce floor-free age GC on any remaining profiles).
log "nix-collect-garbage (dead store paths only)"
nix-collect-garbage || true
# Refresh systemd-boot entries so removed system gens disappear from
# the menu without waiting for the next rebuild.
if [ -x /run/current-system/bin/switch-to-configuration ]; then
log "refreshing bootloader entries"
/run/current-system/bin/switch-to-configuration boot >/dev/null 2>&1 \
|| log "bootloader refresh skipped (non-fatal)"
fi
log "done"
''