如何使 Git 提交哈希在 C++ 代码中可用而无需重新编译?

Posted

技术标签:

【中文标题】如何使 Git 提交哈希在 C++ 代码中可用而无需重新编译?【英文标题】:How to make Git commit hash available in C++ code without needless recompiling? 【发布时间】:2019-01-14 14:09:54 【问题描述】:

一个相当普遍的要求,我想:我希望myapp --version 显示版本和 Git 提交哈希(包括存储库是否脏)。该应用程序是通过Makefile 构建的(实际上是由qmake 生成的,但现在让我们保持“简单”)。我相当精通 Makefile,但这个让我很困惑。

我可以轻松得到想要的输出like this:

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

C++ 代码期望提交哈希可用作名为 GIT_COMMIT 的预处理器宏,例如:

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

以下是我尝试将git describe 输出连接到C++ 的几种不同方法。它们都不能完美地工作。

方法一:$(shell) 函数。

我们使用 make 的 $(shell) 函数运行 shell 命令并将结果粘贴到 make 变量中:

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

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

这适用于干净的构建,但有一个问题:如果我更改 Git 哈希(例如,通过提交或修改干净工作副本中的某些文件),这些更改不会被 make 看到,并且二进制文件不会重建。

方法二:生成version.h

在这里,我们使用 make recipe 生成一个包含必要预处理器定义的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$@ $<

这工作可靠,不会遗漏对 Git 提交哈希的任何更改,但这里的问题是它总是重建 version.h 以及依赖它的所有内容(包括相当长的链接阶段)。

方法三:仅在version.h发生变化时才生成

想法:如果我将输出写入version.h.tmp,然后将其与现有的version.h 进行比较,并且仅在不同时覆盖后者,我们就不需要总是重建。

但是,在实际开始运行任何配方之前,请弄清楚它需要重建什么。所以这必须在那个阶段之前,即也从$(shell)函数运行。

这是我的尝试:

$(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$@ $<

几乎有效:每当 Git 哈希更改时,第一个构建重新生成 version.h 并重新编译,但第二个构建也是如此。从那时起,make 决定一切都是最新的。

因此,似乎 make 甚至在运行 $(shell) 函数之前就决定了要重建的内容,这也导致这种方法被破坏。

这似乎是一件很常见的事情,而且由于 make 是一个如此灵活的工具,我很难相信没有办法做到 100% 正确。 这种方法存在吗?

【问题讨论】:

这是很常见的事情,但人们通过不关心不必要的重新编译来解决问题。 :-) ***.com/a/44038455/7976758 或者你可以使用git hooks @MarcoA。有意思,没考虑过。但是 git 钩子无法检测工作副本是否从干净状态变为脏状态,是吗? 我无法真正重现您的 3. 使用该 Makefile 的方法,main.o 在 git 哈希更改后仅构建一次,第二次调用 make,它不会再次构建它.另一种类似的方法是您的 2. 和 3. 方法的混合,因此您将 version.h 声明为目标而不是使用 $(shell.. ) 但如果 git 哈希未更改,请不要更改文件。 @nos 我想我在之前的测试中不小心提交了version.h,这会在提交所有内容后导致双重重建:一次是因为提交哈希发生了变化,但又一次是因为更新的version.h 去了从干净到肮脏。感谢您指出了这一点!这意味着第三种方法虽然很麻烦,但确实可以完成工作。 【参考方案1】:

我找到了一个不错的解决方案here:

在您的CMakeLists.txt 中输入:

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

然后在您的源代码中定义它:

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

在源代码中,它现在将以#define 的形式提供。可能希望通过包括来确保源代码仍然正确编译:

#ifndef GIT_COMMIT_HASH
#define GIT_COMMIT_HASH "?"
#endif

然后你就可以使用了,例如:

std::string hash = GIT_COMMIT_HASH;

【讨论】:

感谢您的回答。我的问题是关于普通的make(或qmake)而不是CMake。 @Thomas 以防万一你曾经切换到 CMake ;) 。我认为为 CMake 用户编写文档会很好。 ***.com/questions/1435953/… 问题是如果没有对cmake相关文件进行任何更改,该命令将不会自动触发。所以它很快就会失去对提交哈希的跟踪【参考方案2】:

事实证明,我的第三种方法毕竟很好:$(shell) 确实在 make 弄清楚要重建什么之前运行。问题是,在我的隔离测试期间,我不小心将version.h 提交到了存储库,这导致了双重重建。

但仍有改进的空间,感谢@BasileStarynkevitch 和@RenaudPacalet:如果version.h 用于多个文件,最好将哈希存储在version.cpp 文件中,所以我们只需要重新编译一个小文件并重新链接。

所以这是最终的解决方案:

version.h

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

生成文件

$(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$@ $<

感谢大家提出替代方案!

【讨论】:

【参考方案3】:

首先,您可以生成一个虚假的version.h,但只能在version.cpp 中使用它,它定义了在其他地方使用的print_version 函数。在没有任何改变的情况下,每次调用 make 将只花费您一次超快速编译 version.cpp 以及相当长的链接阶段。没有其他重新编译。

接下来,您可能可以通过一些递归 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

当且仅当version.h 已被第一次 make 调用修改(或者无论如何必须重新构建目标)时,$(MAKE) MODE=1 $@ 调用才会起作用。当且仅当提交哈希更改时,第一次 make 调用将修改 version.h

【讨论】:

有趣的想法!也有点吓人。需要一些额外的工作来保留命令行参数(例如要构建的目标)。 @Thomas:是的,还有一些额外的工作。在第一部分添加最后的默认规则很诱人,但它也有不良的副作用。我认为,如果适用的话,最好的选择是构建所有目标的列表并将其分配给ifeq-endif 之前的变量。然后,只需在ifeq-else 中为这些目标添加规则。我更新了我的答案来说明这一点。【参考方案4】:

直接使用.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`

(除了markdown不理解标签,你必须在粘贴中修复它)

并且version.c 配方每次都会运行,因为它的虚假依赖项被假定不存在,但是依赖于 version.c 的东西会检查真实文件,只有在其内容没有的情况下才会真正更新当前版本。

或者您可以在version.h 中生成版本字符串,就像您问题中的“接近第二个”设置一样,重要的是不要告诉make 真实文件是假的。

【讨论】:

当 Git 哈希发生变化时,这将如何导致 version.c 被重建? 哦——我现在明白了,你的问题是你直接使用.PHONY,所以假定真实文件不存在。相反,让你的规则依赖于一个虚假的目标, 请注意,将常量保存在单独编译的对象中可以避免重新编译仅引用它们的所有内容。我错过了您的重新链接成本令人讨厌。 。 . .为了明确回答您的问题,构建任何依赖于version.o 的东西都会重新驱动上述内容,因为 make 从默认规则中找出依赖关系并看到配方每次都必须运行。因此,在某些标头中有一个extern const char version[],您的版本号处理将接近理论上的最小重建开销。【参考方案5】:

为什么不让version.h 依赖于您的.git/index 文件?每当您在暂存区域提交或更改某些内容时都会触及这一点(这通常不会经常发生)。

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

如果您计划在某个时候不使用 Git 进行构建,那么您当然需要更改它...

【讨论】:

这不会选择一个不脏到脏的过渡。 我想你可以将$(shell git ls-files --others) 添加到依赖列表来解决这个问题。但我认为这种解决方案的简单性值得权衡。【参考方案6】:

我建议生成一个很小的自给自足的 C 文件 version.c 定义一些全局变量,并确保在 myapp 可执行文件的每个成功链接处重新生成它。

所以在你的makefile中

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

然后有一些 C++ 标头声明它:

extern "C" const char version_git_commit[];

顺便说一句,查看我的bismon 存储库(提交c032c37be992a29a1e),它的Makefile,目标文件__timestamp.c 以获得灵感。请注意,对于二进制可执行文件 bismonion 目标,make 在每次成功链接后删除 __timestamp.c

您可以改进您的Makefile 以在每次成功链接可执行文件后删除version.cversion.o(例如,在您的myapp 可执行文件的一些$(LINK.cc) 行之后)。因此,您的 makefile 中有:

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

所以您每次只能重建您的version.cversion.o,这非常快。

【讨论】:

【参考方案7】:

您可以通过直接从可执行文件中调用git rev-parse --short HEAD 命令来获取它

这是我所做的:

在 CMakeLists.txt 中

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

在您的源文件中:


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


【讨论】:

谢谢,但在编译时需要它,而不是在运行时。 --version 通常不会从源存储库中调用。

以上是关于如何使 Git 提交哈希在 C++ 代码中可用而无需重新编译?的主要内容,如果未能解决你的问题,请参考以下文章

sh 如何在Git中检索当前提交的哈希值?

gitmerge如何只提交自己的一部分代码

如何使 '<?=' 在 C++ 中可用? [复制]

GIT 在特定提交之前获取提交哈希

如何在 Git 历史记录中 grep(搜索)已提交的代码

给定修复的提交哈希,我怎么能弄清楚代码是如何修复的? [复制]