父仓库记住的是一个提交,不是分支
一句话总结
在子模块中,父仓库记录的是一个提交哈希,而不是分支。 因此子模块总是以 detached HEAD 状态检出;如果拉取了最新版本使用,却没有把这件事提交到父仓库,那么这个变化就只属于你自己。
为什么需要它
多个服务共用同一份代码是很常见的情形,比如公司内部的公共库、协议定义、配置模板等。直接复制很快就会分叉;打成软件包再发布,又会多出一道部署流程。子模块是介于两者之间的答案——把另一个仓库像图钉一样钉在自己仓库的某个目录位置上。
问题在于,很多人还没弄清“钉住”究竟是什么意思就开始用了。不少人以为“父仓库指向那个库的 main 分支”,其实不是。父仓库的树里,该位置放的是一个模式为 160000 的条目,值是提交哈希。默认情况下,分支名也不会写进 .gitmodules。
$ git ls-tree HEAD vendor/lib
160000 commit 9f3c1a4... vendor/lib
这一行就是子模块的全部。明白了这一点,后面所有的行为都能得到解释。
工作原理
.gitmodules 只记录从哪里获取(url)和放在哪里(path),它是一个被跟踪的普通文件。真正使用哪个版本,则由树中的 160000 条目决定。两者职责不同,所以是分开提交的。
进入子模块目录执行 git status,会看到这样的输出。
HEAD detached at 9f3c1a4
父仓库记住的是提交而不是分支,所以检出也是按提交进行的。在这里直接提交,得到的就是一个不属于任何分支的提交,以后很难找回。如果需要在子模块里工作,必须先切到一个分支。
git submodule status 输出的首字符表示状态。
| 首字符 | 含义 |
|---|---|
| (空格) | 父仓库记录的提交与实际检出的提交相同 |
+ |
子模块所在的提交与父仓库记录的不同 |
- |
尚未初始化(目录是空的) |
U |
合并冲突尚未解决 |
- 尤其常见。git clone 默认不会获取子模块,所以克隆下来立刻构建就会因为空目录而失败。可以用 git clone --recurse-submodules 克隆,或者克隆之后执行 git submodule update --init --recursive。平时打开 git config submodule.recurse true,checkout 和 pull 就会连子模块一起处理。
+ 是事故的种子。你在子模块里拉取了最新版本并确认运行良好,但如果没有在父仓库用 git add vendor/lib 把这个变化提交上去,那么只有你自己的机器在使用新代码。同事 clone 之后,拿到的是父仓库记录的旧提交。症状就是典型的“在我电脑上是好的”,而且要很久才能意识到原因出在子模块上。
还有一点。从 git 2.38.1 开始,通过本地路径或 file:// 获取子模块默认被禁止了(CVE-2022-39253),因为恶意仓库可以借助 .gitmodules 让别人机器上的文件被带走。用本地路径做实验,或者在 CI 中使用缓存仓库时,必须显式加上 -c protocol.file.allow=always。
在现场相遇的样子
- CI 构建失败,本地却一切正常。这是 Runner 的 clone 缺了
--recurse-submodules,导致子模块目录是空的。 - 打了发布标签却无法复现。确认标签所指向的父提交中
160000的值对应子模块的哪个提交,有时会发现那个提交在此期间已被子模块仓库的强制推送抹掉了。重写子模块仓库的历史,会使父仓库的“图钉”失效。 - 评审时会看到只有一行的 diff:
Subproject commit 9f3c...。仅凭这一行无法知道改了什么,所以最好预先配置成同时显示提交列表:git diff --submodule=log。
下一项实验要做什么
亲手创建库仓库和父仓库,并把库作为子模块钉进去。先遇到本地路径默认被阻止的情形,再亲眼确认父仓库的树里加入了 160000 条目。然后在库中堆叠新提交,只移动子模块而不提交父仓库,在这种状态下 clone 一份,完整重现同事拿到旧代码的事故。
官方文档是 git-submodule 和 Pro Git 7.11 Submodules。