Files
alo-cluster/scripts/diff-configs.sh

375 lines
13 KiB
Bash
Executable File

#!/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