#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs"; import { join } from "node:path"; const STORE_PATH = /\/nix\/store\/[a-z0-9]{32}-[^\s'";]+/g; const STORE_HASH = /\/nix\/store\/[a-z0-9]{32}-/g; const MAX_OUTPUT = 64 * 1024 * 1024; const MAX_REFERENCE_DEPTH = 4; function run(command, args, options = {}) { try { return execFileSync(command, args, { encoding: "utf8", maxBuffer: MAX_OUTPUT, ...options, }).trim(); } catch (error) { if (error.stderr) process.stderr.write(error.stderr); throw new Error(`${command} ${args.join(" ")} failed`); } } function usage() { console.log("Usage: scripts/diff-nixos-config.js [--host NAME] [--home NAME] REF1 REF2\n\nCompare NixOS and Home Manager outputs. Builds /etc and native Home Manager outputs."); } function parseArgs(args) { const refs = []; const hosts = []; const homes = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--help" || arg === "-h") { usage(); process.exit(0); } if (arg === "--host") { if (!args[index + 1]) throw new Error("--host requires a name"); hosts.push(args[++index]); continue; } if (arg === "--home") { if (!args[index + 1]) throw new Error("--home requires a name"); homes.push(args[++index]); continue; } if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`); refs.push(arg); } if (refs.length !== 2) throw new Error("Specify exactly two Git references"); return { refs, hosts, homes }; } function normalize(text) { return text.replaceAll(STORE_HASH, "/nix/store/-"); } function paths(text) { return [...new Set(text.match(STORE_PATH) ?? [])]; } function entries(root, otherRoot, subdir = "etc") { const result = new Map(); // Walk the generated tree, but do not follow directory links into unrelated packages. function visit(directory, prefix, ancestors) { const resolvedDirectory = realpathSync(directory); if (ancestors.has(resolvedDirectory)) return; const nextAncestors = new Set([...ancestors, resolvedDirectory]); for (const name of readdirSync(directory)) { const relative = join(prefix, name); const file = join(directory, name); const stat = lstatSync(file); if (stat.isDirectory()) { visit(file, relative, nextAncestors); } else if (stat.isSymbolicLink()) { const target = realpathSync(file); const resolved = lstatSync(target); if (resolved.isDirectory()) { const counterpart = join(otherRoot, subdir, relative); try { if (realpathSync(counterpart) === target) continue; } catch { /* The other revision does not contain this path. */ } visit(file, relative, nextAncestors); } else if (resolved.isFile()) { result.set(relative, readFileSync(target)); } } else if (stat.isFile()) { result.set(relative, readFileSync(file)); } } } visit(join(root, subdir), "", new Set()); return result; } function changes(oldEntries, newEntries, prefix = "/etc") { const result = []; const label = (name) => prefix === "store:" ? `store: ${name}` : `${prefix}/${name}`; for (const name of [...new Set([...oldEntries.keys(), ...newEntries.keys()])].sort()) { const before = oldEntries.get(name); const after = newEntries.get(name); if (before?.equals?.(after) || before === after) continue; if (before === undefined || after === undefined) { result.push(`${before === undefined ? "+" : "-"} ${label(name)}`); continue; } const oldText = String(before); const newText = String(after); const kind = normalize(oldText) === normalize(newText) ? "store references" : "content"; result.push(`~ ${label(name)} (${kind})`); // Give the exact line changes for text files; keep large generated files readable. if (kind === "content" && !oldText.includes("\0") && !newText.includes("\0")) { const a = oldText.split("\n"); const b = newText.split("\n"); let start = 0; while (start < a.length && start < b.length && a[start] === b[start]) start += 1; let endA = a.length; let endB = b.length; while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA -= 1; endB -= 1; } const oldLines = a.slice(start, endA); const newLines = b.slice(start, endB); if (oldLines.length + newLines.length <= 12) { result.push(...oldLines.map((line) => ` - ${line}`)); result.push(...newLines.map((line) => ` + ${line}`)); } } } return result; } function referencedChanges(oldEntries, newEntries) { const result = []; const seen = new Set(); // Follow references through generated scripts to the changed package or config. function follow(before, after, name, depth) { if (depth === 0) return; const oldRefs = paths(String(before)); const newRefs = paths(String(after)); for (let index = 0; index < Math.min(oldRefs.length, newRefs.length); index += 1) { const oldRef = oldRefs[index]; const newRef = newRefs[index]; if (oldRef === newRef || normalize(oldRef) !== normalize(newRef)) continue; const pair = `${oldRef}\n${newRef}`; if (seen.has(pair)) continue; seen.add(pair); try { const oldStat = lstatSync(realpathSync(oldRef)); const newStat = lstatSync(realpathSync(newRef)); if (!oldStat.isFile() || !newStat.isFile()) continue; const oldContent = readFileSync(oldRef); const newContent = readFileSync(newRef); if (oldContent.equals(newContent)) continue; if (oldContent.includes(0) || newContent.includes(0)) { result.push(`~ ${name}: ${oldRef} -> ${newRef} (binary content)`); continue; } result.push(...changes(new Map([[oldRef, oldContent]]), new Map([[oldRef, newContent]]), "store:")); follow(oldContent, newContent, oldRef, depth - 1); } catch (error) { if (error.code !== "ENOENT") throw error; } } } for (const [name, before] of oldEntries) { const after = newEntries.get(name); if (!before || !after || before.equals?.(after)) continue; follow(before, after, name, MAX_REFERENCE_DEPTH); } return result; } function flake(root, rev) { return `git+file://${root}?rev=${rev}`; } function attrNames(url, attr) { return JSON.parse(run("nix", ["eval", "--json", "--apply", "builtins.attrNames", `${url}#${attr}`])); } function outputPath(url, attr) { return run("nix", ["eval", "--raw", `${url}#${attr}.outPath`]); } function buildOutput(url, attr) { return run("nix", ["build", "--no-link", "--print-out-paths", `${url}#${attr}`]).split("\n").at(-1); } function evalAttr(url, host, attr) { return JSON.parse(run("nix", ["eval", "--json", `${url}#nixosConfigurations.${host}.${attr}`])); } function buildEtc(url, host) { return buildOutput(url, `nixosConfigurations.${host}.config.system.build.etc`); } function compareHome(label, attrs, paths, systems, urls, localSystem) { const outputs = urls.map((url, index) => outputPath(url, attrs[index])); console.log(`\nHome Manager ${label}`); if (outputs[0] === outputs[1]) { console.log(` Outputs match: ${outputs[0]}`); return; } console.log(` Outputs differ: ${outputs[0]} -> ${outputs[1]}`); if (!systems.every((system) => system === localSystem) && !outputs.every(existsSync)) { console.log(" File comparison requires outputs built for the target platform."); return; } for (let index = 0; index < outputs.length; index += 1) { if (!existsSync(outputs[index])) buildOutput(urls[index], attrs[index]); } const oldFiles = entries(outputs[0], outputs[1], "home-files"); const newFiles = entries(outputs[1], outputs[0], "home-files"); const differences = changes(oldFiles, newFiles, "~"); console.log(differences.length ? differences.map((line) => ` ${line}`).join("\n") : " Home files match."); const activation = changes( new Map([["activate", readFileSync(join(outputs[0], "activate"))]]), new Map([["activate", readFileSync(join(outputs[1], "activate"))]]), "Home Manager", ); if (activation.length) console.log(activation.map((line) => ` ${line}`).join("\n")); const packagePaths = urls.map((url, index) => outputPath(url, paths[index])); for (let index = 0; index < packagePaths.length; index += 1) { if (!existsSync(packagePaths[index])) buildOutput(urls[index], paths[index]); } const packages = packagePaths.map(packageLinks); for (const name of [...new Set([...packages[0].keys(), ...packages[1].keys()])].sort()) { if (packages[0].get(name) !== packages[1].get(name)) { console.log(` Home path ${name}: ${packages[0].get(name) ?? ""} -> ${packages[1].get(name) ?? ""}`); } } } function packageLinks(systemPath) { const result = new Map(); for (const directory of ["bin", "sbin"]) { try { for (const name of readdirSync(join(systemPath, directory))) { const path = join(systemPath, directory, name); if (lstatSync(path).isSymbolicLink()) result.set(`${directory}/${name}`, readlinkSync(path)); } } catch (error) { if (error.code !== "ENOENT") throw error; } } return result; } function compareHost(host, urls) { const outputs = urls.map((url) => run("nix", ["eval", "--raw", `${url}#nixosConfigurations.${host}.config.system.build.toplevel.outPath`])); const systems = urls.map((url) => evalAttr(url, host, "config.system.build.toplevel.drvAttrs")); const oldBuild = systems[0].buildCommand; const newBuild = systems[1].buildCommand; console.log(`\n${host}`); if (outputs[0] === outputs[1]) { console.log(` System outputs match: ${outputs[0]}`); return; } console.log(` System outputs differ: ${outputs[0]} -> ${outputs[1]}`); const oldRefs = paths(oldBuild); const newRefs = paths(newBuild); for (let index = 0; index < Math.max(oldRefs.length, newRefs.length); index += 1) { if (oldRefs[index] === newRefs[index]) continue; console.log(` System input: ${oldRefs[index] ?? ""} -> ${newRefs[index] ?? ""}`); } const etc = urls.map((url) => buildEtc(url, host)); const systemPaths = [oldBuild, newBuild].map((script) => script.match(/\/nix\/store\/[a-z0-9]{32}-system-path/)?.[0]); if (systemPaths.every(Boolean)) { const oldLinks = packageLinks(systemPaths[0]); const newLinks = packageLinks(systemPaths[1]); for (const name of [...new Set([...oldLinks.keys(), ...newLinks.keys()])].sort()) { if (oldLinks.get(name) !== newLinks.get(name)) { console.log(` System path ${name}: ${oldLinks.get(name) ?? ""} -> ${newLinks.get(name) ?? ""}`); } } } const oldEntries = entries(etc[0], etc[1]); const newEntries = entries(etc[1], etc[0]); const differences = changes(oldEntries, newEntries); console.log(differences.length ? differences.map((line) => ` ${line}`).join("\n") : " /etc contents match."); const referenced = referencedChanges(oldEntries, newEntries); if (referenced.length) console.log(referenced.map((line) => ` ${line}`).join("\n")); } try { const { refs, hosts, homes } = parseArgs(process.argv.slice(2)); const root = run("git", ["rev-parse", "--show-toplevel"]); const revisions = refs.map((ref) => run("git", ["rev-parse", "--verify", `${ref}^{commit}`])); const urls = revisions.map((rev) => flake(root, rev)); const available = urls.map((url) => attrNames(url, "nixosConfigurations")); const selected = hosts.length ? hosts : available[0].filter((host) => available[1].includes(host)); const homeNames = urls.map((url) => attrNames(url, "homeConfigurations")); const selectedHomes = homes.length ? homes : homeNames[0].filter((name) => homeNames[1].includes(name)); const localSystem = run("nix", ["eval", "--impure", "--raw", "--expr", "builtins.currentSystem"]); console.log(`${refs[0]} (${revisions[0]}) -> ${refs[1]} (${revisions[1]})`); for (const host of selected) { if (!available.every((names) => names.includes(host))) throw new Error(`Host absent from a revision: ${host}`); compareHost(host, urls); const users = urls.map((url) => attrNames(url, `nixosConfigurations.${host}.config.home-manager.users`)); for (const user of users[0].filter((name) => users[1].includes(name))) { const attrs = urls.map(() => `nixosConfigurations.${host}.config.home-manager.users.${user}.home.activationPackage`); const paths = urls.map(() => `nixosConfigurations.${host}.config.home-manager.users.${user}.home.path`); const systems = urls.map((url) => run("nix", ["eval", "--raw", `${url}#nixosConfigurations.${host}.pkgs.stdenv.hostPlatform.system`])); compareHome(`${host}/${user}`, attrs, paths, systems, urls, localSystem); } } for (const name of selectedHomes) { if (!homeNames.every((names) => names.includes(name))) throw new Error(`Home absent from a revision: ${name}`); const attrs = urls.map(() => `homeConfigurations.${name}.activationPackage`); const paths = urls.map(() => `homeConfigurations.${name}.config.home.path`); const systems = urls.map((url) => run("nix", ["eval", "--raw", `${url}#homeConfigurations.${name}.pkgs.stdenv.hostPlatform.system`])); compareHome(name, attrs, paths, systems, urls, localSystem); } } catch (error) { console.error(error.message); process.exitCode = 1; }