One day you update your machine, reboot, and something is wrong. If you run NixOS, recovery can be painless: reboot again, pick the previous generation from the boot menu, and everything works.
The harder part comes afterwards. The boot menu says “Generation 677”. The Git history says “gnome: patch nautilus”. Unless you recorded the connection, the working system doesn’t tell you which commit to return to.
You can guess from timestamps, until you rebuild twice in one hour, build without committing, or commit without rebuilding. And you’ll be guessing on a machine you’ve just rolled back because something is broken.
It can be worse. Several nixos-rebuild switch runs may appear to work, only for the next reboot to fail, because a switch replaces services and userspace while the new kernel and initrd wait for a boot. If you committed only the final configuration, the earlier state you now need may never have existed as a commit. Nix still has a generation you can boot. Git may have no matching source.
How much this matters depends on the size of your configuration. Twenty lines can be retyped from memory on a bad evening. Mine stopped being one file a long time ago, and the state that built a given generation isn’t something I could reconstruct by remembering it.
NixOS already provides the option to record the connection. Getting it right takes a trustworthy revision, a way to find it again, and a decision about what happens when Git history changes. That last part is where I spent most of my time, and it ended with me deleting the blocking hook.
Recording the revision
In a NixOS module where the flake’s self is in scope:
system.configurationRevision = self.rev;Then nixos-version --configuration-revision gives you the commit hash, and if the commit still exists you can check it out and inspect the source that built the system. This is the approach in Eelco Dolstra’s post on managing NixOS with flakes.
Keep flake.lock committed too. The revision and the lock file identify the configuration and its inputs. They don’t guarantee that a future rebuild succeeds or produces the same result: inputs have to remain available, and overrides or impure dependencies can change the build without changing the recorded revision.
What the dirty fallback gives up
For a Git flake, self.rev is absent when the tree is dirty, so the assignment above fails. A common fallback is:
system.configurationRevision = self.rev or self.dirtyRev or null;This allows generations without an exact source commit. self.dirtyRev is the base hash with a -dirty suffix, so you can tell that the build was dirty, but not what the uncommitted changes were. Checking out the base commit won’t recover them.
That’s a reasonable policy for experiments. For generations I expect to recover through Git, I want a committed source state. That includes files Git doesn’t track yet: flakes normally omit them, so commit everything you intend to build.
Refusing dirty builds
The first check lives in a wrapper around the rebuild command. Mine are nxrb and nxrs, and the check gives an early, readable error:
#!/usr/bin/env bash
cd /path/to/your/nix/config || exit 1
worktree_status=$(git status --porcelain=v1 --untracked-files=normal) || exit 1
if [ -n "$worktree_status" ]; then
echo "ERROR: /path/to/your/nix/config has uncommitted or untracked files." >&2
git status --short >&2
exit 1
fi
exec nixos-rebuild switch --flake /path/to/your/nix/config --sudo "$@"A simpler git diff --quiet HEAD isn’t enough: it ignores untracked files and misses a staged change whose working file has been changed back. git status covers both the index and the worktree, and the wrapper deliberately rejects untracked files too.
The wrapper only covers commands routed through it. Anything that rebuilds another way, down to a forgotten alias older than the wrapper, bypasses it. So the configuration also requires a revision, with an explicit escape hatch:
system.configurationRevision =
self.rev or (
if builtins.getEnv "NIXOS_ALLOW_DIRTY" != "" then
self.dirtyRev or "dirty"
else
throw "Refusing to build without a clean Git revision."
);The throw stops evaluation with a message when the value is required, which improves on the error a bare self.rev gives. It’s not a security boundary: nothing stops someone changing the configuration or deploying an already built system.
Under pure evaluation builtins.getEnv returns an empty string, so the escape hatch needs both the variable and --impure:
dirty tree, normal eval error: Refusing to build without a clean Git revision.
NIXOS_ALLOW_DIRTY=1, still pure error: Refusing to build without a clean Git revision.
NIXOS_ALLOW_DIRTY=1 --impure abc1234...-dirtyAn intentional dirty build also has to bypass the wrapper’s check. It stays marked dirty, and its uncommitted changes need a separate snapshot if they’re to be recovered.
Putting the revision in the boot menu
Putting the short revision in system.nixos.label replaces the default label:
system.nixos.label = self.shortRev;That loses the nixpkgs version everywhere the label is shown, including the boot menu and the version column of nixos-rebuild list-generations. system.nixos.tags keeps it:
system.nixos.tags = [ (self.shortRev or self.dirtyShortRev or "dirty") ];The default label joins the sorted tags and the version with hyphens, giving something like abc1234-26.05.20260903.a5cc6f2. See the NixOS label module.
Finding the commit behind a generation
nixos-rebuild list-generations has a Configuration Revision column. With the Kernel, Specialisation and Current columns omitted:
Generation Build-date NixOS version Configuration Revision
678 2026-09-05 20:26:00 71130bc-26.05.20260903.a5cc6f2 71130bc3004f5276e54253726c91403034cd6408
677 2026-09-05 19:22:30 d43d987-26.05.20260903.a5cc6f2 d43d987dd94b4706f4de7fcd2fe025de9a468081For a retained generation, use its own version command:
/nix/var/nix/profiles/system-677-link/sw/bin/nixos-version --json--configuration-revision instead of --json gives just the hash. It’s the same command nixos-rebuild list-generations runs to fill its column.
Keeping the commit alive
Recording the revision says nothing about whether the commit stays available. An amend, rebase or reset --hard can move the branch away from it, and once no ref or reflog reaches the old object, Git GC can remove it. The generation then records a hash that no longer resolves.
Give each retained generation a ref:
refs/generations/laptop/677 -> d43d987dd94b4706f4de7fcd2fe025de9a468081For an existing generation, run this in the configuration repository, substituting the host and generation:
rev=$(/nix/var/nix/profiles/system-677-link/sw/bin/nixos-version --configuration-revision) &&
git update-ref refs/generations/laptop/677 "$rev"A ref keeps everything it reaches safe from GC: the commit, its ancestors, their trees and blobs. A surviving generation therefore protects older commits even after their own pins are gone.
Running that command by hand after every rebuild won’t last. This script does it for every generation on disk:
#!/usr/bin/env bash
set -euo pipefail
cd /path/to/your/nix/config
host=$(hostname)
shopt -s nullglob
links=(/nix/var/nix/profiles/system-*-link)
shopt -u nullglob
if [ ${#links[@]} -eq 0 ]; then
echo "gen-pin: no generations found, leaving pins alone" >&2
exit 1
fi
# Every generation on disk goes in, even one whose revision can't be read:
# unreadable isn't absent, and its pin stays.
declare -A live=()
for link in "${links[@]}"; do
gen=${link##*/system-}
gen=${gen%-link}
live[$gen]=$("$link/sw/bin/nixos-version" --configuration-revision 2>/dev/null) || live[$gen]=""
done
for gen in "${!live[@]}"; do
rev=${live[$gen]}
case $rev in "" | *dirty) continue ;; esac
if git cat-file -e "$rev^{commit}" 2>/dev/null; then
git update-ref "refs/generations/$host/$gen" "$rev"
else
echo "gen-pin: generation $gen was built from $rev, which isn't here" >&2
fi
done
git for-each-ref --format='%(refname)' "refs/generations/$host/" |
while read -r ref; do
[ -n "${live[${ref##*/}]+x}" ] || git update-ref -d "$ref"
doneIn the rebuild wrapper, replace the exec line with nixos-rebuild switch --flake /path/to/your/nix/config --sudo "$@" && gen-pin, so a failed rebuild still exits with an error. Run it after each garbage collection too, as yourself, not from the collection service that runs as root. Cleanup follows one rule: unreadable isn’t absent. If it finds no generations the script deletes nothing, and a generation whose revision can’t be read keeps its pin.
Running after the build leaves a new commit unpinned until the script next runs, normally a few minutes, and the reflog covers that gap: by default Git keeps a commit dropped from a branch for 30 days. A scheduled collection that doesn’t run the script leaves stale pins, which only hold a commit until the next rebuild releases it.
Trying to block rewrites with a hook
Preserving the commit wasn’t enough for me. I wanted Git to refuse a rewrite that would drop it from the branch, so a stray amend would bounce off with a clear message.
The reference-transaction hook can veto ref transactions: on Git 2.54, a non-zero exit in preparing or prepared aborts the transaction. I built a blocker on it and ran into four problems:
- A tracked hook can disappear during reset. I’d used
core.hooksPath = .githooksto keep the hook under version control. Resetting to a commit older than that directory removes the hook before the ref update reaches it. Installing the hook outside the worktree, with the path override removed, avoids that at the price of an installation step a clone doesn’t carry. - The ref name changes between hook states. In my tests an amend reached
preparingwith the input namingHEAD, and only the later stages namedrefs/heads/main. Filtering onrefs/heads/*silently skips the early event. pack-refslooks like deletion. With the files backend, packing refs removes their loose copies, and in my tests those events had the same hook input as a real deletion. Rejecting every apparent deletion of a pin broke routine maintenance.reset --hardchanges files before the veto. This ended the idea. By the time the hook rejected the branch update, the index and worktree had already moved to the reset target. The branch stayed put, the files didn’t, and they sat staged against the unchanged branch, so a later commit would record the rollback. In testing, a subsequent amend picked up the rolled-back content, and it took the reflog to recover it. Git has nopre-resethook that would make the refusal atomic, andGIT_REFLOG_ACTIONisn’t a reliable way to tell which command is running.
A partial repair exists: git read-tree -u --reset <old-commit> moves the index and worktree back without touching refs. But it runs after the files have already moved, so it can’t bring back overwritten uncommitted work.
Can’t block properly? Then don’t
The property I needed was that a built commit stays recoverable. I tested the pins in a disposable repository by moving the branch away, expiring the reflogs and forcing collection:
# Destructive recovery test: disposable repository only.
git reflog expire --expire=now --expire-unreachable=now --all
git gc --prune=now --aggressiveThe pinned commit survived, and recovering a branch from it doesn’t touch the worktree:
git branch recovered-677 refs/generations/laptop/677The blocker wanted more: that the commit stays in the branch’s ancestry. The hook can’t deliver that without losing the worktree. The other place for the refusal is a separate bare repository that rejects non-fast-forward pushes, which leaves your files alone but never sees a purely local reset. Neither closes the requirement. I’d be glad to be shown an approach that does.
So I kept the pins and removed the blocker. Rewrites go through, and the pinned source remains available. I’m one person with one configuration repository, I amend rarely and have never rebased it, and the worst case that survives a rewrite is one command to recreate a branch. Rare isn’t never, and a guard that worked would be worth building. This one wouldn’t close the case, so I’d pay for the machinery and keep the exposure anyway.
Before automating a guard, ask whether it actually closes the case. Writing it is the cheap part. What you pay afterwards is everything it gets in the way of, plus a mechanism you have to remember a year later, weighed against what it really closes. Often enough a cheap guarantee, recoverable rather than prevented, is where the work should end.
Your arithmetic may come out differently. In a shared repository someone else force-pushes to, or under a rule that history is append-only, the accident gets likelier, and the bare repository closes that case at the remote in a way no local hook can. Then it’s worth building properly.
What can still be lost
Pins protect objects from Git GC while the refs exist in an intact repository. They don’t protect against deleting the refs, losing the repository or corrupting its storage, and an ordinary clone doesn’t copy the refs/generations/* namespace. Nor does releasing a pin collect anything by itself: the commit stays as long as any other ref or reflog still reaches it, and how long generations themselves are kept is your own cleanup policy.
For independent recovery, save the pinned history in another repository or a Git bundle. If only the ref disappeared, it can be recreated from the recorded hash. If the commit objects disappeared, you need that archive. A hash and a bootable system can’t reconstruct a deleted Git commit.