在代码中预处理git信息

在代码中预处理git信息

在开发过程中经常需要在编译期做一些处理,此时经常的方案一般是用宏或者其他预定义的功能。

在cpp,dart和rust中分别提供了不同程度的能力。

比如从环境中拿到一些变量的值,作为变量或者按照条件编译。

  • 像把git提交的id和时间写入到程序和help或者version文本中。
  • 或者根据环境中的变量来改变编译流程。

以获取git提交信息作为例子,总结一下三种语言提供的能力有何不同。

git

先说说如何获取提交id这类的信息吧。

一般说来关心的git信息只有三种:

  • Commit hash,或者叫revision,是指每次提交对应的id hash,这玩意可以完全记录下来,也可以记录前面七个字母。
    • 大概七个字母即可,似乎八个也是正确格式,但是github上显示的短hash是七个字母,这里就也以七个字母为准。
  • Commit time,提交时间,什么时候提交的。
  • Tag,也就是当前或者最近一次git tag打上的标签,这个是软件的版本。
    • 其实还有一点,如果分发不在tag上的提交的话,建议再加上一点额外的信息:记录一下当前提交比最新的tag多几个提交,有点类似patch version的感觉。
    • 举个例子:比如昨天提交完,打了一个tag叫v1.2,今天又提交了一次,那么这次提交比昨天的tag多了一次提交,如果今天的这个提交要作为nightly版本发布的话,最好提供类似v1.2+1或者v1.2-今天的commit-id。可以一眼看出多了多少提交。
    • 否则的话,虽然有git hash,也会让人迷惑:明明是一样的tag版本,怎么提交id还不一样呢。
    • 当然,如果没有nightly版本,所有发布的版本都是正好在tag上,可以不做这个。

那么如何获取各项信息呢?

短的git hash

运行:git --no-pager show --oneline --format=%h -s HEAD

输出:29d6fc0

由于高版本的git在输出时会使用pager(就是可以翻页,然后退出的功能,类似less命令),有可能导致在脚本或者构建过程中使用git命令的时候获取不到正确的输出,因此加上--no-pager保证stdout里的信息的想要的。

然后format呢类似date命令的format,详细定义见man手册。

长的git hash

运行:git --no-pager show --oneline --format=%H -s HEAD

输出:29d6fc099e3042331f8076115752d409ed70b067

git提交时间

运行:git --no-pager show --oneline --format=%cd --date=format:"%F %T %z" -s HEAD

输出:2023-09-07 05:04:03 +0800

这里的时间格式就更像date的了,可以自定义,还是详见man手册。

最近的git tag

运行:git describe --abbrev=0 --tags

输出:v1.5.0

注意如果没有tag,stdout会为空,stderr有错误信息“致命错误:没有发现名称,无法描述任何东西。”,这种情况需要单独处理,比如把版本号手动设置成v0.0.0

距离最近的git tag有几次提交:git log --pretty=oneline TAG_ID...COMMIT_ID | wc -l | xargs expr 1 +

略复杂一点,TAG_ID是之前获得的当前最近的tag的名字,比如v1.6.0COMMIT_ID是之前获得的当前提交的commit hash,比如29d6fc0

然后这个git命令会把从TAG_IDCOMMIT_ID的所有提交打印出来,每个提交占一行,不包括TAG_ID所在的那一次提交。

最后再用wc -l统计一下行数,就可以作为到TAG_ID的距离

那么最后为什么还要用expr加上1呢?其实是喜好问题,如果加上1,当TAG_IDCOMMIT_ID实际是一次提交的时候,获取到的距离是1,最终可以打印类似v1.5.0-release1。不加expr的话最终是v1.5.0-release0。我个人更喜欢这个修订号从1开始,于是加上1了,不加也完全可以。

因为各种包管理的包版本,在这个小的修订号上都是从1开始。

这部分的获取方式不唯一,git提供的命令太多了,选一个喜欢的就好。

有个地方忘了说,如果环境中git版本很低,不支持pager,那加了--no-pager反而会报错,不认识这个参数嘛。但是那种情况很少见,因此不考虑了。

至少我记得ubuntu1804上都支持--no-pager

CPP

直接用宏即可,比如make的时候添加参数-DMY_MACRO=1后源码中会多出一个叫MY_MACRO的值为1的宏。

然后……随便写个例子。

1
2
3
4
5
#if MY_MACRO
#include"my_header1.h"
#else
#include"my_header2.h"
#endif

里面用#if#ifdef其实都可以,看想用宏表达什么意思吧,是类似int的数值还是bool一样“有或者咩有”。

好吧,这个不是今天的重点,重点是如何自动地把这些git信息随着开发自动更新,手动加宏也太丑了。

这个时候还是要请出没有人喜欢但是又不得不用的cmake。

cmake内可以执行命令,可以获取执行的命令的输出,还可以加宏,完全满足需求。

 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
# Get git info
find_package(Git QUIET)
if (GIT_FOUND)
    set(GIT_COMMIT_TIME "")
    set(GIT_COMMIT "")
    set(GIT_COMMIT_LONG "")

    # GIT_BRANCH   = $$system(git rev-parse --abbrev-ref HEAD)
    execute_process(
            #COMMAND ${GIT_EXECUTABLE} --no-pager log -1 --pretty=format:%cd --date=format:"%F %T %z"
            COMMAND ${GIT_EXECUTABLE} --no-pager show --oneline --format=%cd --date=format:"%F %T %z" -s HEAD
            OUTPUT_VARIABLE GIT_COMMIT_TIME
            OUTPUT_STRIP_TRAILING_WHITESPACE
            ERROR_QUIET
            WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    )
    execute_process(
            COMMAND ${GIT_EXECUTABLE} --no-pager show --oneline --format=%h -s HEAD
            OUTPUT_VARIABLE GIT_COMMIT
            OUTPUT_STRIP_TRAILING_WHITESPACE
            ERROR_QUIET
            WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    )
    execute_process(
            COMMAND ${GIT_EXECUTABLE} --no-pager show --oneline --format=%H -s HEAD
            OUTPUT_VARIABLE GIT_COMMIT_LONG
            OUTPUT_STRIP_TRAILING_WHITESPACE
            ERROR_QUIET
            WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    )
    execute_process(
            COMMAND ${GIT_EXECUTABLE} describe --abbrev=0 --tags
            OUTPUT_VARIABLE GIT_VERSION_TAG
            OUTPUT_STRIP_TRAILING_WHITESPACE
            ERROR_QUIET
            WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    )
    execute_process(
            COMMAND ${GIT_EXECUTABLE} log --pretty=oneline ${GIT_VERSION_TAG}...${GIT_COMMIT}
            COMMAND wc -l
            COMMAND xargs expr 1 +
            OUTPUT_VARIABLE GIT_RELEASE_COUNT
            OUTPUT_STRIP_TRAILING_WHITESPACE
            #            ERROR_QUIET
            WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    )
    add_definitions(-DAPP_COMMIT_TIME=${GIT_COMMIT_TIME})
    add_definitions(-DAPP_COMMIT="${GIT_COMMIT}")
    add_definitions(-DAPP_COMMIT_LONG="${GIT_COMMIT_LONG}")
    add_definitions(-DAPP_VERSION_TAG="${GIT_VERSION_TAG}")
    add_definitions(-DAPP_RELEASE_COUNT="${GIT_RELEASE_COUNT}")
    message(STATUS "Git commit time:" ${GIT_COMMIT_TIME})
    message(STATUS "Git commit:" ${GIT_COMMIT})
    message(STATUS "Git commit full name:" ${GIT_COMMIT_LONG})
    message(STATUS "Git version tag:" ${GIT_VERSION_TAG})
    message(STATUS "Git release count:" ${GIT_RELEASE_COUNT})
else ()
    message(WARNING "Git not found")
endif ()

使用的git命令就是刚才说的命令。不同点在于最后的管道要分开写成三个COMMAND。

上述配置把git的commit hash以及提交时间定义成宏,之后在源码里直接使用APP_COMMIT_TIME等宏即可。

需要注意的是,这玩意是在cmake里的,也就是说如果只make,不跑cmake,这些宏的定义不会更新。

并且,在提交之后,需要重新cmake一次,以生成最新的信息,不然宏的值还是老的。

Dart

dart虽然是个易上手的尽量简单的语言,也提供了类似宏的功能,叫dart define。

dart define不是某个包,是语言内置的功能,在fluter run或者flutter build的时候,加上参数--dart-define=MY_MACRO="MACRO_VALUE"以后,在源码里通过String.fromEnvironment("MY_MACRO")可以获取到值MACRO_VALUE。

有一个地方可能会引起疑惑,因为String.fromEnvironment显然是运行期执行的代码,那么我运行的时候如果手动设置环境变量MY_MACRO=MACRO_VALUE的话,岂不是把编译时候的覆盖掉了?

其实不然,如果编译期指定了MY_MACRO的值,那么编译的时候String.fromEnvironment可以获取到,会被编译器优化成写死的值,不需要担心运行时被覆盖。

但是直接在命令里加dart define也非常麻烦,那就写个脚本吧。

 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
#!/bin/bash

set -ex

COMMAND=""
APP_VERSION=""
GIT_COMMIT_TIME=$(git --no-pager show --oneline --format=%cd --date=format:"%F %T %z" -s HEAD)
GIT_COMMIT_ID=$(git --no-pager show --oneline --format=%h -s HEAD)
GIT_COMMIT_ID_LONG=$(git --no-pager show --oneline --format=%H -s HEAD)
FLUTTER_VERSION=$(flutter --version | sed -n 's/Flutter \([0-9\.]*\).*/\1/p')
DART_VERSION=$(dart --version | sed -n 's/Dart SDK version: \([0-9\.]*\).*/\1/p')

if [ -f ../pubspec.yaml ];then
	PUBSPEC_FILE="../pubspec.yaml"
elif [ -f pubspec.yaml ];then
	PUBSPEC_FILE="pubspec.yaml"
fi
APP_VERSION=$(cat ${PUBSPEC_FILE} | sed -n 's/version: \([0-9\.\+]*\).*/\1/p')

COMMAND="flutter $*"

${COMMAND} \
	--dart-define=GIT_COMMIT_TIME="${GIT_COMMIT_TIME}" \
	--dart-define=GIT_COMMIT_ID="${GIT_COMMIT_ID}" \
	--dart-define=GIT_COMMIT_ID_LONG="${GIT_COMMIT_ID_LONG}" \
	--dart-define=FLUTTER_VERSION="${FLUTTER_VERSION}" \
	--dart-define=DART_VERSION="${DART_VERSION}" \
	--dart-define=APP_VERSION="${APP_VERSION}" \

这里不光获取到了git提交的信息,还把flutter和dart的信息也加进去了。

用的时候只需要:

1
2
3
4
5
6
Text(
  String.fromEnvironment(
      'GIT_COMMIT_ID',
      defaultValue: 'unknown',
    ),
  )

非常方便,而且安全。

但是!

但是dart的这个define没法做条件编译。

虽然有网上的文章说可以条件编译,比如:

1
2
import "xxx.dart" 
  if yyy "yyy.dart";

类似这个,但是只能区分web平台和原生平台,无法区分是windows还是linux还是android。

这算个毛线条件编译,如果有条件编译的话,做package的时候就不需要分成好几个package 比如xxx_windows,xxx_linux了。

分开太丑了,而且不好调试,也不好维护,有传言说dart会支持元编程,但是现在还不行,等等吧。

另外如果是windows平台,还需要写powershell完成这个功能。

太折磨了,就一个区分平台,有时候明明只有一点区别却非要分成多个package,我现在在做的一个原生的插件就因为这个很难同时在win和linux上编译,仅仅因为ffi的时候参数类型有区别。 windows是utf16,其他平台是utf8。

Rust

Rust的方案在我看来是上述两个方案的折中吧,既是原生支持的功能,也需要跑一些命令比较麻烦。

在Rust里,这部分可以通过build.rs这个功能完成,在里面跑一些命令,获取输出,保存成变量,然后代码里引入,使用。

build.rs:

  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
use std::env;
use std::ffi::OsStr;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;

#[allow(clippy::too_many_lines)]
fn main() {
    let out_dir = env::var("OUT_DIR").unwrap();

    let git_commit_time = run_command(
        "git",
        [
            "--no-pager",
            "show",
            "--oneline",
            "--format=%cd",
            "--date=format:%F",
            "-s",
            "HEAD",
        ],
    )
    .0;

    let git_commit_time_long = run_command(
        "git",
        [
            "--no-pager",
            "show",
            "--oneline",
            "--format=%cd",
            "--date=format:%F %T %z",
            "-s",
            "HEAD",
        ],
    )
    .0;

    let git_commit_revision_long = run_command(
        "git",
        [
            "--no-pager",
            "show",
            "--oneline",
            "--format=%H",
            "-s",
            "HEAD",
        ],
    )
    .0;

    let git_commit_revision = run_command(
        "git",
        [
            "--no-pager",
            "show",
            "--oneline",
            "--format=%h",
            "-s",
            "HEAD",
        ],
    )
    .0;

    let git_tag_data = run_command("git", ["describe", "--abbrev=0", "--tags"]).0;

    let git_tag = if git_tag_data.is_empty() {
        String::from("0.0.0")
    } else {
        String::from_utf8(git_tag_data).unwrap()
    };

    let mut data = String::new();
    data.push_str(make_trimmed_str_var_from_bytes("GIT_COMMIT_TIME", git_commit_time).as_str());
    data.push_str(
        make_trimmed_str_var_from_bytes("GIT_COMMIT_TIME_LONG", git_commit_time_long).as_str(),
    );
    data.push_str(
        make_trimmed_str_var_from_bytes("GIT_COMMIT_REVISION", git_commit_revision).as_str(),
    );
    data.push_str(
        make_trimmed_str_var_from_bytes("GIT_COMMIT_REVISION_LONG", git_commit_revision_long)
            .as_str(),
    );
    data.push_str(make_trimmed_str_var("GIT_TAG_VERSION", &git_tag).as_str());

    let dst_path = PathBuf::from(out_dir).join("constants.generated.rs");
    generate_file(dst_path, data);
}

fn generate_file<P: AsRef<Path>, D: AsRef<[u8]>>(path: P, data: D) {
    let mut f = OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(true)
        .open(path)
        .unwrap();
    f.write_all(data.as_ref()).unwrap();
}

fn run_command<S, I>(command: &str, args: I) -> (Vec<u8>, Vec<u8>)
where
    S: AsRef<OsStr>,
    I: IntoIterator<Item = S>,
{
    let cmd_output = Command::new(command).args(args).output().unwrap();
    let cmd_stdout = cmd_output.stdout;
    let cmd_stderr = cmd_output.stderr;
    (cmd_stdout, cmd_stderr)
}

fn make_trimmed_str_var(name: &str, value: &str) -> String {
    format!("pub const {name}: &'static str = \"{value}\";\n",)
}

fn make_trimmed_str_var_from_bytes(name: &str, value: Vec<u8>) -> String {
    let v = format!(
        "pub const {name}: &'static str = \"{}\";\n",
        String::from_utf8(value).unwrap().trim()
    );
    v
}

因为重复的代码太多,还稍微封装了一下。

处理得比较粗糙吧,如果任何一处报错了就直接panic,但是无所谓,按说就不应该panic的。

每次build的时候,会把生成的代码放到target/debug/build/app_name-hash/out/constants.generated.rs

生成类似如下的代码:

1
2
3
4
5
pub const GIT_COMMIT_TIME: &'static str = "2023-09-20";
pub const GIT_COMMIT_TIME_LONG: &'static str = "2023-09-20 17:30:39 +0800";
pub const GIT_COMMIT_REVISION: &'static str = "856d8bc";
pub const GIT_COMMIT_REVISION_LONG: &'static str = "856d8bce9cef73cf134414384319c3bf57dad35c";
pub const GIT_TAG_VERSION: &'static str = "0.0.0";

不需要手动设置OUT_DIR的值,也不要设置。

然后这样去用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
mod constants {
    #![allow(dead_code)]
    include!(concat!(env!("OUT_DIR"), "/constants.generated.rs"));
}

use self::constants::{GIT_COMMIT_REVISION, GIT_COMMIT_TIME, GIT_TAG_VERSION};

lazy_static! {
    static ref VERSION: String =
        format!("{GIT_TAG_VERSION}+{GIT_COMMIT_REVISION} {GIT_COMMIT_TIME}");
}

定义一个当前模块的子模块,叫constants,名字其实随便,也不用担心重名因为是直接引用的内部的变量。

然后引入需要用的那些常量,GIT_COMMIT_TIME等,然后就随便用了。这里因为需要static周期的变量,用了lazy_static。

怎么说呢,这个做法还比较优雅吧,也不算丑。

我这里测试,不管是intellij-rust还是rust-analyzer,在打开项目以后,编制索引的时候会把这些变量编出来,这样也不会报错找不到定义。

感觉完爆之前在flatpak设置rust代理 里提到的*.in,因为那种方案是把这些变量放到里meson.build里,ide感知不到。

总结

三种语言各有优劣吧。

  • cpp的用cmake,宏用起来简单,好配置,功能也强,大多数人也熟悉,但是注意编之前先跑cmake刷新变量的值。
  • dart的就更简单了,跑脚本甚至手动加都行,也足够安全,但是没法条件编译,功能很弱,跨平台的话还需要写不同的脚本。
  • rust的功能很强,和ide集成也和cpp/cmake无异,也可以条件编译,甚至集成外部的代码,只是写起来麻烦一些。
署名 - 非商业性使用 - 禁止演绎 4.0