flake-update-20260505
1#!/usr/bin/env bash
2# nix-store-analyzer.sh
3# Analyzes Nix store usage and identifies old GC roots keeping packages alive
4#
5# Usage:
6# ./nix-store-analyzer.sh # Analyze local machine
7# ./nix-store-analyzer.sh host.vpn # Analyze remote host via SSH
8# ./nix-store-analyzer.sh user@host # Analyze remote host with specific user
9#
10# shellcheck disable=SC2016,SC2034,SC2046,SC2086
11
12set -euo pipefail
13
14# Colors
15RED='\033[0;31m'
16GREEN='\033[0;32m'
17YELLOW='\033[1;33m'
18BLUE='\033[0;34m'
19CYAN='\033[0;36m'
20BOLD='\033[1m'
21NC='\033[0m' # No Color
22
23# Configuration
24TARGET="${1:-}"
25MIN_SIZE_MB=100 # Only show packages larger than this
26TOP_N=30 # Show top N largest packages
27
28# Determine if remote or local
29if [ -n "$TARGET" ]; then
30 SSH_PREFIX="ssh $TARGET"
31 HOSTNAME="$TARGET"
32else
33 SSH_PREFIX=""
34 HOSTNAME="$(hostname)"
35fi
36
37# Helper function to run command (local or remote)
38run_cmd() {
39 if [ -n "$SSH_PREFIX" ]; then
40 # For remote: use bash explicitly to avoid zsh glob issues
41 $SSH_PREFIX "bash -c $(printf '%q' "$@")"
42 else
43 bash -c "$@"
44 fi
45}
46
47# Helper to format sizes
48human_size() {
49 numfmt --to=iec-i --suffix=B "$1" 2>/dev/null || echo "$1"
50}
51
52echo -e "${BOLD}=== Nix Store Analysis for ${CYAN}${HOSTNAME}${NC}${BOLD} ===${NC}"
53echo ""
54
55# 1. Disk usage overview
56echo -e "${BOLD}${BLUE}1. Disk Usage Overview${NC}"
57echo "---"
58run_cmd 'df -h / | grep -E "Filesystem|^/dev"'
59echo ""
60run_cmd 'timeout 10 du -sh /nix/store 2>/dev/null || echo " (calculating in background, see section 11 for full details)"'
61echo ""
62
63# 2. System generation info
64echo -e "${BOLD}${BLUE}2. System Generations${NC}"
65echo "---"
66CURRENT_SYSTEM=$(run_cmd 'readlink /run/current-system 2>/dev/null' || echo "unknown")
67echo -e "Current system: ${CYAN}${CURRENT_SYSTEM}${NC}"
68echo ""
69
70# Try to list generations (needs root/sudo)
71GEN_COUNT=$(run_cmd 'sudo nix-env --list-generations --profile /nix/var/nix/profiles/system 2>/dev/null | wc -l' || echo "0")
72if [ "$GEN_COUNT" -gt 0 ]; then
73 echo -e "System generations: ${YELLOW}${GEN_COUNT}${NC}"
74 echo ""
75 run_cmd 'sudo nix-env --list-generations --profile /nix/var/nix/profiles/system 2>/dev/null | tail -5'
76else
77 # Fallback: count boot entries
78 BOOT_COUNT=$(run_cmd 'ls -1 /nix/var/nix/profiles/system-*-link 2>/dev/null | wc -l' || echo "0")
79 echo -e "System profile links: ${YELLOW}${BOOT_COUNT}${NC} (may need sudo for full generation list)"
80fi
81echo ""
82
83# 3. Largest packages in store (sample-based, fast)
84echo -e "${BOLD}${BLUE}3. Largest Packages (top ${TOP_N}, >${MIN_SIZE_MB}MB)${NC}"
85echo "---"
86echo -e "${CYAN}Size Package${NC}"
87echo "(Sampling method - fast estimation)"
88echo ""
89
90# Fast method: Check current system + known large package patterns
91run_cmd "timeout 10 bash -c '
92 {
93 # Check current system closure
94 nix path-info -rSh /run/current-system 2>/dev/null | tail -n +2 || true
95
96 # Check known large packages
97 find /nix/store -maxdepth 1 -type d \( \
98 -name \"*linux-firmware*\" -o \
99 -name \"*gcc-*\" -o \
100 -name \"*go-1.*\" -o \
101 -name \"*llvm-*\" -o \
102 -name \"*rust-*\" -o \
103 -name \"*python*\" -o \
104 -name \"*nodejs-*\" -o \
105 -name \"*chromium-*\" -o \
106 -name \"*firefox-*\" -o \
107 -name \"*kernel-*\" -o \
108 -name \"*emacs-*\" \
109 \) -exec du -sh {} \; 2>/dev/null || true
110 } |
111 grep -v \"\.drv\" |
112 awk \"{print \\\$1, \\\$2}\" |
113 sort -rh |
114 head -${TOP_N} |
115 while read -r size path; do
116 # Convert size to MB for filtering
117 size_val=\$(echo \$size | sed \"s/[GM]//\")
118 size_unit=\$(echo \$size | grep -o \"[GM]\" || echo \"M\")
119 if [ \"\$size_unit\" = \"G\" ]; then
120 size_mb=\$(echo \"\$size_val * 1000\" | bc 2>/dev/null || echo \"1000\")
121 else
122 size_mb=\$(echo \"\$size_val\" | cut -d\".\" -f1)
123 fi
124
125 if [ \"\$size_mb\" -ge ${MIN_SIZE_MB} ] 2>/dev/null; then
126 name=\$(basename \"\$path\" 2>/dev/null || echo \"\$path\")
127 printf \"%-10s %s\\\\n\" \"\$size\" \"\$name\"
128 fi
129 done
130' 2>/dev/null" || echo " (timeout or error - use nix-du for full analysis)"
131
132echo ""
133
134# 4. Identify specific problematic packages
135echo -e "${BOLD}${BLUE}4. Potentially Outdated Packages${NC}"
136echo "---"
137
138# Check for old Go versions (actual Go compiler, not tools written in Go)
139echo -e "${YELLOW}Old Go compiler versions:${NC}"
140run_cmd 'shopt -s nullglob; du -sh /nix/store/*-go-1.* 2>/dev/null |
141 grep -E "/go-1\.[0-9]|/go-1\.[0-9][0-9]" |
142 grep -v "go-1.2[0-9]" |
143 grep -v "\.drv" |
144 sort -rh | head -10 || true' || echo " None found"
145echo ""
146
147# Check for old GCC versions
148echo -e "${YELLOW}Old GCC compiler versions:${NC}"
149run_cmd 'du -sh /nix/store/*-gcc-[0-9]* 2>/dev/null |
150 grep -E "gcc-[0-9]\." |
151 grep -v "gcc-1[4-9]" |
152 grep -v "\.drv\|\.patch" |
153 sort -rh | head -10' || echo " None found"
154echo ""
155
156# Check for large firmware
157echo -e "${YELLOW}Firmware packages:${NC}"
158run_cmd 'du -sh /nix/store/*firmware* 2>/dev/null | sort -rh | head -5' || echo " None found"
159echo ""
160
161# Check for multiple versions of same tool (excluding .drv files)
162echo -e "${YELLOW}Multiple versions of common tools:${NC}"
163echo "(Multiple versions are normal - different tools depend on different Go versions)"
164run_cmd 'find /nix/store -maxdepth 1 -type d \( -name "*-helix-*" -o -name "*-difftastic-*" -o -name "*-emacs-[0-9]*" -o -name "*-go-1.*" \) 2>/dev/null |
165 sed "s|/nix/store/[a-z0-9]\{32\}-||" |
166 sed "s/-x86_64.*//" |
167 sort -u |
168 awk -F"-" "
169 {
170 # Extract base name and version
171 base = \$1;
172 version = \"\";
173
174 # Build base name (everything before version number)
175 for(i=2; i<=NF; i++) {
176 if (\$i ~ /^[0-9]+(\.[0-9]+)*/) {
177 # Found version, everything from here is version
178 for(j=i; j<=NF; j++) {
179 if (j == i) version = \$j;
180 else version = version \"-\" \$j;
181 }
182 break;
183 } else {
184 base = base \"-\" \$i;
185 }
186 }
187
188 # Store version for this base
189 if (version != \"\") {
190 versions[base] = versions[base] \" \" version;
191 }
192 }
193 END {
194 for (base in versions) {
195 # Get unique versions
196 split(versions[base], arr, \" \");
197 delete unique;
198 for (i in arr) {
199 if (arr[i] != \"\") {
200 unique[arr[i]] = 1;
201 }
202 }
203
204 count = length(unique);
205 if (count > 1) {
206 printf \" %s: %d versions\n\", base, count;
207
208 # Sort and display versions
209 n = asorti(unique, sorted);
210 printf \" \"
211 for (i = 1; i <= n; i++) {
212 if (i > 1) printf \", \"
213 printf \"%s\", sorted[i];
214 }
215 printf \"\n\"
216 }
217 }
218 }"' || echo " None found"
219echo ""
220
221# 5. Analyze GC roots
222echo -e "${BOLD}${BLUE}5. GC Roots Analysis${NC}"
223echo "---"
224
225# System roots
226echo -e "${YELLOW}System GC roots:${NC}"
227run_cmd 'ls -lh /nix/var/nix/gcroots/auto/ 2>/dev/null | tail -10' || echo " Cannot access"
228echo ""
229
230# User profiles
231echo -e "${YELLOW}User profiles:${NC}"
232run_cmd 'find /nix/var/nix/profiles/per-user/ -type l 2>/dev/null | while read -r link; do
233 target=$(readlink "$link")
234 age=$(stat -c "%y" "$link" 2>/dev/null | cut -d" " -f1)
235 echo " $age: $link -> $(basename "$target")"
236done | sort' || echo " Cannot access"
237echo ""
238
239# Old channel roots
240echo -e "${YELLOW}Old channels (>1 year):${NC}"
241CUTOFF_DATE=$(date -d "1 year ago" +%s 2>/dev/null || date -v-1y +%s 2>/dev/null || echo "0")
242run_cmd "find /nix/var/nix/profiles/per-user/ -name 'channels*' -type l 2>/dev/null" | while read -r link; do
243 if [ -n "$link" ]; then
244 LINK_DATE=$(run_cmd "stat -c %Y '$link' 2>/dev/null || stat -f %m '$link' 2>/dev/null || echo 0")
245 if [ "$LINK_DATE" -lt "$CUTOFF_DATE" ] && [ "$LINK_DATE" -gt 0 ]; then
246 AGE=$(run_cmd "stat -c %y '$link' 2>/dev/null | cut -d' ' -f1 || stat -f '%Sm' -t '%Y-%m-%d' '$link' 2>/dev/null")
247 echo -e " ${RED}${AGE}: $link${NC}"
248 fi
249 fi
250done || echo " None found"
251echo ""
252
253# 6. Find suspicious user-created roots
254echo -e "${BOLD}${BLUE}6. Suspicious User-Created Roots${NC}"
255echo "---"
256
257# Check common locations for old symlinks
258SUSPICIOUS_PATHS=(
259 "/home/*/tmp/system"
260 "/home/*/.cache/direnv"
261 "/home/*/result"
262 "/tmp/nix-build-*"
263)
264
265for pattern in "${SUSPICIOUS_PATHS[@]}"; do
266 echo -e "${YELLOW}Checking: ${pattern}${NC}"
267 run_cmd "find $(dirname $pattern 2>/dev/null) -maxdepth 2 -name '$(basename $pattern)' -type l 2>/dev/null | while read -r link; do
268 if [ -n \"\$link\" ]; then
269 target=\$(readlink \"\$link\" 2>/dev/null || echo '(broken)')
270 age=\$(stat -c %y \"\$link\" 2>/dev/null | cut -d' ' -f1 || stat -f '%Sm' -t '%Y-%m-%d' \"\$link\" 2>/dev/null || echo 'unknown')
271 size=\$(du -sh \"\$link\" 2>/dev/null | cut -f1 || echo '?')
272 echo \" \$age [\$size]: \$link -> \$(basename \$target)\"
273 fi
274 done" || echo " None found"
275done
276echo ""
277
278# Check direnv specifically
279echo -e "${YELLOW}direnv cache sizes:${NC}"
280run_cmd 'shopt -s nullglob; for dir in /home/*/.cache/direnv; do
281 if [ -d "$dir" ]; then
282 size=$(du -sh "$dir" 2>/dev/null | cut -f1)
283 echo " $size: $dir"
284 fi
285done; true' || echo " None found"
286echo ""
287
288# 7. Identify what keeps specific large packages alive
289echo -e "${BOLD}${BLUE}7. Root Cause for Large Old Packages${NC}"
290echo "---"
291
292# Find old Go versions and trace their roots
293echo -e "${YELLOW}Tracing old Go versions:${NC}"
294run_cmd 'shopt -s nullglob; ls -d /nix/store/*-go-1.1[0-9]* 2>/dev/null; true' | while read -r pkg; do
295 if [ -n "$pkg" ]; then
296 name=$(basename "$pkg")
297 size=$(run_cmd "du -sh '$pkg' 2>/dev/null | cut -f1" || echo "?")
298 echo -e " ${CYAN}${name}${NC} (${size})"
299
300 # Find roots
301 roots=$(run_cmd "nix-store --query --roots '$pkg' 2>/dev/null | grep -v '^{'" || echo "")
302 if [ -n "$roots" ]; then
303 echo "$roots" | while IFS=' ' read -r root arrow target; do
304 if [ -n "$root" ] && [ "$root" != "{" ]; then
305 echo -e " ${RED}└─ $root${NC}"
306 fi
307 done
308 else
309 echo -e " ${GREEN}└─ No roots (can be GC'd)${NC}"
310 fi
311 fi
312done
313echo ""
314
315# 8. Result Symlinks in Development Directories
316echo -e "${BOLD}${BLUE}8. Result Symlinks in Development Directories${NC}"
317echo "---"
318echo "Scanning common development directories for 'result' links..."
319echo ""
320
321DEV_DIRS=(
322 "/home/*/src"
323 "/home/*/projects"
324 "/home/*/code"
325 "/home/*/work"
326 "/home/*/dev"
327)
328
329RESULT_COUNT=0
330RESULT_SIZE=0
331
332for dir_pattern in "${DEV_DIRS[@]}"; do
333 run_cmd "find $(dirname $dir_pattern 2>/dev/null)/* -maxdepth 3 -name 'result' -o -name 'result-*' 2>/dev/null" | while read -r link; do
334 if [ -L "$link" ]; then
335 target=$(run_cmd "readlink '$link' 2>/dev/null" || echo "(broken)")
336 age=$(run_cmd "stat -c %y '$link' 2>/dev/null | cut -d' ' -f1" || echo "unknown")
337 size=$(run_cmd "du -sh '$link' 2>/dev/null | cut -f1" || echo "?")
338
339 if [[ "$target" == /nix/store/* ]]; then
340 echo -e " ${YELLOW}$age${NC} [$size]: $link"
341 RESULT_COUNT=$((RESULT_COUNT + 1))
342 fi
343 fi
344 done
345done
346
347if [ "$RESULT_COUNT" -eq 0 ]; then
348 echo -e " ${GREEN}✓ No result symlinks found${NC}"
349else
350 echo ""
351 echo -e "${YELLOW}Found $RESULT_COUNT result symlink(s)${NC}"
352 echo "These prevent garbage collection. Remove with: rm result*"
353fi
354echo ""
355
356# 9. Broken/Dead Symlinks
357echo -e "${BOLD}${BLUE}9. Broken Symlinks (Dead GC Roots)${NC}"
358echo "---"
359echo "Checking for symlinks pointing to deleted store paths..."
360echo ""
361
362# Run the entire broken link check in a single remote command to avoid nested run_cmd calls
363BROKEN_LINKS=$(run_cmd 'bash -c "
364BROKEN_COUNT=0
365for root_dir in /nix/var/nix/gcroots /nix/var/nix/profiles; do
366 if [ -d \"\$root_dir\" ]; then
367 find \"\$root_dir\" -type l 2>/dev/null | while read -r link; do
368 if ! test -e \"\$link\" 2>/dev/null; then
369 target=\$(readlink \"\$link\" 2>/dev/null || echo \"(broken)\")
370 if [[ \"\$target\" == /nix/store/* ]]; then
371 echo \"BROKEN:\$link:\$target\"
372 BROKEN_COUNT=\$((BROKEN_COUNT + 1))
373 fi
374 fi
375 done
376 fi
377done
378"' || echo "")
379
380if [ -n "$BROKEN_LINKS" ]; then
381 echo "$BROKEN_LINKS" | while IFS=: read -r prefix link target; do
382 if [ "$prefix" = "BROKEN" ]; then
383 echo -e " ${RED}✗${NC} $link -> $target"
384 fi
385 done
386else
387 echo -e " ${GREEN}✓ No broken symlinks found in GC roots${NC}"
388fi
389echo ""
390
391# 10. Home-Manager Generations
392echo -e "${BOLD}${BLUE}10. Home-Manager Generations${NC}"
393echo "---"
394
395HM_PROFILE_DIRS=$(run_cmd "find /nix/var/nix/profiles/per-user -name 'home-manager*' -type l 2>/dev/null | sed 's/-[0-9]*-link$//' | sort -u" || echo "")
396
397if [ -n "$HM_PROFILE_DIRS" ]; then
398 echo "$HM_PROFILE_DIRS" | while read -r profile_base; do
399 user=$(basename $(dirname "$profile_base"))
400 gen_count=$(run_cmd "ls -1 ${profile_base}-*-link 2>/dev/null | wc -l" || echo "0")
401
402 if [ "$gen_count" -gt 0 ]; then
403 echo -e "${YELLOW}User $user:${NC} $gen_count generation(s)"
404
405 # Show oldest and newest
406 oldest=$(run_cmd "ls -lt ${profile_base}-*-link 2>/dev/null | tail -1" || echo "")
407 newest=$(run_cmd "ls -lt ${profile_base}-*-link 2>/dev/null | head -1" || echo "")
408
409 if [ -n "$oldest" ]; then
410 oldest_date=$(echo "$oldest" | awk '{print $6, $7, $8}')
411 echo " Oldest: $oldest_date"
412 fi
413 fi
414 done
415else
416 # Check alternative location
417 HM_STATE=$(run_cmd "find /home -path '*/.local/state/nix/profiles/home-manager*' -type l 2>/dev/null | head -1" || echo "")
418 if [ -n "$HM_STATE" ]; then
419 gen_count=$(run_cmd "ls -1 $(dirname $HM_STATE)/home-manager-*-link 2>/dev/null | wc -l" || echo "0")
420 echo -e "${YELLOW}Found $gen_count home-manager generation(s)${NC}"
421 else
422 echo " No home-manager generations found"
423 fi
424fi
425echo ""
426
427# 11. Store Hardlink Statistics & Optimization Status
428echo -e "${BOLD}${BLUE}11. Store Optimization Status${NC}"
429echo "---"
430
431# Check if nix-store --optimize has been run
432echo "Checking for potential space savings from deduplication..."
433echo ""
434
435STORE_SIZE_BYTES=$(run_cmd "timeout 30 du -sb /nix/store 2>/dev/null | cut -f1" || true)
436STORE_SIZE_BYTES=${STORE_SIZE_BYTES:-0}
437STORE_SIZE_HUMAN=$(run_cmd "timeout 30 du -sh /nix/store 2>/dev/null | cut -f1" || true)
438STORE_SIZE_HUMAN=${STORE_SIZE_HUMAN:-unknown}
439# If bytes calculation failed/timed out, estimate from human-readable size
440if [ "$STORE_SIZE_BYTES" = "0" ] && [ "$STORE_SIZE_HUMAN" != "unknown" ]; then
441 # Convert human-readable (e.g., "6.9G") to bytes estimate
442 STORE_SIZE_BYTES=$(echo "$STORE_SIZE_HUMAN" | awk '{
443 val=$1; unit=substr($0, length($0));
444 if (unit=="T") print int(val * 1024 * 1024 * 1024 * 1024);
445 else if (unit=="G") print int(val * 1024 * 1024 * 1024);
446 else if (unit=="M") print int(val * 1024 * 1024);
447 else print int(val)
448 }')
449fi
450
451# Sample check: look at hardlink counts
452SAMPLE_SIZE=100
453SINGLE_LINK_COUNT=$(run_cmd "find /nix/store -type f 2>/dev/null | head -$SAMPLE_SIZE | xargs stat -c %h 2>/dev/null | grep -c '^1$'" || echo "0")
454# Trim whitespace and newlines
455SINGLE_LINK_COUNT=$(echo "$SINGLE_LINK_COUNT" | tr -d '\n\r ' | head -c 10)
456SINGLE_LINK_COUNT=${SINGLE_LINK_COUNT:-0} # Default to 0 if empty
457
458if [ "$SINGLE_LINK_COUNT" -gt 50 ] 2>/dev/null; then
459 OPTIMIZATION_POTENTIAL="High"
460 echo -e " ${YELLOW}⚠ Optimization status: Not optimized${NC}"
461 echo " Store size: $STORE_SIZE_HUMAN"
462 echo " Sample check: $SINGLE_LINK_COUNT/$SAMPLE_SIZE files have single links"
463 echo ""
464 echo -e " ${CYAN}Run 'nix-store --optimize' to deduplicate identical files${NC}"
465 echo " Potential savings: 5-20% of store size (~$(echo "$STORE_SIZE_BYTES * 0.10 / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "?")GB estimated)"
466elif [ "$SINGLE_LINK_COUNT" -gt 20 ] 2>/dev/null; then
467 echo -e " ${YELLOW}Optimization status: Partially optimized${NC}"
468 echo " Store size: $STORE_SIZE_HUMAN"
469 echo " Some optimization possible"
470else
471 echo -e " ${GREEN}✓ Optimization status: Well optimized${NC}"
472 echo " Store size: $STORE_SIZE_HUMAN"
473 echo " Most files are already deduplicated"
474fi
475echo ""
476
477# 12. Nix Database Size
478echo -e "${BOLD}${BLUE}12. Nix Database Size${NC}"
479echo "---"
480
481DB_SIZE=$(run_cmd "du -sh /nix/var/nix/db 2>/dev/null | cut -f1" || echo "unknown")
482DB_SIZE_BYTES=$(run_cmd "du -sb /nix/var/nix/db 2>/dev/null | cut -f1" || echo "0")
483
484echo "Database size: $DB_SIZE"
485
486# Warn if database is >1GB
487if [ "$DB_SIZE_BYTES" -gt 1073741824 ] 2>/dev/null; then
488 echo -e "${YELLOW}⚠ Database is large (>1GB)${NC}"
489 echo " Consider running: nix-store --verify --check-contents"
490else
491 echo -e "${GREEN}✓ Database size is healthy${NC}"
492fi
493echo ""
494
495# 13. Nix Cache Size
496echo -e "${BOLD}${BLUE}13. Nix Cache Size${NC}"
497echo "---"
498
499CACHE_LOCATIONS=(
500 "/home/*/.cache/nix"
501 "/root/.cache/nix"
502)
503
504TOTAL_CACHE_SIZE=0
505for cache_pattern in "${CACHE_LOCATIONS[@]}"; do
506 # Find nix cache directories - use || true to avoid pipefail issues
507 run_cmd "shopt -s nullglob; find $(dirname $cache_pattern 2>/dev/null)/* -maxdepth 2 -type d -name nix 2>/dev/null; true" | while read -r cache_dir; do
508 if [ -n "$cache_dir" ] && [ -d "$cache_dir" ]; then
509 size=$(run_cmd "du -sh '$cache_dir' 2>/dev/null | cut -f1" || echo "0")
510 user=$(echo "$cache_dir" | cut -d'/' -f3)
511 echo " $user: $size ($cache_dir)"
512 fi
513 done
514done
515echo ""
516
517# 14. Estimated Reclaimable Space (using nix-du if available)
518echo -e "${BOLD}${BLUE}14. Estimated Reclaimable Space${NC}"
519echo "---"
520
521# Check if nix-du is available
522# Note: Check locally (not via run_cmd) since nix-du must be installed where script runs
523HAS_NIX_DU=0
524command -v nix-du >/dev/null 2>&1 && HAS_NIX_DU=1 || true
525
526if [ "$HAS_NIX_DU" -eq 1 ]; then
527 echo "Using nix-du for detailed analysis (packages >50MB)..."
528 echo ""
529
530 # Use nix-du with size filter to make it faster, extract text summary
531 run_cmd "timeout 60 nix-du -s=50M 2>/dev/null" | head -100 || echo " (nix-du timed out or unavailable)"
532
533 echo ""
534 echo "For full visualization: nix-du | dot -Tsvg > /tmp/store-analysis.svg"
535 echo ""
536 echo "Basic estimation for comparison:"
537 echo ""
538fi
539
540# Always show basic estimation too
541if [ "$HAS_NIX_DU" -eq 0 ]; then
542 echo "nix-du not found (install with: nix-shell -p nix-du)"
543 echo ""
544fi
545
546# Basic estimation: total store - current system closure
547# Reuse STORE_SIZE_BYTES from section 11 to avoid slow du command
548CURRENT_CLOSURE=$(run_cmd "timeout 30 nix path-info -rS /run/current-system 2>/dev/null | tail -1 | awk '{print \$2}'" || true)
549CURRENT_CLOSURE=${CURRENT_CLOSURE:-0}
550STORE_SIZE=${STORE_SIZE_BYTES:-0} # Already calculated in section 11, default to 0 if empty
551# If STORE_SIZE is still 0, recalculate from section 11's human-readable value
552if [ "$STORE_SIZE" = "0" ] && [ -n "$STORE_SIZE_HUMAN" ] && [ "$STORE_SIZE_HUMAN" != "unknown" ]; then
553 # Convert human-readable (e.g., "117G") to bytes estimate
554 STORE_SIZE=$(echo "$STORE_SIZE_HUMAN" | sed 's/G$//' | awk '{print int($1 * 1024 * 1024 * 1024)}')
555fi
556
557# Clean values
558STORE_SIZE=$(echo "$STORE_SIZE" | tr -d '\n\r ' | head -c 20)
559CURRENT_CLOSURE=$(echo "$CURRENT_CLOSURE" | tr -d '\n\r ' | head -c 20)
560STORE_SIZE=${STORE_SIZE:-0}
561CURRENT_CLOSURE=${CURRENT_CLOSURE:-0}
562
563if [ "$STORE_SIZE" -gt 0 ] 2>/dev/null && [ "$CURRENT_CLOSURE" -gt 0 ] 2>/dev/null; then
564 RECLAIMABLE=$((STORE_SIZE - CURRENT_CLOSURE))
565 RECLAIMABLE_GB=$(awk "BEGIN {printf \"%.1f\", $RECLAIMABLE / 1024 / 1024 / 1024}")
566 STORE_GB=$(awk "BEGIN {printf \"%.1f\", $STORE_SIZE / 1024 / 1024 / 1024}")
567 CLOSURE_GB=$(awk "BEGIN {printf \"%.1f\", $CURRENT_CLOSURE / 1024 / 1024 / 1024}")
568
569 echo -e " Store size: ${YELLOW}${STORE_GB}GB${NC}"
570 echo -e " Current system: ${CYAN}${CLOSURE_GB}GB${NC}"
571 echo -e " Potentially reclaimable: ${GREEN}~${RECLAIMABLE_GB}GB${NC}"
572 echo ""
573 echo " Note: This is a rough estimate. Running GC may reclaim less."
574 if [ "$HAS_NIX_DU" -eq 0 ]; then
575 echo " Install nix-du for accurate analysis: nix-shell -p nix-du"
576 fi
577else
578 echo " Unable to calculate (missing nix path-info)"
579fi
580echo ""
581
582# 15. Generate cleanup suggestions
583echo -e "${BOLD}${BLUE}15. Cleanup Suggestions${NC}"
584echo "---"
585
586CLEANUP_SCRIPT="/tmp/nix-cleanup-${HOSTNAME//[^a-zA-Z0-9]/_}.sh"
587
588cat > "$CLEANUP_SCRIPT" << 'EOF'
589#!/usr/bin/env bash
590# Auto-generated Nix cleanup script
591set -euo pipefail
592
593echo "=== Nix Store Cleanup ==="
594echo ""
595echo "Current disk usage:"
596df -h / | grep -E "Filesystem|^/dev"
597echo ""
598
599# Track what we're doing
600CLEANED=0
601
602EOF
603
604# Add cleanup for old channels
605OLD_CHANNELS=$(run_cmd "find /nix/var/nix/profiles/per-user/ -name 'channels*' -type l -mtime +365 2>/dev/null" || echo "")
606if [ -n "$OLD_CHANNELS" ]; then
607 echo -e "${YELLOW}▸ Found old channel links (>1 year)${NC}"
608 cat >> "$CLEANUP_SCRIPT" << EOF
609# Remove old channels (>1 year)
610echo "Removing old channels..."
611$(echo "$OLD_CHANNELS" | while read -r link; do echo "rm -f '$link'"; done)
612CLEANED=1
613
614EOF
615fi
616
617# Add cleanup for old tmp/system links
618OLD_SYSTEM=$(run_cmd "find /home/*/tmp -name 'system' -type l -mtime +30 2>/dev/null" || echo "")
619if [ -n "$OLD_SYSTEM" ]; then
620 echo -e "${YELLOW}▸ Found old system symlinks in ~/tmp${NC}"
621 cat >> "$CLEANUP_SCRIPT" << EOF
622# Remove old system symlinks
623echo "Removing old system symlinks..."
624$(echo "$OLD_SYSTEM" | while read -r link; do echo "rm -f '$link'"; done)
625CLEANED=1
626
627EOF
628fi
629
630# Add cleanup for direnv cache
631DIRENV_CACHE=$(run_cmd "shopt -s nullglob; find /home/*/.cache/direnv -type d -maxdepth 1 2>/dev/null | head -1; true" || echo "")
632if [ -n "$DIRENV_CACHE" ]; then
633 echo -e "${YELLOW}▸ Found direnv cache${NC}"
634 cat >> "$CLEANUP_SCRIPT" << EOF
635# Clean direnv cache
636echo "Cleaning direnv cache..."
637rm -rf /home/*/.cache/direnv
638CLEANED=1
639
640EOF
641fi
642
643# Add cleanup for old profiles
644OLD_PROFILES=$(run_cmd "shopt -s nullglob; find /nix/var/nix/profiles/per-user/*/profile-* -type l -mtime +90 2>/dev/null; true" || echo "")
645if [ -n "$OLD_PROFILES" ]; then
646 echo -e "${YELLOW}▸ Found old profile links (>90 days)${NC}"
647 cat >> "$CLEANUP_SCRIPT" << EOF
648# Remove old profile generations
649echo "Removing old profile generations..."
650$(echo "$OLD_PROFILES" | while read -r link; do echo "rm -f '$link'"; done)
651CLEANED=1
652
653EOF
654fi
655
656# Add cleanup for result symlinks
657RESULT_LINKS=$(run_cmd "shopt -s nullglob; find /home/*/src /home/*/projects /home/*/code /home/*/work /home/*/dev -maxdepth 3 -name 'result' -o -name 'result-*' 2>/dev/null | head -20; true" || echo "")
658if [ -n "$RESULT_LINKS" ]; then
659 echo -e "${YELLOW}▸ Found result symlinks in development directories${NC}"
660 cat >> "$CLEANUP_SCRIPT" << EOF
661# Remove result symlinks
662echo "Removing result symlinks..."
663$(echo "$RESULT_LINKS" | while read -r link; do echo "rm -f '$link'"; done)
664CLEANED=1
665
666EOF
667fi
668
669# Add cleanup for broken symlinks
670BROKEN_LINKS=$(run_cmd "find /nix/var/nix/gcroots /home -type l 2>/dev/null | while read link; do test -e \"\$link\" || echo \"\$link\"; done | head -10" || echo "")
671if [ -n "$BROKEN_LINKS" ]; then
672 echo -e "${YELLOW}▸ Found broken symlinks${NC}"
673 cat >> "$CLEANUP_SCRIPT" << EOF
674# Remove broken symlinks
675echo "Removing broken symlinks..."
676$(echo "$BROKEN_LINKS" | while read -r link; do echo "rm -f '$link'"; done)
677CLEANED=1
678
679EOF
680fi
681
682# Always add GC commands
683cat >> "$CLEANUP_SCRIPT" << 'EOF'
684# Run garbage collection
685if [ "$CLEANED" -eq 1 ]; then
686 echo ""
687 echo "Running garbage collection..."
688 nix-collect-garbage --delete-older-than 7d
689
690 echo ""
691 echo "Running hard GC..."
692 nix-store --gc
693
694 echo ""
695 echo "Optimizing store (deduplication)..."
696 nix-store --optimize
697else
698 echo "No old roots found to clean. Running standard GC..."
699 nix-collect-garbage --delete-older-than 30d
700 nix-store --gc
701fi
702
703echo ""
704echo "=== Cleanup Complete ==="
705echo ""
706echo "Disk usage after cleanup:"
707df -h / | grep -E "Filesystem|^/dev"
708echo ""
709echo "Nix store size:"
710du -sh /nix/store
711EOF
712
713chmod +x "$CLEANUP_SCRIPT"
714
715echo -e "${GREEN}✓ Generated cleanup script: ${CLEANUP_SCRIPT}${NC}"
716echo ""
717echo "Review and run with:"
718if [ -n "$TARGET" ]; then
719 echo -e " ${CYAN}scp $CLEANUP_SCRIPT $TARGET:/tmp/${NC}"
720 echo -e " ${CYAN}ssh $TARGET 'sudo bash /tmp/$(basename $CLEANUP_SCRIPT)'${NC}"
721else
722 echo -e " ${CYAN}sudo $CLEANUP_SCRIPT${NC}"
723fi
724echo ""
725
726# 16. Summary
727echo -e "${BOLD}${BLUE}16. Summary${NC}"
728echo "---"
729
730DISK_PCT=$(run_cmd "df / | tail -1 | awk '{print \$5}'" | tr -d '%')
731DISK_PCT=${DISK_PCT:-0} # Default to 0 if empty
732STORE_SIZE=$(run_cmd "du -sh /nix/store 2>/dev/null | cut -f1" || echo "unknown")
733
734echo -e "Hostname: ${CYAN}${HOSTNAME}${NC}"
735echo -e "Disk usage: ${YELLOW}${DISK_PCT}%${NC}"
736echo -e "Nix store size: ${YELLOW}${STORE_SIZE}${NC}"
737echo -e "Generations: ${YELLOW}${GEN_COUNT}${NC}"
738echo ""
739
740if [ "$DISK_PCT" -gt 80 ] 2>/dev/null; then
741 echo -e "${RED}⚠ WARNING: Disk usage is high (>${DISK_PCT}%)${NC}"
742elif [ "$DISK_PCT" -gt 70 ] 2>/dev/null; then
743 echo -e "${YELLOW}⚠ NOTICE: Disk usage is elevated (${DISK_PCT}%)${NC}"
744else
745 echo -e "${GREEN}✓ Disk usage is healthy (${DISK_PCT}%)${NC}"
746fi
747echo ""
748
749echo -e "${BOLD}Next steps:${NC}"
750echo "1. Review the cleanup script generated above"
751echo "2. Run the cleanup script to remove old GC roots"
752echo "3. Consider setting up automated maintenance"
753echo ""