17

A fairly common requirement, methinks: I want myapp --version to show the version and the Git commit hash (including whether the repository was dirty). The application is being built through a Makefile (actually generated by qmake, but let's keep it "simple" for now). I'm fairly well versed in Makefiles, but this one has me stumped.

I can easily get the desired output like this:

$ git describe --always --dirty --match 'NOT A TAG'
e0e8556-dirty

The C++ code expects the commit hash to be made available as a preprocessor macro named GIT_COMMIT, e.g.:

#define GIT_COMMIT "e0e8556-dirty" // in an include file
-DGIT_COMMIT=e0e8556-dirty         // on the g++ command line

Below are the several different ways I have tried to plumb the git describe output through to C++. None of them work perfectly.

Approach The First: the $(shell) function.

We use make's $(shell) function to run the shell command and stick the result into a make variable:

GIT_COMMIT := $(shell git describe --always --dirty --match 'NOT A TAG')

main.o: main.cpp
    g++ -c -DGIT_COMMIT=$(GIT_COMMIT) -o$@ $<

This works for a clean build, but has a problem: if I change the Git hash (e.g. by committing, or modifying some files in a clean working copy), these changes are not seen by make, and the binary does not get rebuilt.

Approach The Second: generating version.h

Here, we use a make recipe to generate a version.h file containing the necessary preprocessor defines. The target is phony so that it always gets rebuilt (otherwise, it would always be seen as up to date after the first build).

.PHONY: version.h
version.h:
    echo "#define GIT_COMMIT \"$(git describe --always --dirty --match 'NOT A TAG')\"" > $@

main.o: main.cpp version.h
    g++ -c -o$@ $<

This works reliably and does not miss any changes to the Git commit hash, but the problem here is that it always rebuilds version.h and everything that depends on it (including a fairly lengthy link stage).

Approach The Third: only generating version.h if it has changed

The idea: if I write the output to version.h.tmp, and then compare this to the existing version.h and only overwrite the latter if it's different, we wouldn't always need to rebuild.

However, make figures out what it needs to rebuild before actually starting to run any recipes. So this would have to come before that stage, i.e. also run from a $(shell) function.

Here's my attempt at that:

$(shell echo "#define GIT_COMMIT \"$$(git describe --always --dirty --match 'NOT A TAG')\"" > version.h.tmp; if diff -q version.h.tmp version.h >/dev/null 2>&1; then rm version.h.tmp; else mv version.h.tmp version.h; fi)

main.o: main.cpp version.h
    g++ -c -o$@ $<

This almost works: whenever the Git hash changes, the first build regenerates version.h and recompiles, but so does the second build. From then on, make decides that everything is up to date.

So it would seem that make decides what to rebuild even before it runs the $(shell) function, which renders this approach broken as well.

This seems like such a common thing, and with make being such a flexible tool, I find it hard to believe that there is no way to get this 100% right. Does such an approach exist?

Thomas
  • 174,939
  • 50
  • 355
  • 478
  • It's a common thing but people solve the problem by don't caring about needless recompiling. :-) https://stackoverflow.com/a/44038455/7976758 – phd Aug 07 '18 at 13:21
  • 1
    Alternatively you could use [git hooks](https://git-scm.com/docs/githooks) – Marco A. Aug 07 '18 at 13:24
  • 1
    @MarcoA. Interesting, hadn't considered that. But git hooks aren't able to detect if a working copy goes from a clean to a dirty state, are they? – Thomas Aug 07 '18 at 13:26
  • I can't really reproduce your 3. approach with that Makefile, main.o gets built only once after a change in the git hash, the second time I invoke make, it does not build it again. Another similar approache is a mix of your 2. and 3. approach, so that you state version.h as a target instead of using $(shell.. ) but don't change the file if the git hash is not changed. – nos Aug 07 '18 at 13:52
  • @nos I think I accidentally committed `version.h` in my previous testing, which would cause double rebuild after committing everything: once because the commit hash changed, but then once more because the updated `version.h` went from clean to dirty. Thanks for pointing that out! It means the third approach, though hairy, does get the job done. – Thomas Aug 08 '18 at 07:18
  • To alleviate the "fairly lengthy link stage", on Linux we now have `mold` as a drop-in replacement for `ld`, and it's _fast_. – Thomas Apr 28 '23 at 08:15

7 Answers7

11

I found a nice solution here:

In your CMakeLists.txt put:

# Get the current working branch
execute_process(
    COMMAND git rev-parse --abbrev-ref HEAD
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    OUTPUT_VARIABLE GIT_BRANCH
    OUTPUT_STRIP_TRAILING_WHITESPACE)

# Get the latest commit hash
execute_process(
    COMMAND git rev-parse HEAD
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    OUTPUT_VARIABLE GIT_COMMIT_HASH
    OUTPUT_STRIP_TRAILING_WHITESPACE)

and then define it in your source:

target_compile_definitions(${PROJECT_NAME} PRIVATE
    "-DGIT_COMMIT_HASH=\"${GIT_COMMIT_HASH}\"")

In the source it will now be available as a #define. One might want to make sure that the source still compiles correctly by including:

#ifndef GIT_COMMIT_HASH
#define GIT_COMMIT_HASH "?"
#endif

Then you are ready to use, with for example:

std::string hash = GIT_COMMIT_HASH;
Tom de Geus
  • 5,625
  • 2
  • 33
  • 77
  • Thanks for your answer. My question was about plain `make` though (or `qmake`) but not CMake. – Thomas Sep 16 '20 at 16:25
  • @Thomas Just in case you ever switch to CMake then ;) . I thought it would be good to document for CMake users. – Tom de Geus Sep 16 '20 at 16:45
  • 1
    https://stackoverflow.com/questions/1435953/how-can-i-pass-git-sha1-to-compiler-as-definition-using-cmake/4318642 – Thomas Sep 17 '20 at 09:25
  • 5
    The thing is this command will not be automatically triggered if no change to cmake related files were made. So it will soon lose track to the commit hash – hzh Aug 30 '21 at 16:47
  • 1
    It still works pretty well for release builds that are done from scratch anyway. – Audrius Meškauskas Feb 02 '23 at 10:54
6

It turns out my third approach was fine after all: $(shell) does run before make figures out what to rebuild. The problem was that, during my isolated tests, I accidentally committed version.h to the repository, which caused the double rebuild.

But there is room for improvement still, thanks to @BasileStarynkevitch and @RenaudPacalet: if version.h is used from multiple files, it's nicer to store the hash in a version.cpp file instead, so we only need to recompile one tiny file and re-link.

So here's the final solution:

version.h

#ifndef VERSION_H
#define VERSION_H
extern char const *const GIT_COMMIT;
#endif

Makefile

$(shell echo -e "#include \"version.h\"\n\nchar const *const GIT_COMMIT = \"$$(git describe --always --dirty --match 'NOT A TAG')\";" > version.cpp.tmp; if diff -q version.cpp.tmp version.cpp >/dev/null 2>&1; then rm version.cpp.tmp; else mv version.cpp.tmp version.cpp; fi)

# Normally generated by CMake, qmake, ...
main: main.o version.o
    g++ -o$< $?
main.o: main.cpp version.h
    g++ -c -o$@ $<
version.o: version.cpp version.h
    g++ -c -o$@ $<

Thanks everyone for chiming in with alternatives!

Thomas
  • 174,939
  • 50
  • 355
  • 478
3

First of all, you could generate a phony version.h but use it only in version.cpp that defines the print_version function used everywhere else. Each invocation of make while nothing changed would then cost you only one ultra-fast compilation of version.cpp plus the fairly lengthy link stage. No other re-compilations.

Next, you can probably solve your problem with a bit of recursive make:

TARGETS := $(patsubst %.cpp,%.o,$(wildcard *.cpp)) ...

ifeq ($(MODE),)
$(TARGETS): version
    $(MAKE) MODE=1 $@

.PHONY: version

version:
    VERSION=$$(git describe --always --dirty) && \
    printf '#define GIT_COMMIT "%s"\n' "$$VERSION" > version.tmp && \
    if [ ! -f version.h ] || ! diff --brief version.tmp version.h &> /dev/null; then \
        cp version.tmp version.h; \
    fi
else
main.o: main.cpp version.h
    g++ -c -o$@ $<

...
endif

The $(MAKE) MODE=1 $@ invocation will do something if and only if version.h has been modified by the first make invocation (or if the target had to be re-built anyway). And the first make invocation will modify version.h if and only if the commit hash changed.

Renaud Pacalet
  • 25,260
  • 3
  • 34
  • 51
  • Interesting idea! A bit frightening, too. Would need some extra work to preserve command line arguments (e.g. target to be built). – Thomas Aug 08 '18 at 07:11
  • @Thomas: yes, there is some extra work. Adding a last-resort default rule to the first part is tempting but it also has undesirable side-effects. The best option, I think, if applicable, is to build a list of all your targets and assign it to a variable before the `ifeq-endif`. Then, just add a rule for these targets in the `ifeq-else`. I updated my answer to illustrate this. – Renaud Pacalet Aug 08 '18 at 08:15
2

Using .PHONY directly means the target file is presumed not to exist, which you don't want for real files. To force a recipe that might rebuild a file, make it depend on a phony target. Like so:

.PHONY: force
version.c: force
        printf '"%s"' `git describe --always --dirty` | grep -qsf - version.c \
        || printf >version.c 'const char version[]="%s";\n' `git describe --always --dirty`

(except markdown doesn't understand tabs, you have to fix that in the paste)

and the version.c recipe will run every time, since its phony dependency is presumed not to exist, but things that depend on version.c will check the real file, which only really gets updated if its contents didn't have the current version.

Or you could generate the version string in version.h as with the "Approach the Second" setup in your question, the important thing is not to tell make real files are phony.

jthill
  • 55,082
  • 5
  • 77
  • 137
  • How will that cause `version.c` to be rebuilt when the Git hash changes? – Thomas Aug 08 '18 at 07:13
  • Oh -- I see it now, your problem is you're using `.PHONY` directly, so the real file is presumed not to exist. Instead, make your rules depend on a phony target, – jthill Aug 08 '18 at 14:30
  • Notice that keeping constants in a separately-compiled object avoids recompilation of everything that merely refers to them. I had missed that your relink cost is irksome. – jthill Aug 08 '18 at 15:23
  • . . . and to answer your question explicitly, building anything with a dependency on `version.o` will redrive the above, since make figures out the dependency from default rules and sees the recipe has to run every time. So have an `extern const char version[]` in some header and your version-number handling gets near theoretical-minimum rebuild overhead. – jthill Aug 16 '18 at 17:31
1

Why not have version.h depend on your .git/index file? That is touched whenever you commit or change something in your staging area (which does not happen often, usually).

version.h: .git/index
    echo "#define GIT_COMMIT \"$(git describe --always --dirty)\"" > $@

If you plan on building without Git at some point, you will need to change this, of course...

Botje
  • 26,269
  • 3
  • 31
  • 41
  • 2
    This won't pick up a not-dirty-to-dirty transition. – jthill Aug 08 '18 at 02:56
  • I suppose you could add `$(shell git ls-files --others)` to the dependency list to solve this. But I think the simplicity of this solution is worth the trade-off. – Botje Aug 08 '18 at 05:40
0

I suggest generating a tiny self-sufficient C file version.c defining some global variables, and ensuring it is regenerated at every successful link of myapp executable.

So in your makefile

 version.c:
       echo "const char version_git_commit[]=" > $@
       echo "  \"$(git describe --always --dirty)\";" >> $@

Then have some C++ header declaring it:

extern "C" const char version_git_commit[];

BTW, look into my bismon repository (commit c032c37be992a29a1e), its Makefile, target file __timestamp.c for inspiration. Notice that for the binary executable bismonion target, make is removing __timestamp.c after each successful link.

You could improve your Makefile to remove version.c and version.o after each successful executable linking (e.g. after some $(LINK.cc) line for your myapp executable). Hence you would have in your makefile:

myapp: #list of dependencies, with version.o ....
      $(LINK.cc) .... version.o ... -o $@
      $(RM) version.o version.c

So you could have only your version.c and version.o rebuilt every time, and that is very quick.

Basile Starynkevitch
  • 223,805
  • 18
  • 296
  • 547
-1

You can get it by calling git rev-parse --short HEAD command directly from your executable

Here is what I did:

in CMakeLists.txt

add_definitions("-DPROJECT_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}\"")

and in your source file:


#include <array>
#include <cstdio>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>

inline std::string execCommand(const char* cmd) {
  std::array<char, 128> buffer;
  std::string result;
  std::unique_ptr<FILE, decltype(&pclose)> pipe(popen(cmd, "r"), pclose);
  if (!pipe) {
    throw std::runtime_error("popen() failed!");
  }
  while (fgets(buffer.data(), buffer.size(), pipe.get()) != nullptr) {
    result += buffer.data();
  }
  return result;
}


int main()
{
  std::string git_command = "cd ";
  git_command += PROJECT_DIR;  // get your project directory from cmake variable
  git_command += " && git rev-parse --short HEAD";  // get the git commit id in your project directory

  std::cout << "Git commit id is :" << execCommand(git_command.c_str()) << std::endl;

  return 0;
}

mdemirst
  • 510
  • 1
  • 5
  • 8
  • Thanks, but it's needed at compile time, not at runtime. `--version` is usually not invoked from within the source repository. – Thomas Oct 13 '19 at 15:12