How to make Git commit hash available in C++ code without needless recompiling?

The name of the pictureThe name of the pictureThe name of the pictureClash Royale CLAN TAG#URR8PPP



How to make Git commit hash available in C++ code without needless recompiling?



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.


myapp --version


Makefile


qmake



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.:


GIT_COMMIT


#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.


git describe


$(shell)



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


$(shell)


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 up by make, and the binary does not get rebuilt.


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).


version.h


.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).


version.h


version.h



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.


version.h.tmp


version.h



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.


$(shell)



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.


version.h



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


$(shell)



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?





It's a common thing but people solve the problem by don't caring about needless recompiling. :-) stackoverflow.com/a/44038455/7976758
– phd
Aug 7 at 13:21






Alternatively you could use git hooks
– Marco A.
Aug 7 at 13:24






@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 7 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 7 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 8 at 7:18


version.h


version.h




5 Answers
5



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


.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.


version.c



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.


version.h


make





How will that cause version.c to be rebuilt when the Git hash changes?
– Thomas
Aug 8 at 7:13


version.c





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 8 at 14:30


.PHONY





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 8 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 at 17:31


version.o


extern const char version



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


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...





This won't pick up a not-dirty-to-dirty transition.
– jthill
Aug 8 at 2: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 8 at 5:40


$(shell git ls-files --others)



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.


version.h


version.cpp


print_version


version.cpp



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.


$(MAKE) MODE=1 $@


version.h


version.h





Interesting idea! A bit frightening, too. Would need some extra work to preserve command line arguments (e.g. target to be built).
– Thomas
Aug 8 at 7: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 8 at 8:15


ifeq-endif


ifeq-else



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.


version.c


myapp



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.


Makefile


__timestamp.c


make


__timestamp.c



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:


Makefile


version.c


version.o


$(LINK.cc)


myapp


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.


version.c


version.o



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.


$(shell)


version.h



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.


version.h


version.cpp



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"nnchar 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!






By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

Popular posts from this blog

Firebase Auth - with Email and Password - Check user already registered

Dynamically update html content plain JS

How to determine optimal route across keyboard