<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Xkoder</title>
    <description>The latest articles on DEV Community by Xkoder (@ikskoder).</description>
    <link>https://dev.to/ikskoder</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4174620%2F9cfabee0-07f7-41d7-9142-d709ae2fef21.png</url>
      <title>DEV Community: Xkoder</title>
      <link>https://dev.to/ikskoder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ikskoder"/>
    <language>en</language>
    <item>
      <title>Pinning NixOS generations to the commits that built them</title>
      <dc:creator>Xkoder</dc:creator>
      <pubDate>Sun, 20 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ikskoder/pinning-nixos-generations-to-the-commits-that-built-them-1cbf</link>
      <guid>https://dev.to/ikskoder/pinning-nixos-generations-to-the-commits-that-built-them-1cbf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ikskoder.dev/blog/nixos-generations-git-commits/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, where you can also listen to a narrated version.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who this is for:&lt;/strong&gt; You keep your NixOS configuration in a Git repository and build it as a flake, and you want a generation in the boot menu to name the commit that built it. On a channel-based machine that edits &lt;code&gt;/etc/nixos/configuration.nix&lt;/code&gt; in place there's no commit to pin yet, and version control comes first.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;code&gt;system.configurationRevision&lt;/code&gt; to the flake's &lt;code&gt;self.rev&lt;/code&gt;, and every generation records the commit that built it.&lt;/li&gt;
&lt;li&gt;Put the short revision in &lt;code&gt;system.nixos.tags&lt;/code&gt;, so it shows up in the boot menu and in &lt;code&gt;nixos-rebuild list-generations&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Refuse to build from a dirty tree, in the rebuild wrapper and in the configuration itself: &lt;code&gt;self.dirtyRev&lt;/code&gt; records that the tree was dirty, not what was in it.&lt;/li&gt;
&lt;li&gt;Hold each generation's commit with a &lt;code&gt;refs/generations/&amp;lt;host&amp;gt;/&amp;lt;n&amp;gt;&lt;/code&gt; ref, so Git GC can't take the source of a system you can still boot. A short script keeps the refs in step with the generations.&lt;/li&gt;
&lt;li&gt;I wrote a hook to block history rewrites, then deleted it: its refusal lands after &lt;code&gt;reset --hard&lt;/code&gt; has already moved your files.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It can be worse. Several &lt;code&gt;nixos-rebuild switch&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recording the revision
&lt;/h2&gt;

&lt;p&gt;In a NixOS module where the flake's &lt;code&gt;self&lt;/code&gt; is in scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;configurationRevision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;nixos-version --configuration-revision&lt;/code&gt; 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 &lt;a href="https://www.tweag.io/blog/2020-07-31-nixos-flakes/" rel="noopener noreferrer"&gt;post on managing NixOS with flakes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;flake.lock&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the dirty fallback gives up
&lt;/h2&gt;

&lt;p&gt;For a Git flake, &lt;code&gt;self.rev&lt;/code&gt; is absent when the tree is dirty, so the assignment above fails. A common fallback is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;configurationRevision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;rev&lt;/span&gt; &lt;span class="nv"&gt;or&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;dirtyRev&lt;/span&gt; &lt;span class="nv"&gt;or&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows generations without an exact source commit. &lt;code&gt;self.dirtyRev&lt;/code&gt; is the base hash with a &lt;code&gt;-dirty&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusing dirty builds
&lt;/h2&gt;

&lt;p&gt;The first check lives in a wrapper around the rebuild command. Mine are &lt;code&gt;nxrb&lt;/code&gt; and &lt;code&gt;nxrs&lt;/code&gt;, and the check gives an early, readable error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/your/nix/config &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="nv"&gt;worktree_status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;v1 &lt;span class="nt"&gt;--untracked-files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;normal&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$worktree_status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: /path/to/your/nix/config has uncommitted or untracked files."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  git status &lt;span class="nt"&gt;--short&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;exec &lt;/span&gt;nixos-rebuild switch &lt;span class="nt"&gt;--flake&lt;/span&gt; /path/to/your/nix/config &lt;span class="nt"&gt;--sudo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A simpler &lt;code&gt;git diff --quiet HEAD&lt;/code&gt; isn't enough: it ignores untracked files and misses a staged change whose working file has been changed back. &lt;code&gt;git status&lt;/code&gt; covers both the index and the worktree, and the wrapper deliberately rejects untracked files too.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;configurationRevision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;rev&lt;/span&gt; &lt;span class="nv"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kr"&gt;builtins&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;getEnv&lt;/span&gt; &lt;span class="s2"&gt;"NIXOS_ALLOW_DIRTY"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
      &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;dirtyRev&lt;/span&gt; &lt;span class="nv"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;"dirty"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="kr"&gt;throw&lt;/span&gt; &lt;span class="s2"&gt;"Refusing to build without a clean Git revision."&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;throw&lt;/code&gt; stops evaluation with a message when the value is required, which improves on the error a bare &lt;code&gt;self.rev&lt;/code&gt; gives. It's not a security boundary: nothing stops someone changing the configuration or deploying an already built system.&lt;/p&gt;

&lt;p&gt;Under pure evaluation &lt;code&gt;builtins.getEnv&lt;/code&gt; returns an empty string, so the escape hatch needs both the variable and &lt;code&gt;--impure&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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...-dirty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting the revision in the boot menu
&lt;/h2&gt;

&lt;p&gt;Putting the short revision in &lt;code&gt;system.nixos.label&lt;/code&gt; replaces the default label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;nixos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;shortRev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That loses the nixpkgs version everywhere the label is shown, including the boot menu and the version column of &lt;code&gt;nixos-rebuild list-generations&lt;/code&gt;. &lt;code&gt;system.nixos.tags&lt;/code&gt; keeps it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;nixos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;shortRev&lt;/span&gt; &lt;span class="nv"&gt;or&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;dirtyShortRev&lt;/span&gt; &lt;span class="nv"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;"dirty"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default label joins the sorted tags and the version with hyphens, giving something like &lt;code&gt;abc1234-26.05.20260903.a5cc6f2&lt;/code&gt;. See the &lt;a href="https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/misc/label.nix" rel="noopener noreferrer"&gt;NixOS label module&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the commit behind a generation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nixos-rebuild list-generations&lt;/code&gt; has a &lt;strong&gt;Configuration Revision&lt;/strong&gt; column. With the Kernel, Specialisation and Current columns omitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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  d43d987dd94b4706f4de7fcd2fe025de9a468081
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a retained generation, use its own version command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/nix/var/nix/profiles/system-677-link/sw/bin/nixos-version &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--configuration-revision&lt;/code&gt; instead of &lt;code&gt;--json&lt;/code&gt; gives just the hash. It's the same command &lt;code&gt;nixos-rebuild list-generations&lt;/code&gt; runs to fill its column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the commit alive
&lt;/h2&gt;

&lt;p&gt;Recording the revision says nothing about whether the commit stays available. An amend, rebase or &lt;code&gt;reset --hard&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Give each retained generation a ref:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;refs/generations/laptop/677 -&amp;gt; d43d987dd94b4706f4de7fcd2fe025de9a468081
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an existing generation, run this in the configuration repository, substituting the host and generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;rev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;/nix/var/nix/profiles/system-677-link/sw/bin/nixos-version &lt;span class="nt"&gt;--configuration-revision&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  git update-ref refs/generations/laptop/677 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rev&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Running that command by hand after every rebuild won't last. This script does it for every generation on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/your/nix/config
&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; nullglob
&lt;span class="nv"&gt;links&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;/nix/var/nix/profiles/system-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;-link&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; nullglob
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;links&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"gen-pin: no generations found, leaving pins alone"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Every generation on disk goes in, even one whose revision can't be read:&lt;/span&gt;
&lt;span class="c"&gt;# unreadable isn't absent, and its pin stays.&lt;/span&gt;
&lt;span class="nb"&gt;declare&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nv"&gt;live&lt;/span&gt;&lt;span class="o"&gt;=()&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;link &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;links&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;gen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;link&lt;/span&gt;&lt;span class="p"&gt;##*/system-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  &lt;span class="nv"&gt;gen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;gen&lt;/span&gt;&lt;span class="p"&gt;%-link&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  live[&lt;span class="nv"&gt;$gen&lt;/span&gt;&lt;span class="o"&gt;]=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$link&lt;/span&gt;&lt;span class="s2"&gt;/sw/bin/nixos-version"&lt;/span&gt; &lt;span class="nt"&gt;--configuration-revision&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; live[&lt;span class="nv"&gt;$gen&lt;/span&gt;&lt;span class="o"&gt;]=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;done

for &lt;/span&gt;gen &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;!live[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;rev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;live&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$gen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$rev&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;dirty&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;git cat-file &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rev&lt;/span&gt;&lt;span class="s2"&gt;^{commit}"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;git update-ref &lt;span class="s2"&gt;"refs/generations/&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$gen&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rev&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"gen-pin: generation &lt;/span&gt;&lt;span class="nv"&gt;$gen&lt;/span&gt;&lt;span class="s2"&gt; was built from &lt;/span&gt;&lt;span class="nv"&gt;$rev&lt;/span&gt;&lt;span class="s2"&gt;, which isn't here"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="k"&gt;fi
done

&lt;/span&gt;git &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="nt"&gt;-each-ref&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%(refname)'&lt;/span&gt; &lt;span class="s2"&gt;"refs/generations/&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; |
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; ref&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;live&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;##*/&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]+x&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; git update-ref &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ref&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the rebuild wrapper, replace the &lt;code&gt;exec&lt;/code&gt; line with &lt;code&gt;nixos-rebuild switch --flake /path/to/your/nix/config --sudo "$@" &amp;amp;&amp;amp; gen-pin&lt;/code&gt;, 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: &lt;strong&gt;unreadable isn't absent&lt;/strong&gt;. If it finds no generations the script deletes nothing, and a generation whose revision can't be read keeps its pin.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying to block rewrites with a hook
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://git-scm.com/docs/githooks#_reference_transaction" rel="noopener noreferrer"&gt;&lt;code&gt;reference-transaction&lt;/code&gt; hook&lt;/a&gt; can veto ref transactions: on Git 2.54, a non-zero exit in &lt;code&gt;preparing&lt;/code&gt; or &lt;code&gt;prepared&lt;/code&gt; aborts the transaction. I built a blocker on it and ran into four problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A tracked hook can disappear during reset.&lt;/strong&gt; I'd used &lt;code&gt;core.hooksPath = .githooks&lt;/code&gt; to 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ref name changes between hook states.&lt;/strong&gt; In my tests an amend reached &lt;code&gt;preparing&lt;/code&gt; with the input naming &lt;code&gt;HEAD&lt;/code&gt;, and only the later stages named &lt;code&gt;refs/heads/main&lt;/code&gt;. Filtering on &lt;code&gt;refs/heads/*&lt;/code&gt; silently skips the early event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pack-refs&lt;/code&gt; looks like deletion.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;reset --hard&lt;/code&gt; changes files before the veto.&lt;/strong&gt; 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 no &lt;code&gt;pre-reset&lt;/code&gt; hook that would make the refusal atomic, and &lt;code&gt;GIT_REFLOG_ACTION&lt;/code&gt; isn't a reliable way to tell which command is running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A partial repair exists: &lt;code&gt;git read-tree -u --reset &amp;lt;old-commit&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can't block properly? Then don't
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Destructive recovery test: disposable repository only.&lt;/span&gt;
git reflog expire &lt;span class="nt"&gt;--expire&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;now &lt;span class="nt"&gt;--expire-unreachable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;now &lt;span class="nt"&gt;--all&lt;/span&gt;
git gc &lt;span class="nt"&gt;--prune&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;now &lt;span class="nt"&gt;--aggressive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pinned commit survived, and recovering a branch from it doesn't touch the worktree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git branch recovered-677 refs/generations/laptop/677
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can still be lost
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;refs/generations/*&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;For independent recovery, save the pinned history in another repository or a &lt;a href="https://git-scm.com/docs/git-bundle" rel="noopener noreferrer"&gt;Git bundle&lt;/a&gt;. 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.&lt;/p&gt;

</description>
      <category>nixos</category>
      <category>git</category>
      <category>nix</category>
    </item>
  </channel>
</rss>
