手头有五六个仓库要一起改、一起发布的时候,你就会发现 Git 的原生能力开始不够用了。多仓库管理这个老问题,聊来聊去最后都会落到两个答案上:官方内置的 git submodule,和 Google 开源的 repo 工具(也叫 git-repo)。我这两套方案都深度用过,也见过团队为选型争论到拍桌子的场面。这篇文章把我实际使用中的体会彻底摊开,从原理、工作流到选型边界讲清楚,给正在做多仓库选型的人一个可以直接抄作业的判断依据。
1. 多仓库管理的本质:版本一致性与协作成本
1.1 为什么多仓库会成为常态
先别急着比较工具,得先想清楚我们到底在解决什么问题。代码仓库从"单仓"变成"多仓",背后通常是这几类原因:
- 组织边界:不同团队对代码有不同的权限诉求,A 组的核心库不能让 B 组随便改,仓库拆开是最自然的管理手段。
- 独立版本节奏:公共库、SDK、组件需要按自己的节奏发版,不能因为主应用不发版就被绑死。
- 代码规模:某些巨型代码库连 Git 本身都会吃不消,切到多仓库后,克隆、diff、日志操作都快得多。
- 对外交付:SDK 要发布给第三方,必然要和主工程分开。
仓库一多,"配置地狱"就来了:每个仓库各自有分支、有 tag、有自己的提交历史。你今天改了一个 SDK 的接口,明天下游三个应用全部编译失败——不是你代码写得不对,而是你和下游的仓库状态根本没对齐。
多仓库管理的核心矛盾就是:既要每个仓库保持独立演进的自由,又要保证整套系统在某一个时刻能够整体构建、整体发布。 这本质上是"版本一致性"和"协作自由"之间的取舍,所有工具都是在这个矛盾上做文章。
1.2 两种主流解法各自的"锚点"
Git submodule 和 repo 工具,其实是两种完全不同的解题思路。
submodule 的思路是:父仓库直接记录子仓库的某个提交哈希(SHA-1)。整个多仓库系统的状态,被父仓库里那一排"指针"锁死。你只需要关心父仓库在哪个提交上,它引用的子模块就必然是确定的版本。
repo 的思路则完全不同:用一个独立的 manifest 清单仓库,集中记录所有子仓库应该处于哪个分支、哪个 tag、或者哪个提交。repo sync 的时候,每个子仓库都按照清单的指示去更新自己。系统的整体状态由 manifest 的定义来决定,而不是散落在各个父仓库的提交记录里。
我见过太多人在没搞懂这两套机制之前就开始选型,最后用 submodule 管理 40 个仓库,或者用 repo 管理 3 个仓库,全都别扭到不行。下面两章先把原理拆透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Git submodule:把版本钉死在父仓库里的官方方案
2.1 指针模型:submodule 记录的到底是什么
很多人第一次接触 submodule 都会有个误解:以为父仓库会把子仓库的代码整个复制进来。不是的。Git 在父仓库的树对象(tree object)里只记录一个特殊条目,叫 gitlink,内容就是子仓库某个提交的 SHA-1 值,外加一个路径映射。
这个映射关系写在一个名为 .gitmodules 的文件里,格式长这样:
ini复制[submodule "libs/core"]
path = libs/core
url = https://example.com/git/libs/core.git
.gitmodules 本身会被父仓库版本管理。注意看,它记录的是 URL 和路径,真正的提交指针在父仓库的 Git 对象库里,用 git ls-tree HEAD libs/core 能看到一个 160000 commit 类型的条目——这就是 gitlink。
理解这个模型最关键的一点:submodule 记录的是提交,不是分支。哪怕你当时在子模块的 main 分支上,父仓库记录的也只是"你当前 HEAD 指向的那个提交哈希"。子模块以后的 main 分支怎么挪,都不影响父仓库的指针。只有当你在父仓库重新 git add libs/core 并提交,指针才会更新。
2.2 一套完整的 submodule 协作流程
如果我今天要让"app 仓库"依赖"libs/core 仓库",实际操作是这样的:
bash复制# 1. 添加子模块
git submodule add https://example.com/git/libs/core.git libs/core
# 2. 进入子模块,切换到需要的工作分支
cd libs/core
git checkout dev-feature
# 3. 在子模块里改代码、提交
git add .
git commit -m "feat: add new API"
# 4. 回到父仓库,把新的指针提交上去
cd ..
git add libs/core
git commit -m "chore: bump libs/core to 8f3a2c1"
# 5. 同事拉取,初始化并同步子模块
git pull
git submodule update --init --recursive
这套流程里最容易被新手忽略的就是第 4 步。子模块里提交完之后,父仓库并不会自动感知。你必须在父仓库里再提交一次指针变更。我见过太多新人改完子模块直接推父仓库,结果 CI 上跑的还是旧代码,排查半天才发现是指针没更新。
拉取的一方也有个常见坑:clone 父仓库时子模块目录是空的,必须执行 git submodule update --init 才会真正拉取内容。多层的子模块嵌套要加 --recursive,否则嵌套的下一层还是空的。
2.3 改错提交消息?用 amend 修正指针提交
这个场景在 submodule 工作流里出现频率极高:你提交了子模块指针,然后发现消息写错了,或者发现子模块实际上还差一个提交没记录进去。如果这个指针提交还没推远端,直接用:
bash复制git commit --amend
把新的指针和新的提交消息一起覆盖进去。如果指针本身也需要更新,先 git add libs/core 再 amend 一样有效。记住一条铁律:push 之前可以随便 amend,push 之后就别动了,那会重写公共历史,团队里会有人骂街。
2.4 submodule 的边界:它撑不了大规模
submodule 最大的问题不是性能,而是协作单元太小。当你要跨 5 个仓库做一个功能时,你得在 5 个子模块里分别提交、分别推远端,然后在父仓库里连做 5 次指针更新,还要保证 5 个提交在时间上"对齐"。只要一个仓库的提交晚了几分钟,中间状态就是坏掉的。
另外一个让团队头疼的问题:每个子模块在父仓库里都是 detached HEAD 状态。你进入子模块时永远不在分支上,一旦忘记切分支再改代码,提交就会漂移。配合指针模型,整条链路非常容易在"稍不留神"的情况下进入不一致状态。
3. git-repo:Google 在多仓库协作上的解法
3.1 manifest 清单:仓库的"总控台"
repo 工具最初是为了管理 Android 这种动辄几百个仓库的超大规模代码库而生的,后来 Chromium 等项目也都在用。它本身是一组 Python 脚本,核心思想在一个词:manifest(清单)。
repo 的工作方式是这样的:你先有一个"清单仓库",里面放一个 default.xml(文件名可以自定义),它描述了你整个系统中所有仓库的位置、路径、分支等信息。一个最小清单长这样:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<remote name="origin"
fetch="https://example.com/git/" />
<default revision="main"
remote="origin" />
<project path="app" name="client/app" />
<project path="libs/core" name="libs/core"
revision="release-1.2" />
</manifest>
每个 <project> 标签里,name 是远端仓库的名字(通常拼上 fetch 前缀就是完整 URL),path 是拉到本地后的目录相对路径。revision 不写的话继承 <default> 里的,也就是 main 分支;写了就按写的来——可以是分支、tag、甚至具体 SHA-1。
理解了 manifest,你就掌握了 repo 的灵魂。系统中任何一个仓库该处于什么状态,都在这个 XML 里集中定义。它替代了 submodule 里散落在父仓库各个提交中的指针,变成一个显式、集中、可审查的配置。
3.2 repo 的完整工作流
假设你要基于一组仓库开始开发:
bash复制# 1. 初始化工作区,拉取 manifest 仓库
mkdir workspace && cd workspace
repo init -u https://example.com/git/manifest.git -b master
# 2. 按清单同步所有仓库
repo sync
# 3. 在所有仓库里同时开一个新分支
repo start my-feature --all
# 4. 在各自仓库里正常改代码、提交
cd libs/core
git add .
git commit -m "feat: add new API"
cd ../app
git add .
git commit -m "feat: use new API"
# 5. 把所有仓库的提交推上去供评审
repo upload
注意第 3 步的 repo start --all,这是 repo 在开发体验上一个巨大的优化:一条命令让所有仓库都切到同一个新分支,避免了手工为每个仓库切分支的繁琐工序。改完推完,代码评审通过后合并,每个人再 repo sync 拉最新。
如果是新环境,安装 repo 也很快。它本质是个 Python 脚本,安装 Git 之后,把 repo 脚本下载到本地并赋予执行权限,或者通过包管理器安装,就可以使用了。工具本身对操作系统的要求很低。
3.3 设计哲学:分支优先与原子同步
repo 和 submodule 在哲学层面就是两个物种。submodule 是"提交快照优先",repo 是"分支协同优先"。在 repo 的世界里,所有仓库默认跟着某个分支走,repo sync 做的就是最朴素的一件事:让大家的分支都尽量对齐。
repo sync 的原子性也值得单独讲。它在内部会先逐个 fetch 所有项目,然后统一计算结果,最后再让所有工作区一起更新。这样某一时刻要么全部更新到位,要么都不更新,很少出现"五个仓库只更新了三个"的中间态。这一点在大型 CI 场景里价值巨大,因为它从机制上消灭了一整类"部分更新导致构建失败"的问题。
4. 核心差异对照
4.1 维度对比总表
下面这张表是我在实际项目里总结的对照,先整体看,再挑重点展开。
| 对比维度 | git submodule | git-repo (repo) |
|---|---|---|
| 定位 | Git 官方内置的子功能 | 独立的多仓库编排工具 |
| 状态记录方式 | 父仓库记录子仓库的提交 SHA-1 | manifest 记录各仓库的 revision |
| 同步单位 | 单个子模块逐个 update | repo sync 统一处理一批仓库 |
| 跨仓库一致性 | 无原子保证,靠人工维护 | sync 有预检,整体更新 |
| 协作流程 | 常规 push + 代码评审 | 分支 + repo upload 集中评审 |
| 适用仓库规模 | 10 个以内较舒服 | 几十到几百个 |
| 学习成本 | 低,Git 自带 | 中,需要理解 manifest 语法 |
| 平台依赖 | 任意 Git 平台都支持 | 通常配合 Gerrit 等评审系统 |
4.2 提交指针 vs 分支追踪
这是两者最本质的分歧。
submodule 世界里,系统的"状态真相"在父仓库的提交记录里。它精确、可复现,但代价是状态会被动僵化——远端子仓库更新了,本地不手动改指针就永远不知道。
repo 世界里,系统的"状态真相"在 manifest 里。默认情况下它跟着 main 之类的分支跑,repo sync 一执行,大家自动跟上远端最新。敏捷性拉满,但代价是你不主动锁定 revision,状态就一直在漂。所以成熟团队的 manifest 里通常会为发布明确写死 revision="release-x.y.z" 这样的 tag。
一个精于复现,一个精于协同——没有谁绝对好,只有谁更匹配你的场景。
4.3 协作和代码评审的差异
跨仓库开发时,submodule 的评审流程很痛苦:每个子仓库的提交要分开 review、分开合并,再回头更新父仓库指针,再来一轮 review。改动一多,光评审就够喝一壶。
repo 的 repo upload 一次会把所有相关仓库的提交一起推送到评审系统,形成一个跨仓库的变更集合。评审人看到的是"这一整套改动"而不是零散的单仓库提交。这个体验差异,在规模上来之后是决定性的。别小看它,跨仓库评审看不清全貌,就是返工和线上事故的温床。
4.4 构建、发布与 CI 的差异
CI 场景里的差异更直观。用 submodule 的工程,CI 克隆时一般要加 --recursive,构建前再 submodule update。每个依赖的版本都藏在父仓库的历史指针里,想从日志搞清楚"这次构建用哪个版本的 SDK",你得一层层 diff 指针。
repo 工程的 CI 则简单粗暴:repo init -u <manifest-url> -b <branch> 加 repo sync,然后让 CI 读取 manifest 内容,把版本号打进构建产物。manifest 本身就是一份机器可读、可归档的版本清单。发布后把当时的 manifest 存档,哪天要复现线上版本,直接按这个清单重新 sync 一遍即可。
5. 选型建议:三个典型场景的取舍
5.1 少量依赖:submodule 最省心
如果你的系统只有 3~5 个仓库,其中只有一个核心库被其他工程引用,彼此没有复杂的交叉依赖,submodule 绝对够用。它零额外依赖、平台支持广、团队培训成本几乎为零。GitHub、GitLab 的网页端也都有完整的 submodule 支持,PR 里能看到指针变更 diff,体验足够顺滑。
这种规模下强行上 repo,反而会引入 manifest 仓库、Gerrit 评审流程这些新概念,属于用大炮打蚊子。
5.2 大规模组件平台:repo 的舒适区
当仓库数量来到 20 个以上,或者涉及多团队并行开发、共享组件频繁升级、需要定期做整体发布时,repo 的价值就压不住了。repo sync 的原子更新、repo start --all 的批量分支操作、repo upload 的跨仓库评审集中管理,每一项都是针对这个规模痛点设计的。
尤其做嵌入式、移动端平台、云原生组件这类"底包 + 大量插件"的架构,仓库之间的联动修改是常态,用 repo 会让协作体验发生质变。
5.3 混合折中的思路
我实际见过不少团队采用 hybrid 方案:用 repo 管整个开发工作区和 CI,用 submodule 处理个别需要对外发布的小依赖。比如一个 SDK 项目内部组件多,用 repo 组织研发;对外交付时单独把某个库作为 submodule 嵌进客户工程。两套工具并不互斥,关键是你得清楚边界在哪。
从 submodule 迁到 repo 时,有一点要有心理准备:Git 历史不会跟着清单迁移,各仓库的提交历史原封不动,但"整体系统的演进历史"会从父仓库的提交链变成 manifest 的版本链。以前的 tag 策略也要重建,需要在 manifest 里用 revision 锁版本。这不是一个无痛替换,是协作范式的切换。
6. 常见问题排查手册
6.1 submodule 侧的高频翻车现场
- clone 后子模块目录是空的:忘了
git submodule update --init,或者父仓库没提交 .gitmodules。先确认.gitmodules在版本控制里,再执行更新。 fatal: Pathspec 'xxx' is in submodule:这是你在父仓库对子模块内部路径执行了 git 操作,Git 明确拒绝。要么进子模块目录操作,要么在父仓库只操作指针本身。- 子模块总是显示 modified:进去看
git status,多半是 detached HEAD 或指针和远端不一致。先切回正确的分支或 tag,再重新git add指针。 - 子模块的远端 URL 换地址了:改了 Git 平台上的仓库地址后,必须同步修改
.gitmodules和.git/config两处,然后执行git submodule sync。我见过不少人只改了其中一个,结果本地能拉、同事拉不了。
6.2 repo 侧的高频翻车现场
- repo sync 时本地有未提交改动:repo 不会像 submodule 那样安静地覆盖你,它会报错或要求
-f强制。根治办法是改代码前先repo start <branch> --all,在专属分支里干活,sync 时互不干扰。 - manifest 分支漂移:manifest 仓库本身也是 Git 仓库,如果你
repo init -b other-branch切换清单分支,会遇到清单内容剧变、sync 后项目大面积变动的"惊喜"。切换清单分支前先看 diff,最好拉一份变更说明。 - repo forall 命令的引号陷阱:
repo forall -c 'git status'时命令会在每个项目目录下执行,但你要是用了相对路径引用外部文件,很容易踩雷。建议用绝对路径,或者把复杂操作写成脚本再-c调用。 - 发布版本复现:记住,发布时不要依赖默认分支,把 manifest 里的 revision 改为具体 tag,这样线上版本出问题时才能精确复现当时的状态。
我心里最深的体会其实就一句:这两个工具不是竞争对手,它们解决问题的层次完全不同。 submodule 是 Git 给你的"机制",repo 是 Google 在超大规模实践里提炼出的"工作流"。你有没有几十上百个仓库、需不需要跨仓库评审、CI 对原子同步的容忍度有多高——把这些答案写下来,选型基本就水落石出了。最后再分享一个小习惯:无论用哪套方案,都建议把系统的"整体版本号"和 manifest 或父仓库指针绑定,发布时一并归档,这是我在排查线上问题时救过无数次命的做法。
