aboutsummaryrefslogtreecommitdiff
path: root/git_watcher.cmake
blob: fb2058bcfac72015a924a7d513e29c70100784d8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# git_watcher.cmake
# https://raw.githubusercontent.com/andrew-hardin/cmake-git-version-tracking/master/git_watcher.cmake
#
# Released under the MIT License.
# https://raw.githubusercontent.com/andrew-hardin/cmake-git-version-tracking/master/LICENSE


# This file defines a target that monitors the state of a git repo.
# If the state changes (e.g. a commit is made), then a file gets reconfigured.
# Here are the primary variables that control script behavior:
#
#   PRE_CONFIGURE_FILE (REQUIRED)
#   -- The path to the file that'll be configured.
#
#   POST_CONFIGURE_FILE (REQUIRED)
#   -- The path to the configured PRE_CONFIGURE_FILE.
#
#   GIT_STATE_FILE (OPTIONAL)
#   -- The path to the file used to store the previous build's git state.
#      Defaults to the current binary directory.
#
#   GIT_WORKING_DIR (OPTIONAL)
#   -- The directory from which git commands will be run.
#      Defaults to the directory with the top level CMakeLists.txt.
#
#   GIT_EXECUTABLE (OPTIONAL)
#   -- The path to the git executable. It'll automatically be set if the
#      user doesn't supply a path.
#
#   GIT_FAIL_IF_NONZERO_EXIT (OPTIONAL)
#   -- Raise a FATAL_ERROR if any of the git commands return a non-zero
#      exit code. This is set to TRUE by default. You can set this to FALSE
#      if you'd like the build to continue even if a git command fails.
#
#   GIT_IGNORE_UNTRACKED (OPTIONAL)
#   -- Ignore the presence of untracked files when detecting if the
#      working tree is dirty. This is set to FALSE by default.
#
# DESIGN
#   - This script was designed similar to a Python application
#     with a Main() function. I wanted to keep it compact to
#     simplify "copy + paste" usage.
#
#   - This script is invoked under two CMake contexts:
#       1. Configure time (when build files are created).
#       2. Build time (called via CMake -P).
#     The first invocation is what registers the script to
#     be executed at build time.
#
# MODIFICATIONS
#   You may wish to track other git properties like when the last
#   commit was made. There are two sections you need to modify,
#   and they're tagged with a ">>>" header.

# Short hand for converting paths to absolute.
macro(PATH_TO_ABSOLUTE var_name)
    get_filename_component(${var_name} "${${var_name}}" ABSOLUTE)
endmacro()

# Check that a required variable is set.
macro(CHECK_REQUIRED_VARIABLE var_name)
    if(NOT DEFINED ${var_name})
        message(FATAL_ERROR "The \"${var_name}\" variable must be defined.")
    endif()
    PATH_TO_ABSOLUTE(${var_name})
endmacro()

# Check that an optional variable is set, or, set it to a default value.
macro(CHECK_OPTIONAL_VARIABLE_NOPATH var_name default_value)
    if(NOT DEFINED ${var_name})
        set(${var_name} ${default_value})
    endif()
endmacro()

# Check that an optional variable is set, or, set it to a default value.
# Also converts that path to an abspath.
macro(CHECK_OPTIONAL_VARIABLE var_name default_value)
    CHECK_OPTIONAL_VARIABLE_NOPATH(${var_name} ${default_value})
    PATH_TO_ABSOLUTE(${var_name})
endmacro()

CHECK_REQUIRED_VARIABLE(PRE_CONFIGURE_FILE)
CHECK_REQUIRED_VARIABLE(POST_CONFIGURE_FILE)
CHECK_OPTIONAL_VARIABLE(GIT_STATE_FILE "${CMAKE_BINARY_DIR}/git-state-hash")
CHECK_OPTIONAL_VARIABLE(GIT_WORKING_DIR "${CMAKE_SOURCE_DIR}")
CHECK_OPTIONAL_VARIABLE_NOPATH(GIT_FAIL_IF_NONZERO_EXIT TRUE)
CHECK_OPTIONAL_VARIABLE_NOPATH(GIT_IGNORE_UNTRACKED FALSE)

# Check the optional git variable.
# If it's not set, we'll try to find it using the CMake packaging system.
if(NOT DEFINED GIT_EXECUTABLE)
    find_package(Git QUIET REQUIRED)
endif()
CHECK_REQUIRED_VARIABLE(GIT_EXECUTABLE)


set(_state_variable_names
    GIT_RETRIEVED_STATE
    GIT_HEAD_SHA1
    GIT_IS_DIRTY
    GIT_AUTHOR_NAME
    GIT_AUTHOR_EMAIL
    GIT_COMMIT_DATE_ISO8601
    GIT_COMMIT_SUBJECT
    GIT_COMMIT_BODY
    GIT_DESCRIBE
    GIT_BRANCH
    # >>>
    # 1. Add the name of the additional git variable you're interested in monitoring
    #    to this list.
)



# Macro: RunGitCommand
# Description: short-hand macro for calling a git function. Outputs are the
#              "exit_code" and "output" variables. The "_permit_git_failure"
#              variable can locally override the exit code checking- use it
#              with caution.
macro(RunGitCommand)
    execute_process(COMMAND
        "${GIT_EXECUTABLE}" ${ARGV}
        WORKING_DIRECTORY "${_working_dir}"
        RESULT_VARIABLE exit_code
        OUTPUT_VARIABLE output
        ERROR_VARIABLE stderr
        OUTPUT_STRIP_TRAILING_WHITESPACE)
    if(NOT exit_code EQUAL 0 AND NOT _permit_git_failure)
        set(ENV{GIT_RETRIEVED_STATE} "false")

        # Issue 26: git info not properly set
        #
        # Check if we should fail if any of the exit codes are non-zero.
        # Most methods have a fall-back default value that's used in case of non-zero
        # exit codes. If you're feeling risky, disable this safety check and use
        # those default values.
        if(GIT_FAIL_IF_NONZERO_EXIT )
            string(REPLACE ";" " " args_with_spaces "${ARGV}")
            message("${stderr} (${GIT_EXECUTABLE} ${args_with_spaces})")
        endif()
    endif()
endmacro()



# Function: GetGitState
# Description: gets the current state of the git repo.
# Args:
#   _working_dir (in)  string; the directory from which git commands will be executed.
function(GetGitState _working_dir)

    # This is an error code that'll be set to FALSE if the
    # RunGitCommand ever returns a non-zero exit code.
    set(ENV{GIT_RETRIEVED_STATE} "true")

    # Get whether or not the working tree is dirty.
    if (GIT_IGNORE_UNTRACKED)
        set(untracked_flag "-uno")
    else()
        set(untracked_flag "-unormal")
    endif()
    RunGitCommand(status --porcelain ${untracked_flag})
    if(NOT exit_code EQUAL 0)
        set(ENV{GIT_IS_DIRTY} "false")
    else()
        if(NOT "${output}" STREQUAL "")
            set(ENV{GIT_IS_DIRTY} "true")
        else()
            set(ENV{GIT_IS_DIRTY} "false")
        endif()
    endif()

    # There's a long list of attributes grabbed from git show.
    set(object HEAD)
    RunGitCommand(show -s "--format=%H" ${object})
    if(exit_code EQUAL 0)
        set(ENV{GIT_HEAD_SHA1} ${output})
    endif()

    RunGitCommand(show -s "--format=%an" ${object})
    if(exit_code EQUAL 0)
        set(ENV{GIT_AUTHOR_NAME} "${output}")
    endif()

    RunGitCommand(show -s "--format=%ae" ${object})
    if(exit_code EQUAL 0)
        set(ENV{GIT_AUTHOR_EMAIL} "${output}")
    endif()

    RunGitCommand(show -s "--format=%ci" ${object})
    if(exit_code EQUAL 0)
        set(ENV{GIT_COMMIT_DATE_ISO8601} "${output}")
    endif()

    RunGitCommand(show -s "--format=%s" ${object})
    if(exit_code EQUAL 0)
        # Escape \
        string(REPLACE "\\" "\\\\" output "${output}")
        # Escape quotes
        string(REPLACE "\"" "\\\"" output "${output}")
        set(ENV{GIT_COMMIT_SUBJECT} "${output}")
    endif()

    RunGitCommand(show -s "--format=%b" ${object})
    if(exit_code EQUAL 0)
        if(output)
            # Escape \
            string(REPLACE "\\" "\\\\" output "${output}")
            # Escape quotes
            string(REPLACE "\"" "\\\"" output "${output}")
            # Escape line breaks in the commit message.
            string(REPLACE "\r\n" "\\r\\n\\\r\n" safe "${output}")
            if(safe STREQUAL output)
                # Didn't have windows lines - try unix lines.
                string(REPLACE "\n" "\\n\\\n" safe "${output}")
            endif()
        else()
            # There was no commit body - set the safe string to empty.
            set(safe "")
        endif()
        set(ENV{GIT_COMMIT_BODY} "${safe}")
    else()
        set(ENV{GIT_COMMIT_BODY} "") # empty string.
    endif()

    # Get output of git describe
    RunGitCommand(describe --always ${object})
    if(NOT exit_code EQUAL 0)
        set(ENV{GIT_DESCRIBE} "unknown")
    else()
        set(ENV{GIT_DESCRIBE} "${output}")
    endif()
    
    # Convert HEAD to a symbolic ref. This can fail, in which case we just
    # set that variable to HEAD.
    set(_permit_git_failure ON)
    RunGitCommand(symbolic-ref --short -q ${object})
    unset(_permit_git_failure)
    if(NOT exit_code EQUAL 0)
        set(ENV{GIT_BRANCH} "${object}")
    else()
        set(ENV{GIT_BRANCH} "${output}")
    endif()

    # >>>
    # 2. Additional git properties can be added here via the
    #    "execute_process()" command. Be sure to set them in
    #    the environment using the same variable name you added
    #    to the "_state_variable_names" list.

endfunction()



# Function: GitStateChangedAction
# Description: this function is executed when the state of the git
#              repository changes (e.g. a commit is made).
function(GitStateChangedAction)
    foreach(var_name ${_state_variable_names})
        set(${var_name} $ENV{${var_name}})
    endforeach()
    configure_file("${PRE_CONFIGURE_FILE}" "${POST_CONFIGURE_FILE}" @ONLY)
endfunction()



# Function: HashGitState
# Description: loop through the git state variables and compute a unique hash.
# Args:
#   _state (out)  string; a hash computed from the current git state.
function(HashGitState _state)
    set(ans "")
    foreach(var_name ${_state_variable_names})
        string(SHA256 ans "${ans}$ENV{${var_name}}")
    endforeach()
    set(${_state} ${ans} PARENT_SCOPE)
endfunction()



# Function: CheckGit
# Description: check if the git repo has changed. If so, update the state file.
# Args:
#   _working_dir    (in)  string; the directory from which git commands will be ran.
#   _state_changed (out)    bool; whether or no the state of the repo has changed.
function(CheckGit _working_dir _state_changed)

    # Get the current state of the repo.
    GetGitState("${_working_dir}")

    # Convert that state into a hash that we can compare against
    # the hash stored on-disk.
    HashGitState(state)

    # Issue 14: post-configure file isn't being regenerated.
    #
    # Update the state to include the SHA256 for the pre-configure file.
    # This forces the post-configure file to be regenerated if the
    # pre-configure file has changed.
    file(SHA256 ${PRE_CONFIGURE_FILE} preconfig_hash)
    string(SHA256 state "${preconfig_hash}${state}")

    # Check if the state has changed compared to the backup on disk.
    if(EXISTS "${GIT_STATE_FILE}")
        file(READ "${GIT_STATE_FILE}" OLD_HEAD_CONTENTS)
        if(OLD_HEAD_CONTENTS STREQUAL "${state}")
            # State didn't change.
            set(${_state_changed} "false" PARENT_SCOPE)
            return()
        endif()
    endif()

    # The state has changed.
    # We need to update the state file on disk.
    # Future builds will compare their state to this file.
    file(WRITE "${GIT_STATE_FILE}" "${state}")
    set(${_state_changed} "true" PARENT_SCOPE)
endfunction()



# Function: SetupGitMonitoring
# Description: this function sets up custom commands that make the build system
#              check the state of git before every build. If the state has
#              changed, then a file is configured.
function(SetupGitMonitoring)
    add_custom_target(check_git
        ALL
        DEPENDS ${PRE_CONFIGURE_FILE}
        BYPRODUCTS
            ${POST_CONFIGURE_FILE}
            ${GIT_STATE_FILE}
        COMMENT "Checking the git repository for changes..."
        COMMAND
            ${CMAKE_COMMAND}
            -D_BUILD_TIME_CHECK_GIT=TRUE
            -DGIT_WORKING_DIR=${GIT_WORKING_DIR}
            -DGIT_EXECUTABLE=${GIT_EXECUTABLE}
            -DGIT_STATE_FILE=${GIT_STATE_FILE}
            -DPRE_CONFIGURE_FILE=${PRE_CONFIGURE_FILE}
            -DPOST_CONFIGURE_FILE=${POST_CONFIGURE_FILE}
            -DGIT_FAIL_IF_NONZERO_EXIT=${GIT_FAIL_IF_NONZERO_EXIT}
            -DGIT_IGNORE_UNTRACKED=${GIT_IGNORE_UNTRACKED}
            -P "${CMAKE_CURRENT_LIST_FILE}")
endfunction()



# Function: Main
# Description: primary entry-point to the script. Functions are selected based
#              on whether it's configure or build time.
function(Main)
    if(_BUILD_TIME_CHECK_GIT)
        # Check if the repo has changed.
        # If so, run the change action.
        CheckGit("${GIT_WORKING_DIR}" changed)
        if(changed OR NOT EXISTS "${POST_CONFIGURE_FILE}")
            GitStateChangedAction()
        endif()
    else()
        # >> Executes at configure time.
        SetupGitMonitoring()
    endif()
endfunction()

# And off we go...
Main()