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