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.