Git 2.56 的 fetch.followRemoteHEAD:一条全局配置修好所有 remote 的过期 origin/HEAD
参考:git-fetch 文档 2.56.0、git 源码仓库。
我有个两年前克隆的仓库,origin/HEAD 还指向 origin/master,而项目早就把默认分支换成 main 了。它从来没自己修好过。这之后的每一次 git fetch 都拉到了正确的提交,却把那个符号引用原封不动留在原地。
几周前发布的 Git 2.56 新增了一个配置项 fetch.followRemoteHEAD,按设计就是来解决这个问题的:给你一个全局开关,而不是每个 remote 单独设一遍。我从源码编译了三个版本 —— Ubuntu 打包的 2.43.0、我自己编的 2.55.0、以及 2.56.0 —— 拿它们对着几个本地裸仓库跑,看这个设置实际做了什么、没做什么,以及取错值的时候会发生什么。
复现过期的符号引用
先确认这个 bug 我手上就有一份。我在裸仓库 HEAD 指向 main 的时候克隆它,然后把远端默认分支改名成 trunk,也就是 GitHub 在设置页改默认分支时干的事:
$ git clone remote2 clone-create -q
$ git -C clone-create symbolic-ref refs/remotes/origin/HEAD
refs/remotes/origin/main
$ git --git-dir=remote2 branch -m main trunk
$ git -C clone-create fetch
From /tmp/gittest/remote2
* [new branch] trunk -> origin/trunk
$ git -C clone-create symbolic-ref refs/remotes/origin/HEAD
refs/remotes/origin/main
fetch 成功了,新分支也拉到了。origin/HEAD 仍然指向 origin/main,一个远端已经不存在了的分支。这就是 fetch.followRemoteHEAD 的默认值 create 的行为:只有在符号引用还不存在时才会创建它。一旦存在,就再也不会被更新,永久如此,所有带这个设置的 Git 版本都是这样。
把 per-remote 的值设成 warn 也不会修复它,它只是提示你:
hint: Run 'git remote set-head origin trunk' to follow the change, or modify
hint: either of the 'remote.origin.followRemoteHEAD' or 'fetch.followRemoteHEAD'
hint: configuration variables to handle the situation differently.
'HEAD' at 'origin' is 'trunk', but we have 'main' locally.
per-remote 设成 always 则符合预期:下一次 fetch 会静默把符号引用改指到 trunk。这几种模式(create、warn、always,加上 never)在 2.56 之前就存在了。2.56 的新东西是:你可以用 fetch.followRemoteHEAD 一次性给所有这些行为设默认值,不用再手动往每个 remote 的配置里写 remote..followRemoteHEAD。
全局设置真能省掉 per-remote 配置吗
我把 fetch.followRemoteHEAD 设为 always,写在一个由 GIT_CONFIG_GLOBAL 指向的配置文件里,然后往一个新仓库加了三个 remote 并全部 fetch:
$ git config --global fetch.followRemoteHEAD always
$ git remote add r1 ../remote1 # HEAD -> trunk
$ git remote add r2 ../remote2 # HEAD -> trunk
$ git remote add r3 ../remote3 # HEAD -> main
$ git fetch --all
...
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/trunk
$ git symbolic-ref refs/remotes/r2/HEAD
refs/remotes/r2/trunk
$ git symbolic-ref refs/remotes/r3/HEAD
refs/remotes/r3/main
$ grep -c followRemoteHEAD .git/config
0
三个 remote,零行 per-remote 配置,三个 HEAD 都跟对了。但对一个还没有符号引用的全新 remote 来说,旧的默认行为 create 也能做到这一点。
真正的测试是之后发生的事:一个我已经存在的 remote 再次更换默认分支,而任何地方都没有新写配置:
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/trunk
$ git --git-dir=../remote1 branch -m trunk develop
$ git fetch r1
From ../remote1
* [new branch] develop -> r1/develop
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/develop
$ git config --get-regexp 'remote\.r1\..*'
remote.r1.url ../remote1
remote.r1.fetch +refs/heads/*:refs/remotes/r1/*
这才是实际改进:一行全局配置,设一次,就让每个 remote 的 HEAD 无限期保持最新,且从不碰该 remote 自己的配置块。在 2.56 之前,等价做法是给你关心的每一个 remote 手动加一行 remote..followRemoteHEAD always —— 而且通常是在你注意到问题之后才加。
它在上个月的 Git 上完全没有作用
为了确认这个全局键是真新增的,而不只是刚补上文档,我把同一个 GIT_CONFIG_GLOBAL 文件指向 Git 2.55.0:
$ git --version
git version 2.55.0
$ git fetch r1 # first fetch, symref doesn't exist yet, 'create' kicks in
* [new branch] develop -> r1/develop
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/develop
$ git --git-dir=../remote1 branch -m develop final
$ git fetch r1 # second fetch, remote renamed again
* [new branch] final -> r1/final
$ git symbolic-ref refs/remotes/r1/HEAD
refs/remotes/r1/develop
同一个全局配置文件,同一次改名,同一个 remote。在 2.55 上,第二次改名之后 HEAD 就卡在 develop 不动了,因为 2.55 只认 per-remote 键。它对无法识别的全局设置不报错,只是从不去读它。第一次 fetch 之所以成功,是默认的 create 行为在一个从未见过的 remote 上正常干活,不是全局设置在起作用。
它不接受的取值
per-remote 设置接受一个全局设置不接受的取值:warn-if-not-$branch,它的行为像 warn,但当远端 HEAD 仍然匹配你指定的分支时保持安静。我尝试把它设在全局层:
$ git config --global fetch.followRemoteHEAD warn-if-not-main
$ git fetch r3
warning: unrecognized fetch.followRemoteHEAD value 'warn-if-not-main' ignored
From ../remote3
* [new branch] newmain -> r3/newmain
$ echo $?
0
没有报错,退出码也是 0,只有一条 warning,然后静默回退到 create。如果你把这条写进全局配置、指望它对所有 remote 抑制某个具体分支名的警告,它会悄悄不这么做。
我也检查了两个设置之间的优先级:
$ git config --global fetch.followRemoteHEAD never
$ git --git-dir=../remote2 branch -m trunk r2final
$ git fetch r2 # no per-remote override: stays stale
$ git symbolic-ref refs/remotes/r2/HEAD
refs/remotes/r2/trunk
$ git config remote.r2.followRemoteHEAD always
$ git --git-dir=../remote2 branch -m r2final r2final2
$ git fetch r2 # per-remote override: updates
$ git symbolic-ref refs/remotes/r2/HEAD
refs/remotes/r2/r2final2
两个方向都和文档一致。
开销,以及它不告诉你的事
我对着本地文件系统 remote,分别在 create 和 always 下各计时了五次 fetch:
| mode | run 1 | run 2 | run 3 | run 4 | run 5 |
|---|---|---|---|---|---|
| create | 11ms | 11ms | 11ms | 11ms | 11ms |
| always | 11ms | 11ms | 12ms | 11ms | 11ms |
没有可测量的差异。这是本地文件系统传输,所以只能说:在一次本来就要发生的 fetch 之上,多写一个 ref 的开销可以忽略。
更有意思的"开销"是 always 模式做了什么的时候不给你任何反馈:
$ git fetch r3
From ../remote3
* [new branch] finalbranch -> r3/finalbranch
$ echo $?
0
不管 origin/HEAD 有没有被静默改指,这段输出都一模一样。想知道它发生了,只能事后自己用 git symbolic-ref 或 git remote show 去查。
我中途搞错的地方
我测试全局默认值的第一版做法是:加三个全新的 remote,事先设好 fetch.followRemoteHEAD always,fetch 全部三个,然后确认三个 HEAD 引用都正确、且没有任何 per-remote 配置。结果确实如此,有那么几分钟我把这当成了这个特性可用的证据。
它什么都证明不了。旧默认值 create 本来就会给任何还没有 HEAD 符号引用的 remote 创建一个正确的。三个全新 remote、此前都没有 HEAD,恰恰是 create 和 always 结果完全相同的场景。我是在第二次重读 fetch.followRemoteHEAD 文档时才发现的,也正是这一点让我回头重跑测试:改用一个已经被 fetch 过一次、之后又换了默认分支的 remote。
自己跑一遍
这需要从源码编译 Git,因为 Ubuntu 打包的 Git(这里是 2.43.0)根本还没有 per-remote 设置。四核上编译要几分钟。
sudo apt-get install -y build-essential libssl-dev libcurl4-openssl-dev libexpat1-dev gettext zlib1g-dev
git clone --depth 1 --branch v2.56.0 https://github.com/git/git /tmp/git-256
cd /tmp/git-256 && make configure && ./configure --prefix=/opt/git-2.56 && make -j4 && make install
mkdir /tmp/gittest && cd /tmp/gittest
git init --bare -b main remote-a
git clone remote-a seed -q && cd seed && git commit --allow-empty -qm init && git push -q origin main
cd .. && rm -rf seed
GIT=/opt/git-2.56/bin/git
$GIT clone remote-a work -q
git --git-dir=remote-a branch -m main trunk # simulate a default-branch rename
cd work
$GIT fetch # pulls trunk, origin/HEAD stays on main
$GIT symbolic-ref refs/remotes/origin/HEAD # -> refs/remotes/origin/main, stale
$GIT config remote.origin.followRemoteHEAD always
git --git-dir=../remote-a branch -m trunk final
$GIT fetch # now it follows
$GIT symbolic-ref refs/remotes/origin/HEAD # -> refs/remotes/origin/final
如果你维护的 remote 不止两三个,或者你负责一个 CI 镜像、要克隆的那些仓库的主人随时可能把 master 改名成 main,那就在全局配置里把 fetch.followRemoteHEAD 设成 always 一次,然后别再按 remote 去想了。只是别指望 warn-if-not-$branch 在那里能用,也别指望它悄悄帮你修好东西时会输出什么。