1. 先搞清楚一件事:submodule 和 repo 压根不是同一层的东西
在对比它俩之前,我先把结论放前面:Git submodule 和 repo 解决的问题看着像,实际根本不是同一层的东西。submodule 是 Git 的一种链接机制,它把“另一个仓库的某个 commit”以指针的形式记录在当前仓库里;而 repo 是构建在 Git 之上的批量工作流工具,它本身不管理具体的版本依赖,它管理的是“一批仓库的集合”。
这也就是为什么很多人在网上搜“submodule 和 repo 哪个好”会越看越糊涂——因为答案不应该是“谁替代谁”,而要看你的工程形态。我这些年大概见过三种典型的形态:
- 单体仓库,偶尔引用外部组件:两个内部服务之间需要共享一个公共 SDK,SDK 单独维护一个仓,服务仓以 submodule 方式引用。
- 一个产品切成多个仓,但每次都要一起改、一起发版:这种情况下用 submodule 会痛不欲生,因为每次改动都要先提交子仓、再提交主仓,指针永远在跳。
- 整套平台几十上百个仓,按产品线、按版本线统一拉取、统一构建:repo 几乎就是这一类场景的标准答案,它本身就是当年为 Android 这种超大规模多仓工程设计的。
所以这篇文章我会从原理层面把两个工具的差异拆开,再结合我实际维护过的多仓工程,讲清楚在什么场景下应该选哪个,以及如果选错了,迁移的时候会踩到哪些坑。适合正在做仓库拆分、正在选多仓管理方案的技术负责人,也适合刚接触 submodule、被指针搞烦了的普通开发。
1.1 submodule 到底在干什么?
要理解 submodule,你得先接受一件事:它本质上是"记录一个引用"。当你执行git submodule add https://github.com/foo/bar.git libs/bar时,Git 做了三件事:
- 在
.gitmodules里记录了子仓库的 URL; - 在当前仓库的索引(index)里写入了一个 gitlink,也就是"这个路径对应的仓库的 HEAD commit 是 xxx";
- 把子仓库完整克隆到
libs/bar目录下。
关键点在于:外层仓库只记录子仓库的一个 commit id,它永远不会跟着子仓库的新提交自动前进。这个设计在源码层面是合理的——子模块可以被 pinned(锁定)在某个版本,保证构建的可复现性。
但问题也随之而来。当团队多人协作时,如果有人改动了子仓并提交了新的 commit,父仓不会感知;必须有人手动更新 gitlink,再把父仓提交一遍。一旦忘了这一步,别人拉代码时看到的还是旧的子仓版本,跑起来根本不是同一套代码。我在团队里见过太多次"我明明改了子仓,为什么 CI 构建出来还是旧版本"这种问题,十有八九就是 gitlink 没推进。
1.2 repo 在干什么?
repo 是 Google 开源的一个 Python 脚本工具,设计目标非常明确:管理 Android 这种由几百个独立 Git 仓库组成的工程。它不修改 Git 本身的任何行为,而是在 Git 外面加了一层"清单(manifest)"机制。
manifest 本身是一个 Git 仓库,里面有一份default.xml(名字可以自定义),录了每一个子仓库的地址、所在路径、当前需要 checkout 的分支或 commit。你执行repo init -u <manifest仓库地址>之后,再执行repo sync,repo 会按 manifest 里写的清单,把每个仓库一个个 clone/checkout 到指定路径。
所以 repo 管理的"版本快照"不是散落在每个仓库的 gitlink 里,而是集中在 manifest 仓库中。想切到另一个版本线?不用跑到几十个仓库里逐个 checkout,改 manifest 或者切 manifest 的分支,一条repo sync就能整体切换。
这个差异很关键,我后面会详细展开。
2. 日常操作体感对比:从克隆到提交,差在哪儿
我不打算只讲理论,直接对比日常开发中最高频的几个操作,感受最直观。
2.1 克隆一套代码
submodule 风格:
git clone https://github.com/example/main.git cd main git submodule update --init --recursive如果你没见过这个工程,根本不知道它还有子模块;.gitmodules虽然会出现在仓库根目录,但很多人不会主动去 cat 一下。更麻烦的是,如果某个子模块的 URL 已经失效,或者仓库因为权限问题拒绝 clone,你会在第二步卡住报错,而且报错信息不是一下就能看懂的。
repo 风格:
repo init -u https://github.com/example/manifest.git -b main repo syncrepo 首先做的是下载 manifest 并 checkout 到 main 分支,接着根据 manifest 里的仓库列表逐个拉取代码。如果一个仓库拉取失败,repo sync 会标记失败但不中断,提示你可以重跑修复。从体感上讲,repo 拉多仓是"批量任务"的感觉,submodule 拉多仓是"嵌套依赖"的感觉。
2.2 提交代码的路径
这是两者最大的分水岭。
用 submodule 时,修改子仓代码的正确流程是:
cd libs/bar git add . git commit -m "fix: optimize cache" git push origin main cd ../.. git status # 此时能看到 libs/bar 显示为 modified git add libs/bar git commit -m "chore: bump bar to xxxx" git push origin main也就是说,一个改动要被最终合并,你至少要提交两次、推两次。如果工程里有三个子仓库同时改了,那就是三倍的工作量;如果哪个环节忘了做第二步,或者 CI 不想让你手动维护 gitlink 而在脚本里强制更新,版本就又可能对不上。
用 repo 时,这个过程被彻底简化。每个仓库是独立的工作副本,你改动哪个仓就只在那个仓里 add/commit/push,不存在"再提交一次外层指针"的概念。manifest 仓库只有在"我要调整子仓 checkout 的分支/commit"时才需要动一下,平时开发根本不用碰。
2.3 查看"这套代码整体处于什么版本"
submodule 下,你要知道整套代码的准确版本,只能到每个子仓里看 HEAD:cd libs/bar && git log -1,然后还要对比父仓记录的 gitlink 是不是一致。指望一个命令给出总览?Git 没有这个原生能力,需要自己写脚本。
repo 下就舒服很多:repo manifest -r可以输出一个带精确 commit 的 manifest 文件,repo forall -g 'git log -1'可以一次性列出所有仓库的 HEAD。排查问题的时候,这几乎是从地狱难度降到普通难度。
2.4 操作频率与心智负担差异
我做一个表格,日常使用比较直观:
| 对比维度 | Git submodule | repo |
|---|---|---|
| 克隆整套代码 | git clone + submodule update --init --recursive | repo init + repo sync |
| 修改单个子仓 | 提交子仓 + 推进父仓 gitlink | 只在子仓内提交 |
| 查看全局版本状态 | 需逐仓查看并手动比对 | repo manifest -r / repo forall |
| 切换到新版本线 | 需批量更新所有子仓指针 | 切 manifest 分支 + repo sync |
| 需要理解的概念 | gitlink, .gitmodules, gitlink 推进 | manifest XML, repo sync, repo start |
从表格里能看出来,submodule 的日常操作琐碎、心智负担偏重;repo 把"多仓"当成一个整体来操作。但这里我得提醒一句:repo 的心智负担轻,不代表没有负担。它引入了 manifest、分支策略、repo start 这种新的工作流概念,团队如果没形成统一约定,也会乱。
3. 团队协作与仓库演进:谁更适合高速生长的代码库
我见过不少团队从单体仓拆分成多仓时,第一反应就是上 submodule,因为它是 Git 自带的,不需要装任何额外工具。结果用了一段时间后,开始出现各种别扭,其实根本原因都在于:submodule 解决的是"少数几个外部依赖的版本锁定",不是"高频协同开发的一组仓库"。
3.1 权限与责任边界
submodule 的一个隐蔽问题在于权限管理。子仓库通常和父仓分开授权,但在代码评审阶段,Reviewer 看到的可能是父仓里的一次"指针更新"——也就是一个 gitlink diff,而不是子仓里实际的代码改动。这意味着评审的人要么对子仓代码完全不设防(直接看 gitlink 变化就批准),要么被迫在多个仓库之间跳来跳去才能完成一次评审。这两种状态都不健康。
repo 的权限边界更清晰:每个仓库独立管理、独立评审、独立合并。你改动哪个仓,评审也在哪个仓做。manifest 仓库只负责"版本编排",改动通常很小,评审压力天然就小。
3.2 信息入口:gitlink 还是 manifest
submodule 下,“当前这个产品由哪些仓库组成”,这个信息散落在两个地方:.gitmodules里的 URL 列表,和父仓树中每个子目录的 gitlink commit。看起来似乎够用,但当仓库数量增长到二三十个甚至更多,问题就来了:如何快速知道 A 服务依赖 B 服务的哪个版本?要不要给子仓打 tag?这些都需要团队自己建立一套额外的维护规则。
repo 的 manifest 把信息集中到一个文件里。它可以是这样的:
<manifest> <remote name="origin" fetch="https://github.com/example" /> <default revision="main" remote="origin" sync-j="4" /> <project path="services/auth" name="services/auth" /> <project path="services/order" name="services/order" /> <project path="frontend/web" name="frontend/web" /> </manifest>路径、仓库名、拉取分支一目了然。甚至可以对不同 project 指定不同的 revision,比如让 auth 服务固定在 v1.2.0,其余跟 main。
3.3 冲突与合并体验
这是我想专门拿出来说的一点。submodule 在冲突场景下会出一些很磨人的问题。比如两个同事同时更新了同一个子仓的 gitlink,父仓 rebase/merge 时会出现提示:"Automatic merge failed; fix conflicts and then commit the result." 你打开父仓一看,冲突的是libs/bar这个目录的 gitlink:一侧指向 commit A,另一侧指向 commit B。这时候你不能简单地在两个 A/B 里选一个,你得搞清楚子仓里哪个 commit 才是当前代码线真正要的;如果两个 commit 之间还有互相覆盖的关系,处理起来会非常棘手。
repo 在合并这个层面上跟 Git 原生 merge/rebase 基本无关,它只是"批量拉取 + 批量 checkout"的编排工具。你不需要面对 gitlink 冲突,因为这层抽象根本不存在。当然,如果两个同事对同一个子仓库里的同几个文件改了不同内容,该有的冲突还是会有——这是 Git 本身的事,跟 repo 无关。我的意思是:repo 帮你避开了"元信息冲突"这一层,让你只需要面对"真实代码冲突"。
3.4 仓库演进速度的影响
如果你的工程还在快速演进,仓库拆分方案还没定型,我建议谨慎上 submodule。因为每调整一次仓库边界(比如把公共 SDK 从 A 仓挪到独立仓),都需要同步修改所有引用方的.gitmodules和已 checkout 的子仓,操作繁琐且容易漏。repo 时代,仓库边界调整就简单得多:改 manifest 是基本操作,大不了把旧 project 删掉、新 project 加进来,repo sync一遍就能拉齐。
4. 分支策略、代码审核与构建系统的连带影响
多仓管理从来不只是"拉代码"的问题,它会往上下游渗透到分支策略、审核流程和构建发布。这一章讲得很重要,因为很多人一开始只把它当工程问题看,后来才发现是整个研发流程的问题。
4.1 多分支并行的场景
产品并行开发多个版本线(比如 v1.x、v2.x、main),这是多仓管理最大的考验之一。
submodule 的做法是:为每个版本线建立独立的父仓分支,每个分支上的 gitlink 各自指向子仓的对应 commit。维护成本很高——你需要人为保证"父仓分支 A 的 gitlink = 子仓分支 A 的某 commit"这种对应关系。一旦有人只合代码不推 gitlink,版本线就直接漂移了。真要做,还得加 CI 检查。
repo 的做法则是把 manifest 仓库切分支:manifest 的 v1.x 分支写了各子仓 checkout 到 v1.x 对应的分支或 tag,v2.x 分支同理。push 哪个产品版本线,就切到 manifest 对应分支,repo sync一次完成切换。这个模式在 Android 场景里被验证了十几年,成熟度高。
4.2 代码审核流程差异
前面提过 gitlink 的评审盲区。这里再补一点:submodule 模式下,如果团队希望"子仓的代码改动要跟着父仓的发布一起评审合入",经常需要把子仓的改动先 merge 到子仓主干,然后再回父仓升指针。这样评审链路会很长。repo 模式下,每个改动在它自己所属的仓里评审合入即可;产品发版时的"评审"焦点集中在 manifest 仓库的版本编排。职责区分非常明确。
4.3 构建系统与 CI/CD
构建层面,两者也有明显区别。
submodule 的 CI/CD 通常分两步:第一步 checkout 父仓,第二步submodule update。如果没加--remote参数,拿到的子仓版本完全取决于父仓 gitlink;加上--remote就变成了"子仓直接拉远端分支最新",构建可复现性基本就没了。不少团队在这里踩过坑——CI 脚本里写了submodule update --remote,导致 prod 构建和本地构建对不上。
repo 的 CI/CD 更依赖 manifest 的精确性。repo sync默认按 manifest 记录的分支/commit checkout,可复现性天然比 submodule--remote好。建议 CI 里加上repo manifest -r导出精确 commit 清单,旁边存下来作为构建记录的附件,出问题时能精确回溯。
分享一个我常用的排查思路:构建环境里先在 manifest 目录执行git log -1,再执行repo manifest -r,两者一对比就能快速判断是清单未更新还是构建缓存坏了。
5. 选型判断和换工具的避坑清单
5.1 什么时候选 submodule
我给一个比较实际的判断表,场景命中得越多,越适合 submodule:
- 仓库数量很少,通常不超过 5~8 个;
- 子仓不是天天改,它更多是"版本锁定的外部依赖";
- 不想引入任何额外工具,团队对 Git 本身比较熟;
- 每次改动子仓的频次低,推进 gitlink 的负担可以接受。
如果你的子仓基本只改版本、不常动代码,submodule 完全够用,而且零额外安装成本,这是它的核心优势。
5.2 什么时候选 repo
反过来,命中这些场景就越适合 repo:
- 仓库数量多(我从合理经验判断,20 个以上就认真考虑);
- 每次发版都要同时带动多个仓库的代码变更;
- 有多个版本线要长期并行维护;
- 需要统一拉取、统一构建、统一版本回溯。
repo 也并不是安卓专用,任意一个"多个 Git 仓库组成一个产品"的工程都能用。很多芯片、车载嵌入式、系统级 SDK 团队都这么干。
5.3 从 submodule 迁到 repo 的实际坑
如果真的决定从 submodule 迁到 repo,有几点很容易栽:
一是历史 commit 里的 gitlink 引用了子仓的旧提交,切分支或者 checkout 过去的 tag 时,子仓可能已经不存在或者已换地址。迁移前一定先把.gitmodules的 URL 全部重新核对一遍,避免影子依赖。
二是两者工作流切换带来的习惯冲击。团队已经习惯"子仓提交 + 外层指针提交"的协作节奏,切成 repo 后,需要建立新的约定:哪些改动进子仓、什么状态算完成、manifest 什么时候提升。建议第一周只做"一个功能、一个仓库"的实验性迁移,别一上来就大规模切换。
三是一些自动化脚本可能写死了 submodule 命令。比如 CI 里的git submodule update,发布脚本里检查子仓状态。要在切换的同时把这些脚本一起改完,灰度验证再全量。
5.4 一些实际经验
我个人在实际操作中比较偏好 repo 处理"产品级多仓",因为它把版本编排、分支切换、多仓状态审视都封装得相对合理。submodule 更适合"少数外部依赖的版本锁定"这种偏静态的场景。不少团队最终是两者混用:产品核心开发在 repo 体系里,个别闭源或第三方组件仍然以 submodule 形式挂进来——这样既享受了 repo 的批量化优势,也让第三方依赖的版本锁定简单直接。
最后分享一个小经验:不管用哪种方式,都建议在 CI 里加一道"版本一致性"校验。submodule 场景可以检查父仓 gitlink 和子仓 HEAD 是否一致;repo 场景可以直接比对repo manifest -r的输出是否与预期一致。这道校验能在问题扩散之前拦住绝大部分低级事故。