main
  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 ""