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

@@ -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"
''