Git Submodule 子仓库协作
一次绿色构建为什么会在新机器上失败
发布分支在老构建机上连续通过,新构建机却在 vendor/rules 处报错:有时目录为空,有时是 Permission denied,还有时是 not our ref。开发者进入该目录执行 git pull 后暂时恢复,下一次流水线却构建出另一套依赖。
这不是一个故障,而是四类状态被混成了“代码已经拉下来了”:
超级仓库提交记录了子仓库的哪个 Commit。.gitmodules 声明了从哪里初始化子模块。当前克隆的本地配置实际使用哪个 URL、更新策略和凭证。
子模块工作树现在检出了哪个 Commit,是否还有未提交修改。
Git Submodule 适合这样的关系:两个仓库需要独立历史、独立权限和独立发布,但上层项目必须精确记录已经验证过的下层版本。它不是包管理器,也不会替团队解决兼容性、漏洞升级或跨仓库原子变更。把它用好,第一原则是让每次构建都回答同一个问题:超级仓库记录的 gitlink 指向哪个对象,这个对象能否由所有执行者稳定取得。
Git 实际记录了什么
普通目录在 tree 中记录文件或子 tree,子模块路径记录的是模式为 160000 的 gitlink。它的对象名是另一个仓库中的 Commit ID:
git ls-tree HEAD vendor/component典型输出如下:
160000 commit 8d5c7f6... vendor/component超级仓库没有复制子仓库的文件和历史,也无法仅凭自身对象库解释 8d5c7f6... 的内容。它只承诺“这个版本指向该 Commit”。URL 则保存在顶层 .gitmodules 中。按照 gitmodules 的字段定义,每个条目至少有唯一的 path 和 url:
[submodule "vendor/component"]
path = vendor/component
url = ../component.git
update = checkout
branch = main这些字段的影响不同:
| 字段 | 解决的问题 | 关键边界 |
|---|---|---|
path | 子模块出现在超级仓库的哪个路径 | 路径唯一,不能以 / 结尾 |
url | 初始化时从哪里取得子仓库 | 可为绝对 URL,或相对超级仓库默认远端解析 |
update | submodule update 默认怎样移动工作树 | checkout、merge、rebase、none;不允许从 .gitmodules 分发自定义命令 |
branch | update --remote 跟踪哪个远端分支 | 不改变 gitlink 仍固定 Commit 的事实;. 表示跟随超级仓库当前分支同名分支 |
fetchRecurseSubmodules | fetch 是否递归进入子模块 | 本地配置和命令行可以覆盖 |
ignore | status/diff 如何显示子模块修改 | 只是可见性策略,不能阻止漂移或保护数据 |
shallow | 初始化时是否建议浅克隆 | 固定 Commit 必须仍可达,历史诊断能力会下降 |
git submodule init 会把可用的初始化信息写入超级仓库本地 .git/config。此后本地 submodule.<name>.url 可以覆盖 .gitmodules,所以改完版本库里的 URL 后还要同步旧克隆。子模块自身又有独立 HEAD、index、远端和工作树。把这几层分开观察:
git config -f .gitmodules --get-regexp '^submodule\.'
git config --get-regexp '^submodule\.'
git submodule status --recursive
git -C vendor/component status --short --branchgit submodule status 的前缀是快速证据:- 表示未初始化,+ 表示工作树 Commit 与 gitlink 不同,U 表示 gitlink 尚有冲突。无前缀只证明 Commit 对齐,子模块内部的 dirty state 仍要单独检查。
启用与递归克隆
Submodule 是 Git 内置能力,不安装插件,也不启动常驻服务。先确认执行环境中的 Git 和身份来源:
git --version
git config --show-origin --get user.name
git config --show-origin --get user.email在超级仓库中添加一个已经存在的仓库:
git submodule add <repository-url> vendor/component
git status --short
git diff --cached --submodule=log预期暂存区同时出现 .gitmodules 与 vendor/component。后者不是一批文件,而是 gitlink。确认后一起提交:
git commit -m "构建:固定 component 子模块版本"新成员和 CI 应从一开始递归克隆:
git clone --recurse-submodules <superproject-url> workspace已有普通克隆使用:
git submodule update --init --recursive根据 git-submodule 的 update 语义,--init 负责注册尚未初始化的条目,--recursive 继续处理嵌套子模块,默认 checkout 会把每个子模块置于超级仓库记录的 Commit,通常是 detached HEAD。detached HEAD 在消费方是可复现特性,不是故障。
只有 clone 本身带过滤器时,子模块才需要额外决定是否也过滤。git-clone 要求 --also-filter-submodules 与 --filter、--recurse-submodules 一起使用:
git clone --filter=blob:none --recurse-submodules --also-filter-submodules <url> workspace这会把远端可用性和按需取对象风险递归放大。没有离线预热、服务端 filter 能力和失败回退时,不应只为缩短首轮下载就默认启用。
可复制的 gitlink 正反实验
下面的实验只使用本地临时仓库,不需要账号或网络。为了让命令可跨终端理解,先创建一个空目录并进入其中;目录名不应与真实项目相同。
建立子仓库和超级仓库
mkdir submodule-lab
cd submodule-lab
git init --initial-branch=main component
git -C component config user.name "Example Developer"
git -C component config user.email "developer@example.invalid"
printf "v1\n" > component/version.txt
git -C component add version.txt
git -C component commit -m "feat: component v1"
git clone --bare component component.git
git init --initial-branch=main product
git -C product config user.name "Example Developer"
git -C product config user.email "developer@example.invalid"本地文件协议默认可能被安全策略拒绝。实验只对这一条命令临时允许 file 协议,不要写入全局配置:
git -C product -c protocol.file.allow=always submodule add ../component.git vendor/component
git -C product commit -m "build: add component submodule"验证 gitlink 和工作树指向相同对象:
git -C product ls-tree HEAD vendor/component
git -C product submodule status
git -C product/vendor/component rev-parse HEAD预期 ls-tree 显示 160000 commit,后两条输出的 Commit ID 与 gitlink 相同。
验证递归初始化的正例
git -c protocol.file.allow=always clone --recurse-submodules product consumer
git -C consumer submodule status --recursive
git -C consumer/vendor/component show HEAD:version.txt预期无 -、+、U 前缀,最后输出 v1。这证明新克隆不是碰巧复用了开发者已有目录,而是按 .gitmodules 和 gitlink 重建状态。
制造“上游已更新,超级仓库未升级”的反例
printf "v2\n" > component/version.txt
git -C component add version.txt
git -C component commit -m "feat: component v2"
git -C component push ../component.git main
git -C consumer/vendor/component fetch origin main
git -C consumer/vendor/component checkout origin/main
git -C consumer submodule status
git -C consumer diff --submodule=log预期 status 前出现 +。文件已经是 v2,但超级仓库仍记录 v1;此时直接构建会让本地结果偏离受评审状态。先在子仓库验证新对象,再在超级仓库提交指针:
git -C consumer add vendor/component
git -C consumer commit -m "build: upgrade component to v2"git submodule update --remote 也只是帮助取得 submodule.<name>.branch 指向的远端 Commit。它不会自动形成可审计升级,最终仍需提交新的 gitlink。
制造不可达指针的反例
固定 SHA 的可靠性取决于远端长期保留该 Commit。可以先记录当前指针,再验证远端是否能广告或取到它:
git -C consumer ls-tree HEAD vendor/component
git -C consumer/vendor/component fetch origin
git -C consumer/vendor/component cat-file -e HEAD^{commit}若子仓库历史被强制改写、对象已清理,新机器可能出现:
fatal: remote error: upload-pack: not our ref <object-id>
fatal: Fetched in submodule path 'vendor/component', but it did not contain <object-id>修复不是在构建机上随便切到 main,而是从受信备份恢复原 Commit,或在超级仓库提交一个仍可达且重新验证过的新 gitlink。前者保持历史可复现,后者属于依赖升级,应重新评审和发布。
清理实验
在实验父目录确认路径后,先解除子模块本地注册,再删除临时目录:
git -C consumer submodule deinit -f --all
cd ..
rm -rf submodule-labPowerShell 可用 Remove-Item -LiteralPath .\submodule-lab -Recurse,执行前必须确认当前目录和目标绝对路径。清理真实仓库不能照搬 -f --all。
项目接入:把升级做成可评审变更
一次安全升级至少有两个提交顺序:先让子仓库目标 Commit 可从正式远端取得,再更新超级仓库 gitlink。反过来会制造一个任何干净克隆都无法完成的窗口。
git -C vendor/component switch main
git -C vendor/component pull --ff-only
git -C vendor/component log -1 --oneline
# 在子仓库完成测试并确保目标提交已推送后
git add vendor/component
git diff --cached --submodule=log
git commit -m "构建:升级 component 到已验证版本"评审页面若只显示一行 SHA 变化,reviewer 仍应查看子仓库提交范围:
git submodule summary --cached
git diff --cached --submodule=log
git -C vendor/component log --oneline <old>..<new>超级仓库 PR 应关联子仓库 PR、发布说明、兼容性结果和回滚 SHA。对关键依赖,门禁应验证:
git submodule sync --recursive
git submodule update --init --recursive
git submodule foreach --recursive 'git diff --quiet && git diff --cached --quiet'
git submodule status --recursiveforeach 中的 $name、$sm_path、$sha1 等变量由 Git 提供,但复杂 shell 在 Windows 与 POSIX 环境存在差异。跨平台流水线更适合用脚本逐条读取 git submodule status --recursive,并明确检查前缀和退出码。
常用操作:URL、更新策略与旧克隆
修改 URL 应使用 Git 子命令,让 .gitmodules 和当前克隆的本地配置关系可见:
git submodule set-url vendor/component <new-url>
git submodule sync --recursive
git config --get submodule.vendor/component.url
git -C vendor/component remote -v如果名称不是路径,应先从 .gitmodules 查出实际 section。sync 负责把版本化 URL 传播到已初始化配置;它不会授予新地址权限,也不会迁移对象。
默认 checkout 最适合消费方和 CI:工作树准确落到 gitlink。merge 和 rebase 会把 gitlink 对应 Commit 与子模块当前分支组合,适合明确的子模块开发会话,却可能令无人值守环境产生额外历史或冲突。none 可阻止自动更新特定模块,但也要求构建系统明确提供内容。
不要用 ignore = all 隐藏升级差异。它改变 status/diff 可见性,不改变 gitlink,也不阻止提交错误指针。对生成物或脏工作树的容忍应通过构建目录隔离解决,而不是让 Git 沉默。
移除、撤销与残留对象
“本机暂时不检出”和“从项目历史删除”是两种操作。gitsubmodules 将前者称为 deinitialized submodule:gitlink 和 .gitmodules 仍在,只移除本地工作树和注册信息。
git submodule deinit -- vendor/component
git submodule update --init -- vendor/component第二条可重新初始化。若要从项目删除,先确认子模块无未提交和未跟踪的重要文件:
git -C vendor/component status --short
git rm vendor/component
git diff --cached
git commit -m "构建:移除 component 子模块"git-rm 的子模块行为 会同时暂存 gitlink 和对应 .gitmodules section 的删除;现代 gitfile 形式的子模块 Git 目录通常保留在超级仓库 $GIT_DIR/modules/ 下,以便检出旧提交。不要为了“目录干净”立即手删该位置。
误删尚未推送时,可恢复暂存区和工作树;已经形成共享提交时,使用 revert 保持历史连续:
git restore --staged .gitmodules vendor/component
git restore .gitmodules vendor/component
git submodule update --init --recursive
# 或撤销已经共享的删除提交
git revert <removal-commit>
git submodule update --init --recursive只有确认历史不再需要、备份和保留策略允许时,才考虑删除 $GIT_DIR/modules/<name>。路径应由 git rev-parse --git-path modules 获取,不能假设 .git 永远是目录。
常见失败与排查证据
克隆后目录为空
先运行:
git submodule status --recursive
git config -f .gitmodules --get-regexp '^submodule\.'前缀 - 表示尚未初始化。执行 git submodule update --init --recursive。若命令成功但内容仍空,检查该 path 是否被 sparse-checkout 排除,以及上层是否使用 update = none。
指针前出现 +
git ls-tree HEAD <path>
git -C <path> rev-parse HEAD
git diff --submodule=log两个 Commit ID 不同。若本地 Commit 是计划升级,先确认已推送并在超级仓库提交 gitlink;若是误操作,保存必要分支后执行 git submodule update --checkout -- <path>。不要在有未提交修改时强制覆盖。
URL 已改但旧机器仍访问旧地址
git config -f .gitmodules --get submodule.<name>.url
git config --get submodule.<name>.url
git -C <path> remote -v三层不一致时执行 git submodule sync --recursive,再验证认证。若本机有合法镜像覆盖,记录覆盖来源,不要把个人 URL 反写进 .gitmodules。
detached HEAD 上的提交像是丢失
git -C <path> status --short --branch
git -C <path> reflog --date=iso
git -C <path> switch -c rescue/submodule-work <commit>先建立分支,再推送到子仓库并走正常评审。直接在超级仓库提交一个只存在本机的 gitlink,会把个人疏忽升级为全团队构建故障。
权限错误与对象不存在混在一起
先验证 URL 和身份,再验证对象:
git config --get submodule.<name>.url
git -C <path> remote -v
git -C <path> fetch origin
git -C <path> cat-file -e <gitlink>^{commit}401/403、Permission denied (publickey) 指向认证授权;not our ref 更可能是对象不可达、历史改写或远端选错。代理的 407 和 TLS 证书错误应先在 Git 传输层处理,不要把 token 拼进 URL 重试。
代理 / 权限 / 凭证与供应链边界
递归克隆会把依赖图中的每个 URL 都变成网络和信任入口。超级仓库可读不代表子仓库可读;开发者、CI、发布机和灾备恢复账号都要分别验证最小读取权限。
.gitmodules 是版本化输入,只放无凭证 URL。PAT、密码、私钥和临时签名参数不得写入其中。优先统一 SSH 或 HTTPS 身份入口。混合协议会让代理、主机校验和凭证轮换出现两套故障路径。相对 URL 便于同域迁移,但 Fork 或镜像层级改变时会重新解释目标。迁移演练必须从新 URL 做干净递归克隆。
审核 .gitmodules 变更与普通依赖清单同等重要。新增 URL、协议、分支和 update 策略都应由代码所有者审批。不可信仓库可能携带恶意或意外 URL。Git 的 protocol.<name>.allow 可限制协议;不要为了让递归命令“先跑起来”而全局放开 file 或未知 helper。子仓库强制推送会破坏已发布 gitlink。受依赖分支应禁止删除或历史改写,并保证固定 Commit 的对象保留时间覆盖产品可恢复周期。
构建产物应记录超级仓库 Commit 和全部递归 gitlink,而不是只记录分支名。出现供应链事件时,才能回答实际构建用了什么。
架构选型:何时采用,何时退出
Submodule 的收益是权限和历史独立、版本指针精确、无需把源码复制进上层仓库。成本也同样明确:每增加一层,就增加一个远端可用性、凭证、对象保留、评审和升级单元。
| 需求 | 更合适的方式 | 判断依据 |
|---|---|---|
| 独立授权的源码或资产,必须固定到 Commit | Submodule | 消费方能承担双仓库发布和递归认证 |
| 需要语义版本、传递依赖、漏洞通告和制品缓存 | 包管理器/制品仓库 | 交付物比源码协作更重要 |
| 两个模块频繁原子修改、统一评审和统一构建 | Monorepo 或普通目录 | 跨仓库提交已成为主要交付阻力 |
| 只复制少量稳定代码,允许主动同步 | subtree/vendor 流程 | 接受历史或源码复制,换取单仓库消费 |
| 大型二进制文件 | 制品仓库或 Git LFS | Submodule 不解决对象体积和带宽成本 |
架构评审不应只统计仓库数量。要测量递归 clone P50/P95、失败率、子仓库授权工单、升级滞后时间、不可达 gitlink 次数、CI 缓存体积和灾备恢复时长。一个子模块半年不升级,可能不是“稳定”,而是没有 owner 或没人敢动。
团队落地与运行模型
子模块需要明确三类责任:子仓库 owner 保证目标 Commit 可达、发布说明和兼容性;超级仓库 owner 审核 gitlink 升级并验证集成;平台 owner 管理递归凭证、镜像、缓存和审计。职责空缺时,固定 SHA 只能固定风险。
建议把以下规则写入仓库门禁和运维手册:
新增子模块必须说明 owner、URL 信任域、保留策略、升级和退出路径。子仓库 Commit 先推送并通过自己的门禁,超级仓库随后提交 gitlink。CI 每次从受控缓存或干净目录执行 sync 与递归 update --init,不得依赖构建机遗留工作树。
PR 展示 old/new Commit 范围,关联子仓库变更,并验证子模块无 dirty state。定期从空缓存执行递归克隆,覆盖开发者身份、机器身份、代理、证书和嵌套模块。禁止受依赖对象在恢复窗口内因分支删除或强推而失去可达性;镜像和备份也要验证具体 Commit。
URL 迁移、凭证轮换和平台退出都以“干净环境可取得所有 gitlink”为完成标准。
落地工程深水区:可复现、可用与成本的取舍
固定 SHA 仍可能不可复现
gitlink 固定的是对象名,不是远端持久性。子仓库删除分支、改写历史并完成对象清理后,旧超级仓库依然保存那个 SHA,却没有来源可以取回。关键依赖应配置禁止改写、镜像、备份和对象保留验证;灾备演练必须按 SHA 恢复,而不是只看默认分支存在。
递归把故障半径按依赖图扩散
一个顶层仓库有十个子模块,每个子模块再嵌套两个模块,首次构建面对的不是一次 clone,而是一组远端、DNS、TLS、代理和授权交互。并行 --jobs 可以缩短成功路径,也会放大代理连接数和服务端突发负载。并发值应由实测确定,失败重试要有上限,镜像缓存必须保持对象完整。
分支跟踪不能代替发布
branch 与 update --remote 提高升级便利性,却容易让团队误以为子模块运行时会自动追随分支。真正进入产品版本的仍是超级仓库提交的 gitlink。无人评审地定时更新 gitlink,相当于自动引入源码依赖,必须配套兼容性测试、变更摘要、失败回滚和 owner 审批。
相对 URL 同时提供可迁移性和歧义
相对 URL 按超级仓库默认远端解析。在同组织镜像中很方便,在个人 Fork、跨组迁移或多 remote 环境中可能指向错误仓库。判断标准不是“URL 更短”,而是开发者、CI、Fork 和灾备四种克隆入口是否都得到同一受信对象源。
本地模块仓库不是垃圾目录
现代子模块的 Git 目录通常位于 $GIT_DIR/modules。它可能保存 detached HEAD 上尚未发布的工作,也支撑检出旧版本。清理脚本若只按目录名删除,会破坏恢复证据。先运行 status、reflog、fsck 和备份判断,再按 deinit、git rm、提交或 revert 的语义操作。
git ls-tree 显示每个子模块为预期的 160000 commit,递归 status 无 -、+、U。.gitmodules 中没有 token、密码、个人主机名或不受信协议,URL 在开发机和 CI 均可解析。path、url、branch、update、递归 fetch 和 shallow 策略均有明确理由。
子仓库目标 Commit 已先推送、可由干净机器取得,并处于对象保留和备份范围内。PR 可查看 old/new Commit 差异,子仓库变更、兼容性结果和回滚 SHA 已关联。开发者与机器身份分别通过递归克隆,代理、TLS、SSO 和凭证轮换失败路径有证据。
CI 不依赖遗留子模块目录,构建产物记录顶层 Commit 与完整 gitlink 清单。子模块内部无未提交、未跟踪或 detached HEAD 上未发布的重要工作。URL 迁移后执行过 sync --recursive,并从新入口完成干净克隆。
本地停用使用 deinit,项目删除使用 git rm;共享删除可用 revert 恢复。clone 时延、失败率、缓存、授权工单、升级滞后和恢复时长进入长期度量。当跨仓库原子修改和升级成本超过权限隔离收益时,已经触发包管理、制品化或 Monorepo 复评。
