#!/usr/bin/env bash # Compare NixOS configurations between current state and HEAD # Shows what would change if you committed the current changes # # Requirements: nvd must be in PATH # Run inside `nix develop` or with direnv enabled set -euo pipefail # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[0;33m' NC='\033[0m' # No Color # Normalize nix store paths by replacing 32-char hashes with placeholder normalize_nix_paths() { sed -E 's|/nix/store/[a-z0-9]{32}-|/nix/store/HASH-|g' } # Filter diff output to remove hunks where only nix store hashes differ # Returns: filtered diff (empty if only hash changes), exit code 0 if real changes found filter_hash_only_diffs() { local diff_output="$1" local current_hunk="" local output="" local has_real_changes=false # Process line by line while IFS= read -r line || [ -n "$line" ]; do if [[ "$line" =~ ^@@ ]]; then # New hunk starts - process previous one if it exists if [ -n "$current_hunk" ]; then if hunk_has_real_changes "$current_hunk"; then output+="$current_hunk"$'\n' has_real_changes=true fi fi # Start new hunk current_hunk="$line"$'\n' else # Add line to current hunk current_hunk+="$line"$'\n' fi done <<< "$diff_output" # Process last hunk if [ -n "$current_hunk" ]; then if hunk_has_real_changes "$current_hunk"; then output+="$current_hunk" has_real_changes=true fi fi # Remove trailing newline output="${output%$'\n'}" if [ "$has_real_changes" = true ]; then echo "$output" return 0 else return 1 fi } # Check if a diff hunk has real changes (not just hash changes) hunk_has_real_changes() { local hunk="$1" # Use temp file to avoid bash here-string issues local temp_hunk=$(mktemp) printf '%s' "$hunk" > "$temp_hunk" local minus_lines=() local plus_lines=() # Extract - and + lines (skip @@ and context lines) while IFS= read -r line || [ -n "$line" ]; do if [[ "$line" =~ ^- && ! "$line" =~ ^--- ]]; then minus_lines+=("${line:1}") # Remove the - prefix elif [[ "$line" =~ ^\+ && ! "$line" =~ ^\+\+\+ ]]; then plus_lines+=("${line:1}") # Remove the + prefix fi done < "$temp_hunk" rm -f "$temp_hunk" # If counts don't match, there are structural changes if [ ${#minus_lines[@]} -ne ${#plus_lines[@]} ]; then return 0 # Has real changes fi # If no changes at all, skip if [ ${#minus_lines[@]} -eq 0 ]; then return 1 # No real changes fi # Compare each pair of lines after normalization for i in "${!minus_lines[@]}"; do local minus_norm=$(echo "${minus_lines[$i]}" | normalize_nix_paths) local plus_norm=$(echo "${plus_lines[$i]}" | normalize_nix_paths) if [ "$minus_norm" != "$plus_norm" ]; then return 0 # Has real changes fi done # All changes are hash-only return 1 } # Check for nvd if ! command -v nvd &> /dev/null; then echo "Error: nvd not found in PATH" echo "Run this script inside 'nix develop' or enable direnv" exit 1 fi # Parse flags verbose=false deep=false hosts_args=() while [[ $# -gt 0 ]]; do case $1 in -h|--help) echo "Usage: $0 [-v|--verbose] [-d|--deep] [HOST...]" echo "Compare NixOS configurations between working tree and HEAD" echo "" echo "Options:" echo " -v, --verbose Show detailed list of added/removed store paths" echo " -d, --deep Show content diffs of changed files (implies -v)" echo "" echo "Arguments:" echo " HOST One or more hostnames to compare (default: all)" echo "" echo "Examples:" echo " $0 # Compare all hosts (summary)" echo " $0 -v c1 # Compare c1 with path list" echo " $0 --deep c1 # Compare c1 with content diffs" echo " $0 c1 c2 c3 # Compare only c1, c2, c3" exit 0 ;; -v|--verbose) verbose=true shift ;; -d|--deep) deep=true verbose=true # deep implies verbose shift ;; *) hosts_args+=("$1") shift ;; esac done # Restore positional parameters set -- "${hosts_args[@]}" # Check if we're in a git repo if ! git rev-parse --git-dir > /dev/null 2>&1; then echo "Error: Not in a git repository" exit 1 fi # Check if there are any changes if git diff --quiet && git diff --cached --quiet; then echo "No changes detected between working tree and HEAD" exit 0 fi echo "Comparing configurations: current working tree vs HEAD" echo "=======================================================" echo # Get list of hosts to compare if [ $# -gt 0 ]; then # Use hosts provided as arguments hosts="$@" echo -e "${YELLOW}Comparing selected hosts: $hosts${NC}" else # Get all hosts from flake echo "Discovering all hosts from flake..." hosts=$(nix eval --raw .#deploy.nodes --apply 'nodes: builtins.concatStringsSep "\n" (builtins.attrNames nodes)' 2>/dev/null) if [ -z "$hosts" ]; then echo "Error: No hosts found in flake" exit 1 fi fi echo # Create temp worktree at HEAD worktree=$(mktemp -d) trap "git worktree remove --force '$worktree' &>/dev/null || true; rm -rf '$worktree'" EXIT echo "Creating temporary worktree at HEAD..." git worktree add --quiet --detach "$worktree" HEAD echo "Building and comparing configurations..." echo any_changes=false for host in $hosts; do echo -e "${BLUE}━━━ $host ━━━${NC}" # Build current (with uncommitted changes) echo -n " Building current... " if ! current=$(nix build --no-link --print-out-paths \ ".#nixosConfigurations.$host.config.system.build.toplevel" 2>/dev/null); then echo -e "${RED}FAILED${NC}" # Re-run to show error nix build --no-link ".#nixosConfigurations.$host.config.system.build.toplevel" 2>&1 | head -20 | sed 's/^/ /' continue fi echo "done" # Build HEAD echo -n " Building HEAD... " if ! head=$(nix build --no-link --print-out-paths \ "$worktree#nixosConfigurations.$host.config.system.build.toplevel" 2>/dev/null); then echo -e "${RED}FAILED${NC}" # Re-run to show error nix build --no-link "$worktree#nixosConfigurations.$host.config.system.build.toplevel" 2>&1 | head -20 | sed 's/^/ /' continue fi echo "done" # Compare if [ "$head" = "$current" ]; then echo -e " ${GREEN}✓ No changes${NC}" else any_changes=true echo -e " ${RED}⚠ Configuration changed${NC}" echo # Show nvd summary if ! nvd diff "$head" "$current" 2>&1; then echo -e " ${RED}(nvd diff failed - see error above)${NC}" fi # Show detailed closure diff if verbose if [ "$verbose" = true ]; then echo echo -e " ${YELLOW}Changed store paths:${NC}" # Get paths unique to HEAD and current head_only=$(comm -23 <(nix-store -q --requisites "$head" 2>/dev/null | sort) \ <(nix-store -q --requisites "$current" 2>/dev/null | sort)) current_only=$(comm -13 <(nix-store -q --requisites "$head" 2>/dev/null | sort) \ <(nix-store -q --requisites "$current" 2>/dev/null | sort)) # Count changes removed_count=$(echo "$head_only" | wc -l) added_count=$(echo "$current_only" | wc -l) echo -e " ${RED}Removed ($removed_count paths):${NC}" echo "$head_only" | head -10 | sed 's|^/nix/store/[^-]*-| - |' if [ "$removed_count" -gt 10 ]; then echo " ... and $((removed_count - 10)) more" fi echo echo -e " ${GREEN}Added ($added_count paths):${NC}" echo "$current_only" | head -10 | sed 's|^/nix/store/[^-]*-| - |' if [ "$added_count" -gt 10 ]; then echo " ... and $((added_count - 10)) more" fi # Show content diffs if deep mode if [ "$deep" = true ]; then echo echo -e " ${YELLOW}Content diffs of changed files:${NC}" # Extract basenames for matching declare -A head_paths while IFS= read -r path; do [ -z "$path" ] && continue basename="${path#/nix/store/[a-z0-9]*-}" head_paths["$basename"]="$path" done <<< "$head_only" # Find matching pairs and diff them matched=false while IFS= read -r path; do [ -z "$path" ] && continue basename="${path#/nix/store/[a-z0-9]*-}" # Check if we have a matching path in head if [ -n "${head_paths[$basename]:-}" ]; then old_path="${head_paths[$basename]}" new_path="$path" matched=true echo echo -e " ${BLUE}▸ $basename${NC}" # If it's a directory, diff all files within it if [ -d "$old_path" ] && [ -d "$new_path" ]; then # Count files to avoid processing huge directories file_count=$(find "$new_path" -maxdepth 3 -type f 2>/dev/null | wc -l) # Skip very large directories (e.g., system-path with 900+ files) if [ "$file_count" -gt 100 ]; then echo " (skipping directory with $file_count files - too large)" else # Diff all files in the directory for file in $(find "$new_path" -maxdepth 3 -type f 2>/dev/null); do [ -z "$file" ] && continue relpath="${file#$new_path/}" old_file="$old_path/$relpath" if [ -f "$old_file" ] && [ -f "$file" ]; then # Check if file is text if file "$file" | grep -q "text"; then # Get diff output diff_output=$(diff -u "$old_file" "$file" 2>/dev/null | head -50 | tail -n +3 || true) # Filter hash-only changes if [ -n "$diff_output" ]; then filtered_diff=$(filter_hash_only_diffs "$diff_output" || true) if [ -n "$filtered_diff" ]; then echo -e " ${YELLOW}$relpath:${NC}" echo "$filtered_diff" | sed 's/^/ /' fi fi fi fi done fi # If it's a file, diff it directly elif [ -f "$old_path" ] && [ -f "$new_path" ]; then if file "$new_path" | grep -q "text"; then # Get diff output diff_output=$(diff -u "$old_path" "$new_path" 2>/dev/null | head -50 | tail -n +3 || true) # Filter hash-only changes if [ -n "$diff_output" ]; then filtered_diff=$(filter_hash_only_diffs "$diff_output" || true) if [ -n "$filtered_diff" ]; then echo "$filtered_diff" | sed 's/^/ /' else echo " (only hash changes)" fi fi else echo " (binary file)" fi fi fi done <<< "$current_only" if [ "$matched" = false ]; then echo " (no matching paths found to compare)" fi fi fi fi echo done if [ "$any_changes" = false ]; then echo -e "${GREEN}✓ All configurations unchanged${NC}" else echo -e "${RED}⚠ Some configurations changed - review carefully before committing${NC}" fi