#!/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 # 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 key files within it if [ -d "$old_path" ] && [ -d "$new_path" ]; then # Focus on important files for pattern in "activate" "etc/*" "*.conf" "*.fish" "*.service" "*.nix"; do while IFS= read -r file; 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 echo -e " ${YELLOW}$relpath:${NC}" diff -u "$old_file" "$file" 2>/dev/null | head -50 | tail -n +3 | sed 's/^/ /' || true fi fi done < <(find "$new_path" -type f -name "$pattern" 2>/dev/null | head -20) done # If it's a file, diff it directly elif [ -f "$old_path" ] && [ -f "$new_path" ]; then if file "$new_path" | grep -q "text"; then diff -u "$old_path" "$new_path" 2>/dev/null | head -50 | tail -n +3 | sed 's/^/ /' || true 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