The Developer’s Cry

a blog about computer programming

Three Git Commit Hashes

Good software knows its own version number; quality software holds build information. So besides printing the version number, programs should output their source code revision number: what was the exact source this binary was built from, and this is especially important for debug builds. Embedding the latest git commit hash into the build can be done with a neat little trick. The only gotcha is that every build system is different, so that neat little trick is different for every language build system.

The shell command for getting the short commit hash is:

git rev-parse --short=7 HEAD

In principle we can write a shell script to generate a small source file that we then include in the project. In practice we have to work with the build system, so it’s a little bit more involved than that.

Rust

Rust is interesting because of how its build system works. Rust builds are generally done by cargo. By default, cargo build will simply compile all .rs files in the src/ dir and link that to create the target executable.

The way to configure cargo builds is to write build.rs, which is a Rust program itself. The build.rs program prints compiler directives to stdout, that are then picked up by the rustc compiler when it actually builds the main program.

The project directory structure is:

project/
- Cargo.toml
- build.rs
- src/
  - main.rs

The build.rs program must obtain the git commit hash, and somehow inject it into the main program. Since build.rs is a Rust program, running shell commands is not super trivial, but we can do it.

let hash = std::process::Command::new("git")
    .args(["rev-parse", "--short=7", "HEAD"])
    .output()
    .ok()
    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
    .unwrap_or_else(|| "unknown".to_string());

println!("cargo:rustc-env=GIT_HASH={}", hash);
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/index");

We get the commit hash and print it as an environment variable, so rustc can pick it up. When there is a new commit, the hash changes, and cargo should rebuild the project.

In the main program we can insert the environment variable:

println!("{} {} ({})",
    env!("CARGO_PKG_NAME"),
    env!("CARGO_PKG_VERSION"),
    env!("GIT_HASH")
);

The env! macro will bake in the value, note that it will not use the runtime environment when the main executable runs.

The complete flow is:

cargo build =>
cargo runs rustc to compile build.rs =>
rustc compiles build.rs =>
cargo runs build binary, capturing stdout =>
build binary runs git to obtain commit hash =>
build binary prints compiler directives and environment =>
cargo reads the build binary output =>
cargo sets up environment for rustc =>
cargo runs rustc to compile main program =>
rustc uses environment =>
rustc expands env! macro =>
rustc bakes commit hash into target binary =>
main program will print baked-in commit hash

Go

Go has no separate build system; the go command is all you need. There seems to be no way of running shell commands during build, but we don’t need to. Go embeds build information into the target executable, that can be extracted at runtime.

import "runtime/debug"

func GitCommit() string {
    /*
        Returns git commit hash of the build
        This only works for `go build`, not for `go run`
    */
    if info, ok := debug.ReadBuildInfo(); ok {
        for _, setting := range info.Settings {
            if setting.Key == "vcs.revision" {
                commit := setting.Value
                return commit[:7]
            }
        }
    }
    return ""
}

This only works for go build, and does not work for go run. Also, the project must have a go.mod file for this to work, but that’s easy.

Nice detail: even when making a release build, go is smart enough to leave this part of “debug” information in.

C

Even though you shouldn’t write new codes in C anymore, we still continue to do so anyway. The conventional build system for C is make (alternatives do exist) and in particular I’m using GNU make syntax. For C we will use the “traditional” method of writing the commit hash to a source file, but the trick here is to get the Makefile syntax right.

In the Makefile add a FORCE target that triggers regenerating the git_hash.c source file. Mind that make looks at the timestamps of the files; we don’t want the file to be rewritten unless there has been a new commit.

.PHONY: all FORCE

FORCE:

git_hash.c: FORCE
    @tmp=$@.tmp; \
    printf 'const char* git_hash = "%s";\n' "$$(git rev-parse --short=7 HEAD)" > "$$tmp"; \
    cmp -s "$$tmp" "$@" 2>/dev/null || mv "$$tmp" "$@"; \
    rm -f "$$tmp"

There is one more thing to adjust in the Makefile. I often use $(CFILES) and construct the $(OBJS) from that:

C_FILES = $(filter-out git_hash.c,$(wildcard *.c))
OBJS = $(patsubst %.c,obj/%.o,$(C_FILES)) obj/git_hash.o

Now we are all set. In main.c we put:

extern const char* git_hash;

and now we can printf() the baked-in hash.