ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Git submodule与repo怎么选?从原理到实战的避坑指南

Git submodule与repo怎么选?从原理到实战的避坑指南 1. 先搞清楚一件事submodule 和 repo 压根不是同一层的东西在对比它俩之前我先把结论放前面Git submodule 和 repo 解决的问题看着像实际根本不是同一层的东西。submodule 是 Git 的一种链接机制它把“另一个仓库的某个 commit”以指针的形式记录在当前仓库里而 repo 是构建在 Git 之上的批量工作流工具它本身不管理具体的版本依赖它管理的是“一批仓库的集合”。这也就是为什么很多人在网上搜“submodule 和 repo 哪个好”会越看越糊涂——因为答案不应该是“谁替代谁”而要看你的工程形态。我这些年大概见过三种典型的形态单体仓库偶尔引用外部组件两个内部服务之间需要共享一个公共 SDKSDK 单独维护一个仓服务仓以 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 syncrepo 会按 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 下你要知道整套代码的准确版本只能到每个子仓里看 HEADcd libs/bar git log -1然后还要对比父仓记录的 gitlink 是不是一致。指望一个命令给出总览Git 没有这个原生能力需要自己写脚本。repo 下就舒服很多repo manifest -r可以输出一个带精确 commit 的 manifest 文件repo forall -g git log -1可以一次性列出所有仓库的 HEAD。排查问题的时候这几乎是从地狱难度降到普通难度。2.4 操作频率与心智负担差异我做一个表格日常使用比较直观对比维度Git submodulerepo克隆整套代码git clone submodule update --init --recursiverepo 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 还是 manifestsubmodule 下“当前这个产品由哪些仓库组成”这个信息散落在两个地方.gitmodules里的 URL 列表和父仓树中每个子目录的 gitlink commit。看起来似乎够用但当仓库数量增长到二三十个甚至更多问题就来了如何快速知道 A 服务依赖 B 服务的哪个版本要不要给子仓打 tag这些都需要团队自己建立一套额外的维护规则。repo 的 manifest 把信息集中到一个文件里。它可以是这样的manifest remote nameorigin fetchhttps://github.com/example / default revisionmain remoteorigin sync-j4 / project pathservices/auth nameservices/auth / project pathservices/order nameservices/order / project pathfrontend/web namefrontend/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 对应的分支或 tagv2.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的输出是否与预期一致。这道校验能在问题扩散之前拦住绝大部分低级事故。
返回列表