Cannot access commit message from inside build sandbox

I want to include the first line of my git commit messages in the title of my nix generations. As a proof of concept, I decided to start with the following (with the plan to add required filtering later):

  gitmessage = pkgs: (pkgs.runCommandWith {
      name = "gitmessage";
      derivationArgs.src = ./../..;
    } ''
      ${pkgs.git}/bin/git log --format=%B -n 1 HEAD > $out
    '' 
  );

And in my modules:

  system.nixos.tags = [ 
    (builtins.readFile "${gitmessage pkgs}" )
  ] ;

However, git errors out with “not a git repository”. When I add a ls -a command to check what’s going on, I notice that the .git folder is not present when it should be.

Builder output:

error: builder for '/nix/store/hnf7kla99j3w2zc8al3p2v0wsn8x68xj-gitmessage.drv' failed with exit code 128;
       last 4 log lines:
       > .   .gitignore	hooks	 nixos			todo.md
       > ..  README.md	mount.sh  secret_management.sh
       > fatal: not a git repository (or any parent up to mount point /)
       > Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).
       For full logs, run 'nix log /nix/store/hnf7kla99j3w2zc8al3p2v0wsn8x68xj-gitmessage.drv'.

Bash output in same directory

╰─ ls -a
.   .git        hooks     nixos      secret_management.sh
..  .gitignore  mount.sh  README.md  todo.md

What’s going on? Why is the .git folder removed?

Are you using flakes? And can you share your config or at least a MWE? There shouldn’t be any automatic cleaning of src happening at the trivial builder level, but I don’t think I can comment further without more context.

Also, I’ll point out that what you wrote is an IFD, as you’re reading the result of a derivation into the nix evaluator; I would recommend avoiding IFD if possible. But again, the answer to the questions above will inform which solution I’d recommend instead.

One solution (that’s agnostic to flakes in a git repo vs. paths-based) could be to write a commit-msg hook to store the commit message in a file (evidently this is in fact feasible), and your config would read that file.

Here’s a link to my current system configuration:

I think having a IFD here is inevitable (if I want to do what I intend to do without workarounds). I did think about the temp file solution, and that might end up being the most robust option, but I’m leaving the question open, since having a temp file to store state like that can cause problems.

Since you’re using flakes, if you want the .git to be present in the store you’ll have to use a path: URI when you build (at least, that’s the case when flake.nix is at the top-level, I’m not 100% sure in the case of it being in a subdir as you have). But note you’ll end up copying the .git, in full, every time you rebuild after making some changes to any file in the tree.

What would those problems be? The file’s state would be uniquely tied to the commit.
Only issue I can foresee is if you work across multiple branches, you’ll get conflicts on that file when merging/rebasing, so you’d have to handle that case somehow.

Besides, this method would avoid the O(n^2) disk usage (and time spent on I/O) when using a path: URI.

My main concern is that people are stupid, and handling edits or deletions might be annoying.
Even though this system is just for my own private use, I prefer to keep to good practice, and I have also been known to be stupid at times, so…

I could separate the file into a .commit.msg and a .commit.chk with checksum. That would provide the tamperproofing I’m looking for. This might actually be the best option.

I’ll leave the thread open until I get a chance to implement the code, then I’ll post it here. Hopefully we get someone else with a interesting alternative as well. I do have some other questions though.

Can you provide a example of exactly what you mean by a path: URI?

If you’re just storing a text file with the most recent commit message in the store, how do you end up with O(n^2)? I’d figure it would be O(n) disk usage which would be reduced to O(1) if you’re running garbage collection.

nixos-rebuild build --flake 'path:/path/to/flake/directory/'

The text file won’t cause the blowup, the copying the .git to the store prior to creating said file will, since .git will grow. And using path: would cause .git to get copied.

I thought of a cursed alternative: patch nix to read the commit message as part of the definition of the flake, since nix needs to use git anyway to copy the flake to the store, and store the revision…

(I might actually do this myself)

Okay, finally got around to implementing this. There are two changes, a post-commit hook that writes the text, and a gitmessage function that reads it. I’m going to describe everything in-depth for the good of anyone who trips over this thread later.

The hook is very straightforward:

#! /bin/bash

format='{%n  "commit": "%H",%n  "tree": "%T",%n  "author": "%an",%n  "email": "%ae",%n  "date": "%ad",%n  "message": "%s"%n}'  
GITPATH=$(git rev-parse --show-toplevel)
COMMITINFO="$GITPATH/nixos/.lastcommit.json"

echo "Requesting sudo to write to file ${COMMITINFO}"
git log -1 --pretty=format:"${format}" | sudo tee "${COMMITINFO}"
git add --intent-to-add "${COMMITINFO}"
git update-index --assume-unchanged "${COMMITINFO}"


echo -e "\nPost-hook wrote to ${COMMITINFO}"

Besides running git log, the only other important part is that you need to use the git add --intent-to-add and git update-index --assume-unchanged pair to make sure it is excluded from your commits, but is also copied to the Nix Store, as flakes only copy things to the store when they’re in the git index.

Output looks like this:

Requesting sudo to write to file /nix-sync/nixos/.lastcommit.json
[sudo] password for volkswagen: 
{
  "commit": "ed6b88248d17255c28977e712465bcc150eb2db1",
  "tree": "977620f8201f085a3309631d8cab9155843ab1d9",
  "author": "volkswagenfeature",
  "email": "13547477+volkswagenfeature@users.noreply.github.com",
  "date": "Wed Feb 5 17:17:18 2025 -0500",
  "message": "checking clean repo naming feature"
}
Post-hook wrote to /nix-sync/nixos/.lastcommit.json

Minimal:

A minimal implementation of loading the commit message from a file like this would look like the following, but the one I’m using has bells and whistles.

gitmessage = pkgs:
with pkgs.lib;
let
   lastcommit = ../.lastcommit.json;
   commitjson = trivial.importJSON lastcommit ;
in
   commitjson.message

My version:

TLDR for the impatient:

My full function for copy/pasting:

  gitmessage = pkgs: checkrev : 
    with pkgs.lib;
    let
    lastcommit = ../.lastcommit.json;
    commitjson = (assert builtins.pathExists (lastcommit); trivial.importJSON lastcommit) ;
      truncate = str: len: ( 
        strings.concatImapStrings 
         (i: a: if i <= len then a else "")
         ( strings.stringToCharacters str )
      );
      fetch = if (strings.hasSuffix "-dirty" checkrev) then "dirty" else "clean";

    in 
      (
        (
          if (checkrev) != "unknown" 
          then (
            assert commitjson.commit == (strings.removeSuffix "-dirty" checkrev);
            "${fetch}-${truncate checkrev 6}"
          )
          else "unknown-${truncate commitjson.commit 6}"
        ) + "-" + 
        strings.sanitizeDerivationName ( 
          truncate ( 
            builtins.elemAt ( 
              strings.splitString "\n" ( commitjson.message )
            ) 0
          ) 30       
        ) 
      );

Detailed explination:

The file is loaded with a function that takes a instance of pkgs for lib, and a value checkrev, which is set to checkrev = self.rev or self.dirtyRev or "unknown". Nix puts the repo revision in rev if the repo is clean. If it’s dirty, as of 2.17, you can get it from dirtyRev. I also can pull it from the json as a last resort, and that’s what the “unknown” case is for.

Function header:

  gitmessage = pkgs: checkrev : 
    with pkgs.lib;
    let

Path to the json, load the json into commitjson, do some string handling for the format I’ve picked out. I also verify that the file exists at this time.

      lastcommit = ../.lastcommit.json;
      commitjson = (assert builtins.pathExists (lastcommit); trivial.importJSON lastcommit);
      fetch = if (strings.hasSuffix "-dirty" checkrev) then "dirty" else "clean";

I couldn’t find a built-in way to truncate a overly long commit message, so I wrote this utility. usage is just truncate "123456789" 7 -> "1234567"

      truncate = str: len: ( 
        strings.concatImapStrings 
         (i: a: if i <= len then a else "")
         ( strings.stringToCharacters str )
      );

I use an assert to validate the commit hash stored in the temp file. this part generates a string with a format like dirty-fd09ea or clean-43922a or a unkown-993992 in the worst case.

    in 
      (
        (
          if (checkrev) != "unknown" 
          then (
            assert commitjson.commit == (strings.removeSuffix "-dirty" checkrev);
            "${fetch}-${truncate checkrev 6}"
          )
          else "unknown-${truncate commitjson.commit 6}"
        )

the commit message is used like this [raw commit] → [first line only] → [first 30 chars of first line] → [sanitized output (no spaces, backslashes, etc…)] → output

         + "-" + 
        strings.sanitizeDerivationName ( 
          truncate ( 
            builtins.elemAt ( 
              strings.splitString "\n" ( commitjson.message )
            ) 0
          ) 30       
        ) 
      );
}
}

Final version numbers look like this after nixos adds its other stuff:

24-clean-b34e3b-testing-better-commit-detectio-24.11.20250204.030ba19
24-dirty-ed6b98-checking-clean-repo-naming-fea-24.11.20250204.030ba19