Something went wrong. Try again.
Configuration for my NixOS based systems and Home Manager
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348#!/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/<hash>-");}
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) ?? "<none>"} -> ${packages[1].get(name) ?? "<none>"}`); } }}
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] ?? "<none>"} -> ${newRefs[index] ?? "<none>"}`); }
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) ?? "<none>"} -> ${newLinks.get(name) ?? "<none>"}`); } } } 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;}