Skip to content

Git hashes in Nix

While building Amp, I'm often making small, incremental changes in-between official releases. To dogfood these changes during my day-to-day work, I've added the project as a git-based input to my NixOS flake config, allowing me to easily pull those changes into my local installation.

Across various machines, it can be hard to tell which version I'm running. To fix that, I added a "build revision" to the splash screen, showing the shorthand commit hash of the current build:

splash screen

Populating that during Nix-based builds ended up being tricker than I'd thought.

Solving in Rust

If I'm building Amp locally from a cloned repo, this is pretty easy. In that scenario, it's safe to assume Git is installed and available in the user environment, and you can shell out in the Cargo build script:

build.rs
fn set_build_revision() {
    // Run the Git command to get the current commit hash
    let output = Command::new("git")
        .args(&["rev-parse", "--short", "HEAD"])
        .output()
        .expect("Failed to execute git command");

    // Parse the hash
    let build_revision = String::from_utf8(output.stdout)
        .expect("Invalid UTF-8 sequence");

    // Write the hash as an environment variable
    println!("cargo:rustc-env=BUILD_REVISION={}", build_revision.trim());
}

This produces an environment variable that's handed off to the compile phase, where it can be interpolated in the splash screen source using the env! macro:

format!("Build revision {}", env!("BUILD_REVISION")),

There's one problem with this approach: it doesn't work with Nix.

Nix is adamant that builds are pure and don't include local state. If you've ever been caught by Nix ignoring files that aren't tracked by source control, or you've encountered messages like this, you know what I'm talking about:

warning: Git tree /your-nixos-config-path is dirty

What you might not be aware of is that Nix also removes the .git directory during builds. As you can imagine, this breaks the hash look-up command in the build script above. To fix this, we need to push that responsibility out of the Rust build and into the Nix config.

Ackshually...

Nix doesn't always remove the .git directory. If you're writing a Nix build script that lives outside the repository it's building, you're likely using a Git-based fetcher, which can be configured with a leaveDotGit option that will preserve the .git directory. That's not applicable here, but it's worth calling out.

Solving in Nix

Making this work in a Nix environment requires two changes:

  • a Nix build configuration that sets the BUILD_REVISION environment variable
  • a Rust build script that leaves that environment variable alone if it's already set

Let's get the Rust build script change out of the way first.

Don't shell out in Rust

The git rev-parse --short HEAD look-up in the Rust build script shouldn't be used when building with Nix. To make that optional, we can update the build_revision function we created to return early if a value is already set:

build.rs
function build_revision() {
    // Skip if the environment variable is already set
    let revision = env::var("BUILD_REVISION");
    if revision.map(|r| !r.is_empty()) == Ok(true) { // (1)!
        return;
    }

    // Run the Git command to get the current commit hash
    let output = Command::new("git")
        .args(&["rev-parse", "--short", "HEAD"])
        .output()
        .expect("Failed to execute git command");

    // Parse the hash
    let build_revision = String::from_utf8(output.stdout)
        .expect("Invalid UTF-8 sequence");

    // Write the hash as an environment variable
    println!("cargo:rustc-env=BUILD_REVISION={}", build_revision.trim());
}
  1. This is dense and hard to read. If you're curious: reading the environment variable can fail, and so it returns a Result. Since we are ultimately checking for the presence of an environment variable, we only care if this look-up is successful and that its associated value is not empty. To do that, we map its Ok variant to a boolean representing whether or not it has content, and then match against that.

Pretty straight-forward; now let's make the changes in Nix.

Nix hashes

To get a reference to the current revision in Nix, we'll use a special self.rev value available in flake outputs. Here's what that looks like in an abridged version of the Rust project's flake.nix:

flake.nix
{
  description = "Amp: A complete text editor for your terminal";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
  };

  outputs = { self, nixpkgs }:
    let
      supportedSystems = [ "x86_64-linux" ];
      forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
    in {
      # Define packages for all supported systems
      packages = forAllSystems (system: {
        default = self.buildPackage { inherit system; };
      });

      # Function to build the package for a given system
      buildPackage = { system }:
        let pkgs = import nixpkgs { inherit system; };
        in pkgs.rustPlatform.buildRustPackage {
          pname = "amp";
          version = "0.7.0";
          cargoLock.lockFile = ./Cargo.lock;

          # Use source files without version control noise
          src = pkgs.lib.cleanSource ./.;

          # Packages needed at runtime
          buildInputs = with pkgs; [ git xorg.libxcb openssl zlib ];

          # Packages needed during the build
          nativeBuildInputs = with pkgs; [ git ];

          # Make the build/check/install commands explicit so we can
          # provide the commit SHA for the splash screen
          buildPhase = ''
            export BUILD_REVISION=${builtins.substring 0 7 (
              if self ? rev then self.rev else ""
            )}

            cargo build --release
          '';

          checkPhase = ''
            cargo test
          '';

          installPhase = ''
            mkdir -p $out/bin
            cp target/release/amp $out/bin/
          '';

          # Amp creates files and directories in $HOME/.config/amp when run.
          # Since the checkPhase of the build process runs the test suite, we
          # need a writable location to avoid permission error test failures.
          HOME="$src";
        };
  };
}

There are a few implementation details here worth covering.

Taking a substring

This one is pretty straight-forward: the revision we're given by Nix is the full Git SHA. We've written the Rust build script to take BUILD_REVISION as-is, so it's on us to trim that in the flake. Grabbing the first 7 characters gives us the equivalent of a shorthand Git SHA.

Guarding against a missing revision

When a flake is declared as a dependency of another (e.g. adding a 3rd-party flake to your NixOS flake inputs), Nix tracks its version in a flake.lock file so it's able to deterministically reproduce that build in the future.

The self.rev attribute we're relying on is supported by this behaviour, which means it's not available when the 3rd-party flake isn't being built through another flake. As a result, we can't always rely on it being available, hence the if/then/else above.

Putting it all together

Getting Git hashes during a build isn't as easy as it sounds. To recap, here's how the build plays out in different scenarios:

build scenario environment build.rs result
outside of Nix no BUILD_REVISION set Git-based look-up
nix develop no flake.lock or self.rev, BUILD_REVISION is blank Git-based look-up
from another flake flake.lock and self.rev exist, BUILD_REVISION is set BUILD_REVISION

Taking a layered approach as described here ensures there's a Git hash available in all build scenarios.