Support for `git describe` output in a SemVer prerelease string

Hello, I would like to have my project use the current output from git describe as the prerelease component of its SemVer version.

I have a solution that seems to work in all but one corner case. I’m hoping to find a way to resolve the corner case OR to find an alternative workable approach.

I’ve created a git repo to illustrate the issue and facilitate the discussion
https://github.com/ellio167/cmake-and-git-describe. Here is the main CMakeLists.txt file showing a minimal form of my current solution.

cmake_minimum_required(VERSION 3.4)

project(MAIN VERSION 0.2.0 LANGUAGES C)

# FC: means "for conciseness"
find_package(Git)  # FC: assume found
# FC: assume that ${CMAKE_SOURCE_DIR} is git repo toplevel dir

# set configuration to depend on _depend_file; then touch depend with every invocation of make
set(_git_describe_sentinel "git-describe-sentinel")
set(_depend_file "${CMAKE_CURRENT_BINARY_DIR}/${_git_describe_sentinel}-file")
execute_process(COMMAND ${CMAKE_COMMAND} -E touch "${_depend_file}")

set_property(DIRECTORY "${CURRENT_SOURCE_DIR}" APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_depend_file}")
add_custom_target(${_git_describe_sentinel}-target ALL COMMAND ${CMAKE_COMMAND} -E touch "${_depend_file}")

# FC: assume the git index is refreshed
execute_process(
  COMMAND ${GIT_EXECUTABLE} -C "${CMAKE_SOURCE_DIR}" describe --dirty --always
  OUTPUT_STRIP_TRAILING_WHITESPACE
  OUTPUT_VARIABLE _git_describe
  )
set(PROJECT_VERSION_STRING "${PROJECT_VERSION}+${_git_describe}")

add_executable(main "")

add_subdirectory(include)
add_subdirectory(src)

The corner case occurs because when configuration finishes the first time the sentinel file is older than the Makefile. So, when make is called the first time configuration is not triggered. If the git repo working directory is changed (files edited, new commits added, etc.) between the initial configuration and the first execution of make, then incorrect git describe results will be used for the build.

Here is a sequence of commands to reproduce the issue:

% git clone https://github.com/ellio167/cmake-and-git-describe.git
<...>
% cd cmake-and-git-describe.git
% mkdir build
% cd build
% git -C ../ describe --dirty --always
v0.2.0-2-g705268e
% cmake ../
<...>
% printf "/* add comment to main.c */\n" >> ../src/main.c  # repo is now "dirty"
% make
<...>
% ./main  # but main was build without the "-dirty" indicator
version is: 0.2.0
version string is: 0.2.0+v0.2.0-2-g705268e
% make  # now configuration is triggered and correct behavior is obtained
<...>
% ./main
version is: 0.2.0
version string is: 0.2.0+v0.2.0-2-g705268e-dirty
%

One potential solution would be to not create the sentinel file as part of the initial configuration. However, if it does not exist then CMake prunes it from the CMAKE_CONFIGURE_DEPENDS list at the end of the configuration step. So, this does not seem possible (without a change to CMake behavior).

Any help/suggestions would be much appreciated!

1 Like

To make life easier, I opened a PR here: https://github.com/ellio167/cmake-and-git-describe/pull/1

Generally speaking, don’t use execute_process to generate sources. The best command for that is add_custom_command, but it isn’t applicable here since you always need the command to run (need to ask git what changed). Therefore, you need to use add_custom_target + add_dependencies instead.

Here, add_custom_target calls a CMake script that finds Git, checks the describe string against an on-disk cache of the last run and, if they differ, writes out a version.h header.

There are some other changes, here, too. The project(PROJECT_NAME) command creates variables PROJECT_SOURCE_DIR and <PROJECT-NAME>_SOURCE_DIR that you should use instead of CMAKE_SOURCE_DIR

You can also use the REQUIRED mode of find_package() to fail when unable to find a package (Git in this case).

Your top-level CMake should be very simple, just:

cmake_minimum_required(VERSION 3.4)
project(MAIN VERSION 0.2.0 LANGUAGES C)

add_executable(main)

add_subdirectory(include)
add_subdirectory(src)

Then your include/CMakeLists.txt should look like:

add_custom_target(
  update-version-h
  COMMAND ${CMAKE_COMMAND}
    -DPROJECT_SOURCE_DIR=${PROJECT_SOURCE_DIR}
    -DPROJECT_VERSION=${PROJECT_VERSION}
    -P ${CMAKE_CURRENT_SOURCE_DIR}/update-version.cmake
  DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/update-version.cmake
  VERBATIM)

add_dependencies(main update-version-h)

target_include_directories(
  main
  PRIVATE
    "${CMAKE_CURRENT_SOURCE_DIR}"
    "${CMAKE_CURRENT_BINARY_DIR}")

This creates a target that always runs a script (code below) that writes version.h only when the version has actually changed. The main target is set to explicitly depend on it. Here’s the script:

cmake_minimum_required(VERSION 3.4)

# Expects variables:
#   -DPROJECT_SOURCE_DIR - from parent build
#   -DPROJECT_VERSION    - from parent build

find_package(Git REQUIRED QUIET)

execute_process(
  COMMAND ${GIT_EXECUTABLE} -C "${PROJECT_SOURCE_DIR}" describe --dirty --always
  OUTPUT_STRIP_TRAILING_WHITESPACE
  OUTPUT_VARIABLE new_version)

if (EXISTS ".sentinel")
  file(READ ".sentinel" old_version)
endif ()

set(PROJECT_VERSION_STRING "${PROJECT_VERSION}+${new_version}")

if (NOT "${old_version}" STREQUAL "${new_version}")
  file(WRITE ".sentinel" "${new_version}")
  file(WRITE "version.h.in" [[
#define VERSION "@PROJECT_VERSION@"
#define VERSION_STRING "@PROJECT_VERSION_STRING@"
]])
  configure_file("version.h.in" "version.h" @ONLY)
endif ()

I tested this with the following script:

#!/bin/bash

echo "Dirty between configure and first make"
rm -rf build
cmake -DCMAKE_BUILD_TYPE=Debug -S . -B build >/dev/null 2>&1
echo "// comment" >> src/main.c
cmake --build build >/dev/null 2>&1
./build/main

echo

echo "Reset repo and rebuild"
git checkout -- .
cmake --build build >/dev/null 2>&1
./build/main

echo

echo "Remove build dir and fresh build"
rm -rf build
cmake -DCMAKE_BUILD_TYPE=Debug -S . -B build >/dev/null 2>&1
cmake --build build >/dev/null 2>&1
./build/main

and this is the output:

alex@Alex-Desktop:~/cmake-and-git-describe$ ./test.sh
Dirty between configure and first make
version is: 0.2.0
version string is: 0.2.0+v0.2.0-4-g9ec66b2-dirty

Reset repo and rebuild
version is: 0.2.0
version string is: 0.2.0+v0.2.0-4-g9ec66b2

Remove build dir and fresh build
version is: 0.2.0
version string is: 0.2.0+v0.2.0-4-g9ec66b2
1 Like

@alex Thanks for the suggestion. This is interesting. However, in your solution the _get_describe variable is not set in the top-level CMakeLists.txt file scope. In my real use-case (not the simplified example I’ve posted) the contents of the _get_describe variable are used multiple times in various other parts of the configuration (via other add_subdirectory() calls). It would be rather difficult, I think, to disentangle all the uses of this value and combine them all together in a custom target.

That’s the trouble with simplified examples that don’t show your actual use-case. I couldn’t tell you weren’t just trying to generate a header. :man_shrugging:


I would suggest that you first try. Otherwise you might be able to rig something up by using file(GLOB ... CONFIGURE_DEPENDS ...) such that the glob changes between the first configure and the first make. Maybe something like:

file(GLOB _depend_file CONFIGURE_DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/*-sentinel")
if (NOT EXISTS "${_depend_file}")
  set(_depend_file "${CMAKE_CURRENT_BINARY_DIR}/git-describe-sentinel")
else ()
  file(READ "${_depend_file}" _old_describe)
endif ()

set_property(DIRECTORY "${CURRENT_SOURCE_DIR}" APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_depend_file}")

execute_process(...)
set(PROJECT_VERSION_STRING "${PROJECT_VERSION}+${_git_describe}")

if (NOT "${_old_describe}" STREQUAL "${_git_describe}")
  file(WRITE "${_depend_file}" "${_git_describe}")
endif ()

I should note that your custom command will not always run. Doing cmake --build . --target main will avoid it because that doesn’t build ALL and main doesn’t depend on git-describe-sentinel-target. That’s another edge case that makes this “always reconfigure” approach brittle and very much not recommended.

But unfortunately, I’ve spent all the time I can on this. Good luck!

Thanks Alex, I appreciate the time you spent on this! (It’s always hard to find the right balance in creating a simplified example.)

Indeed, you are right. Thanks for pointing this out.

Thanks for this; I was unaware of this 3.12 and above feature.

With Alex’s help and my own additional investigations, I’ve gained some further insight and made some progress. First, I discovered that CMake itself will add a dirty flag to its version string (see ./Source/CMakeVersion.cmake. However, there is no attempt to guarantee that it matches the current working tree state at build-time. So, one can take this as president from the experts for the “right way” to do things.

However, if one still wants to reconfigure every time, I’ve made two additional adjustments to my solution. If available, use the file(GLOB <var> CONFIGURE_DEPENDS ...) mechanism; otherwise, If a system touch command is present (assuming it is POSIX compliant) then the modification/access time can be set into the future. This version of my solution is v0.4.0 in the github repo, and has main CMakeLists.txt

cmake_minimum_required(VERSION 3.4)

project(MAIN VERSION 0.4.0 LANGUAGES C)

find_package(Git)
unset(GIT_FOUND)
if(GIT_FOUND)
  execute_process(COMMAND ${GIT_EXECUTABLE} -C "${PROJECT_SOURCE_DIR}" rev-parse --show-toplevel
    OUTPUT_STRIP_TRAILING_WHITESPACE
    OUTPUT_VARIABLE _toplevel
    RESULT_VARIABLE _isGitRepo
    ERROR_QUIET
    )

  if((_isGitRepo EQUAL 0) AND ("${_toplevel}" STREQUAL "${PROJECT_SOURCE_DIR}"))
    # set configuration to depend on _depend_file
    set(_git_describe_sentinel "git-describe-sentinel")
    set(_depend_file "${CMAKE_CURRENT_BINARY_DIR}/${_git_describe_sentinel}-file")

    find_program(_touch touch)
    if(${CMAKE_MINOR_VERSION} GREATER 11)  # use file(GLOB <var> CONFIGURE_DEPENDS ...) mechanism
      if(EXISTS "${_depend_file}")
        file(REMOVE "${_depend_file}")
      endif()
      file(GLOB _t CONFIGURE_DEPENDS "${_depend_file}")
      file(TOUCH "${_depend_file}")
    elseif(_touch)  # use system 'touch' with future timestamp and CMAKE_CONFIGURE_DEPENDS mechanism
      string(TIMESTAMP _time "1%m%d%H%M")
      math(EXPR _time "${_time} + 1")
      string(REGEX REPLACE "^.(.*)$" "\\1" _time "${_time}")
      execute_process(COMMAND ${_touch} -t "${_time}" "${_depend_file}")   # set modification/access time 1min in the future
      set_property(DIRECTORY "${CURRENT_SOURCE_DIR}" APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_depend_file}")
    else()  # use CMAKE_CONFIGURE_DEPENDS property mechanism [has a number of corner cases]
      execute_process(COMMAND ${CMAKE_COMMAND} -E touch "${_depend_file}")
      add_custom_target(${_git_describe_sentinel}-target ALL COMMAND ${CMAKE_COMMAND} -E touch "${_depend_file}")
      set_property(DIRECTORY "${CURRENT_SOURCE_DIR}" APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_depend_file}")
    endif()

    execute_process(COMMAND ${GIT_EXECUTABLE} -C "${PROJECT_SOURCE_DIR}" update-index -q --refresh
      TIMEOUT 5
      OUTPUT_QUIET
      ERROR_QUIET
      )
    execute_process(
      COMMAND ${GIT_EXECUTABLE} -C "${PROJECT_SOURCE_DIR}" describe --dirty --always
      OUTPUT_STRIP_TRAILING_WHITESPACE
      OUTPUT_VARIABLE _git_describe
      )
  endif()
endif()

if(_git_describe)
  set(_build_metadata "+${_git_describe}")
else()
  set(_build_metadata "")
endif()
set(PROJECT_VERSION_STRING "${PROJECT_VERSION}${_build_metadata}")

add_executable(main "")

add_subdirectory(include)
add_subdirectory(src)