NixOSの世代に紐づくGitコミットを表示してどれが正常にブートするか分かり易くしました

背景

NixOSを使っていると、設定をビルドするたびにブートローダーに新しい世代(generation)が追加されます。

NixOSは設定をビルドする時にある程度の検査はしてくれるので、他のディストリビューションのように設定を壊してしまってシステムが起動しなくなるリスクは低いです。

しかしやはり起動してみるまで検査できないことはたまにあります。

例えばsystemdのユニットファイルの設計を間違えて、永久にハングするユニットがブートを妨害してしまうケースですね。

しかしNixOSの場合そういう理由でブートが壊れてしまった場合でも、 USBブートとかで修復する必要はなくて、ブートローダーのメニューから過去の世代を遡って正常なものを選び直せばブートできます。

ところが、表示されるデフォルトの世代のラベルで実用的な情報として機能するのはビルドした日付ぐらいです。

一日に何回もビルドしてしまったら、同じ日付の世代がいくつも並んでしまい、どれが正常にブートする世代なのかが分からなくなってきます。

そういう時は雑にかなりの日数を遡るしかありません。

そこでブートエントリのラベルを見分け易くしました。

実装は私のdotfilesに対する以下のPRで行いました。

問題

NixOSのブートエントリのラベルは、 system.nixos.label オプションで設定できます。

デフォルトでは、 NixOSのリリースバージョンと、 FlakeのGitのshortRev程度の情報しか含まれていません。

これだと、どのコミットでビルドされた世代なのか識別が難しいです。コミットリビジョンが入っていても、他の端末でわざわざ確認しないとどのコミットなのかは分からないです。

ブートメニューを見た瞬間に、「これはあのコミットだな」と分かるラベルにしたいわけですよね。

設計

ラベルに以下の情報を詰め込むことにしました。

  • ビルドした時刻
  • NixOSのリリースバージョン
  • dotfilesのコミットリビジョン(shortRev)
  • 作業ツリーがdirtyだったかどうか
  • ビルドに使ったブランチ名
  • 最新コミットメッセージから生成した短いラベル

標準的な表示との互換性をある程度保ちつつ、見た瞬間にどういう変更が入ったのか分かるようなラベルを目指しました。

最終的なラベルの形式は以下のようになります。

04:00:42-25.11-8e0a4d5-master-merge.renovate.ncaq-kyosei-action-2.x

ブートメニューの世代一覧にはデフォルト設定で先頭にNixOSが付与する日付が並ぶため、ラベル側では日付を省いて時刻だけにしています。

なぜコミットメッセージとブランチなのか

コミットリビジョンだけだと人間には記号の羅列にしか見えません。

コミットメッセージのsubjectをラベルに含めれば、「あの変更を入れた世代だ」と直感的に分かります。

ただしsubjectをそのまま入れると長すぎたり、ブートローダーで扱いにくい文字が入ったりします。

Unicodeの全ての文字を全てのブートローダーが正しく処理できるとは思えません。そのために全てを表示できるフォントを埋め込む気にもなりません。いざというときに安定性を失うほうが怖いですし。

そこで、 Conventional Commits のコミット形式のASCIIの非記号部分を簡単にパースしたり、 GitHubのマージコミットの形式をパースして、短いラベルに変換することにしました。

特にGitHubのマージコミットは分かり易くていいです。私はブランチ名に非ASCII文字をほぼ使わないので、ある程度意味のあるラベルが安定して生成されることが期待できます。

masterブランチであるという情報もあれば、 CIを通っていて安定しているので、ブートできる可能性が高いと判断できるようになります。

実装

純粋性との戦い

ここが今回の最大の難所でした。

NixのFlakeは純粋性を重視するため、評価中にちょっとGitコマンドを叩いてGitのコミットメッセージを取得するような、素直な手段が使えません。

inputs.self.shortRevのようなリビジョン情報は取得できますが、コミットのsubjectやブランチ名はFlakeの評価コンテキストからは見えません。

環境変数NIXOS_LABELを設定すればそれがラベルになりますが、ビルド時にホストの環境変数を参照するにはimpureにする必要があります。このためだけにdotfiles全体をimpureにするのは絶対に嫌です。

そこで、ビルド直前に一時ファイルへコミット情報を書き出し、それをFlakeのソースに注入するという方法を採りました。

FlakeはGitにstageされたファイルのみをソースに含めるため、本来.gitignoreで無視される一時ファイルを、 git add -fで強制的にstageしてrebuildし、終わったら削除してしまいます。

install.shでコミット情報を注入する

私のdotfilesはリポジトリルートの install.sh からnixos-rebuildを呼んでいます。

ここでstage_last_commit関数を追加し、最新コミットのsubject、dirty状態、ブランチ名をlast-commit.jsonに書き出してstageします。

# 最新コミットの情報をlast-commit.jsonに保存してstagingします。
# flakeはstagingされたファイルのみをソースに含めるため、
# 一時的にgit addで注入してrebuild後にunstageします。
# last-commit.jsonのstagingで必ずdirtyになるため、注入前に本来のdirty状態を記録します。
stage_last_commit() {
  local subject branch dirty
  subject=$(git log -1 --format=%s)
  branch=$(git rev-parse --abbrev-ref HEAD)
  if git diff --quiet && git diff --cached --quiet; then
    dirty=false
  else
    dirty=true
  fi
  jq -n \
    --arg subject "$subject" \
    --argjson dirty "$dirty" \
    --arg branch "$branch" \
    '{subject: $subject, dirty: $dirty, branch: $branch}' \
    >last-commit.json
  git add -f last-commit.json
  trap cleanup_last_commit EXIT
}

ポイントは以下の通りです。

  • last-commit.jsonをstageすると必ずdirtyになるため、注入前に本来のdirty状態を記録しておく
  • trapでrebuild終了後に必ずクリーンアップする

最初の実装ではコミットsubjectとdirty状態を改行区切りのテキストで保存していましたが、ブランチ名を追加する段階で構造が複雑になってきたため、 jqでJSONを生成する形に切り替えました。

最初はNixファイルを生成してそれを直接ロードしたほうがネイティブでシンプルだと思いましたが、任意の式を入れられるのは危ないとAIコードレビューに指摘されたので、特にファイルをNix形式にして嬉しいこともあまりないので素直にJSONにしました。まあ危険性を危惧するのは入る内容と処理する内容的にパラノイアに近いと思いましたが。

クリーンアップではgit resetrmではなく、可能ならtrashを使うようにしています。万が一の事故でも復元できるようにするためです。

label.nixでラベルを組み立てる

注入されたlast-commit.jsonを、 nixos/core/label.nix で読み込みラベルを組み立てます。

/**
  ブートエントリのラベルにdotfilesのコミット情報を含めます。
  デフォルトでは乏しい情報しかないため、
  どのdotfilesコミットでビルドされたか識別が難しいためです。
*/
{
  lib,
  config,
  inputs,
  ...
}:
let
  # `lastModifiedDate`は"20260308123456"形式。日付は他で表示されるため時刻部分のみ使用します。
  d = inputs.self.lastModifiedDate or "00000000000000";
  time = "${builtins.substring 8 2 d}:${builtins.substring 10 2 d}:${builtins.substring 12 2 d}";
  # コミットリビジョン。
  # `install.sh`が最後のコミット情報ファイルをstagingするため必ずdirtyになります。
  # "-dirty"サフィックスは自前の注入によるものなので除去します。
  shortRev = lib.strings.removeSuffix "-dirty" inputs.self.dirtyShortRev or inputs.self.shortRev;
  # `install.sh`が最新コミットの情報を`last-commit.json`に保存してstagingします。
  lastCommitFile = "${inputs.self}/last-commit.json";
  lastCommit =
    if builtins.pathExists lastCommitFile then
      builtins.fromJSON (builtins.readFile lastCommitFile)
    else
      null;
  lastCommitSubject = if lastCommit != null then lastCommit.subject else null;
  # install.shが注入前に記録した本来のdirty状態。
  dirtySuffix = if lastCommit != null && lastCommit.dirty then "-dirty" else "";
  # install.shが記録したブランチ名。
  branchLabel =
    if lastCommit != null && lastCommit.branch != "" then
      "-${builtins.replaceStrings [ "/" ] [ "." ] lastCommit.branch}"
    else
      "-missing-branch";
  # コミットsubjectからラベルを生成します。
  # conventional commits: "fix(boot): message" → "fix.boot", "feat: message" → "feat"
  # GitHubマージ: "Merge pull request #717 from ncaq/branch-name" → "merge.branch-name"
  conventionalParsed =
    if lastCommitSubject != null then
      builtins.match "([a-zA-Z]+)(\\(([a-zA-Z0-9._-]+)\\))?: *(.*)" lastCommitSubject
    else
      null;
  mergeParsed =
    if lastCommitSubject != null then
      builtins.match "Merge pull request #([0-9]+) from [^/]+/(.*)" lastCommitSubject
    else
      null;
  commitLabel =
    if conventionalParsed != null then
      let
        commitType = builtins.elemAt conventionalParsed 0;
        commitScope = builtins.elemAt conventionalParsed 2;
      in
      if commitScope != null then "${commitType}.${commitScope}" else commitType
    else if mergeParsed != null then
      "merge.${builtins.replaceStrings [ "/" ] [ "." ] (builtins.elemAt mergeParsed 1)}"
    else
      "unknown";
  inherit (config.system.nixos) release;
in
{
  system.nixos.label = "${time}-${release}-${shortRev}${dirtySuffix}${branchLabel}-${commitLabel}";
}

ポイントは以下の通りです。

  • lastModifiedDate20260308123456のような形式なので、 builtins.substringで時刻部分だけを切り出す
  • install.shlast-commit.jsonをstageした影響で必ずdirtyになるため、 shortRevから-dirtyサフィックスを除去し、本来のdirty状態は注入したJSONの値で判定する
  • ブランチ名やマージ元ブランチに含まれる/はラベルで扱いにくいので.に置換する
  • コミットメッセージはbuiltins.matchの正規表現で、 Conventional CommitsとGitHubマージコミットの2パターンをパースする

Conventional Commitsの場合は、 fix(boot): messagefix.bootに、 feat: messagefeatに変換します。

GitHubのマージコミットの場合は、 Merge pull request #717 from ncaq/branch-namemerge.branch-nameに変換します。

どちらにも当てはまらない場合はunknownにしています。ここは「あれば嬉しい」程度の情報なので、そこまで厳密にパースせずに、エラー時は雑なフォールバックが入るようにしています。

段階的な改善

最初のPR(#719)では、日時をISO 8601形式のフルの日時で入れていました。

しかしブートメニューには元々NixOSが日付を表示するので、日付が重複して冗長でした。そこで#731で時刻だけに変更しました。

その後#804でブランチ名をラベルに追加しました。私はブランチを切って作業することが多いので、どのブランチでビルドした世代かが分かると便利です。

クリーンアップ処理も#732で、単なるgit resetからtrashを使う形に改善しています。

結果

実際のブートメニューはこのようになりました。

トップのメニューはシンプルです。

GRUBのトップメニュー

NixOS - All configurationsを選ぶと、過去の世代が一覧で並びます。

世代一覧のラベル
  • 時刻
  • リリースバージョン
  • コミットリビジョン
  • ブランチ名
  • コミットラベル

が並んでいて、どの世代が何の変更を入れたものかがひと目で分かるようになりました。

systemd-bootでも動きます

例ではGRUBを使っていますが、ブートラベルを個別に設定しているだけなので、 systemd-bootでも当然同様に動きます。

私はラップトップPCの方ではsystemd-bootを使っているので、こちらでも恩恵を受けています。

課題

標準的な方法でやりたい

この方法はかなりハック的です。

本当はこういうハックはやりたくありませんでした。 impureにしてNIXOS_LABEL環境変数を操作するよりはマシなので、一時ファイルをstageする方法を選びましたが。

特に残念なのはインストール時に常に以下の警告が出るようになってしまったことです。

warning: Git tree '/home/ncaq/dotfiles' is dirty

last-commit.jsonを強制的にstageしている以上、作業ツリーは必ずdirtyになるのでこの警告は避けられません。

GitのコミットリビジョンをFlakeが内部で取得できるのなら、同じくGitのコミットメッセージを取得しても、 Gitのリビジョンに対するNixのビルドの再現性は損なわれないような気がするのですが。

なのでGitのコミットメッセージやブランチをimpureにしなくても取得する方法があるのかもしれません。

少し探しても見つからなかったのでこの方法を使っていますが。

実はうまい方法があったりしないものでしょうか。

もしくは自分の「別にコミットメッセージを取得できても再現性は損なわれない」という仮説が正しければ、 Nixの該当する部分を拡張して取得できるようにした方が良いのかもしれません。

ちゃんとした言語で書き直したい

こんなそこそこ大きめな処理になると最初から分かっているならば、 Nix言語とBashスクリプトで書くのではなくて、 HaskellやRustのようなちゃんとした言語で書くべきでした。

そうしたら前処理部分などを比較的簡単に切り離したり、 withパターンでコマンドを実行することなどが可能で、細かな問題も解決し易くなります。

例えばCIではlast-commit.jsonが生成されないでビルドされるので、ビルドされたパスがmissing-branchunknownで埋まってしまい、実際にインストールするパスと違ってしまうといった問題があります。

これはwithパターンでコマンドを実行すれば容易に解決可能です。

これは他の人との議論とかが必要なく、自分だけの問題にできるので、そのうち書き直します。

感想

デフォルトのラベルが日付しか実用情報を持たないのは、ブートが壊れた時に非常に面倒でした。

ハックではありますが、ブートメニューを見ただけで「これはあのコミット、あのブランチの世代だ」と分かるようになったのは、正常なブートを選ぶのに非常に役立ちます。

最初から簡単にこれができるようになっていれば役立つと思うのですが。