
上个月有个同行在群里发了张截图一个公共的日志封装模块被三个业务项目各复制了一份半年下来三个副本的函数签名全不一样了。修一个时间格式化的 bug得在三个仓库里改三遍还漏了一次结果线上只有一个项目生效。这种场面我见过太多次几乎每个团队发展到一定规模都会撞上同一个坎——公共代码到底该怎么在多个 Git 版本库之间共享。Git 原生给了两套思路完全不同的解法子模块submodule和子树subtree。前者把另一个仓库当成一个指针钉死在你当前仓库的某个提交上后者把另一个仓库的内容连同历史一起揉进你自己的仓库。两者都能让公共代码只保留一份源头但在日常协作里的表现、坑位、维护成本差异极大。这篇就围绕版本库之间的依赖这个主题把两种机制的存储模型、完整操作流、高危动作和选型逻辑拆开讲清楚。不管你是刚开始拆分公共库还是已经被子模块坑过一次打算换方案应该都能从中找到能直接抄的东西。1. 从复制粘贴到依赖管理中间隔着的到底是什么问题1.1 三个副本的宿命从先用着到谁也说不清先说复制粘贴这条路为什么一定会崩。它的问题不在于懒而在于复制的那一刻就把同源关系给切断了。假设项目 A 在三月复制了一份工具库项目 B 在五月复制了一份项目 C 在九月复制了一份。这三次复制的起点其实已经是三个不同的版本了只是当时没人意识到。后面修 bug 时A 的维护者改了dateFormat的时区处理B 的维护者加了缓存C 的维护者什么都没动。等到需要统一升级某个底层依赖时你面对的是三段互不相干的历史没有任何机制能告诉你这三个文件曾经是同一个东西。比这更隐蔽的问题是职责归属。复制之后每个项目团队都会觉得这是我们的代码于是开始按自己的需求改。公共库那边提出一个改进没人愿意跟进公共库提了一个不兼容的 API 变更三个项目各自打补丁。一年之后公共库和三个副本已经是四个不同的项目了只是长得有点像。版本库之间的依赖管理本质就是解决两件事代码归属清晰源头只有一个和版本可控每个消费者明确知道自己用的是哪个版本。这两件事听起来简单但它们的实现方式是互相拉扯的——你越想锁定版本升级就越痛苦你越想跟随最新稳定性就越差。1.2 锁指针与并历史Git 给出的两条路线Git 的两种机制正好对应这两种取舍。子模块走的是锁指针路线。父仓库里记录的不是子模块的文件内容而是一个指向子模块某个具体提交的引用。这个引用在 Git 的索引里表现为一个特殊的条目对象类型是commit权限位是160000。翻译成人话就是父仓库只记得这个目录应该是某某仓库的第几个提交至于那个目录下面有什么文件它一概不管。子树走的是并历史路线。它把对方仓库的内容直接复制到你仓库的一个子目录里作为一个普通的提交合进来。合完之后那些文件就是你仓库的一部分了正常 clone 就能拿到没有任何额外步骤。代价是仓库体积会变大历史会混在一起。这两条路线的分歧点从第一秒就决定了后面所有的操作方式和坑位分布。理解这一点比记住那几条命令重要得多。2. submodule 的存储模型父仓库里究竟存了些什么2.1 一个 gitlink、一个配置文件、一个独立对象库很多人第一次用子模块会懵因为看不到预期的文件。执行完添加命令之后父仓库里多了三样东西位置和作用各不相同第一样是索引中的一个 gitlink 条目。它躺在父仓库的索引里记录路径 → 提交 SHA这条映射。你用git ls-tree能看到它git ls-tree HEAD vendor/lib-shared # 160000 commit 8a3f9c2e1b7d4a5f6e8c9d0b1a2f3e4d5c6b7a8f vendor/lib-shared注意那个160000这是 Git 专门为子模块保留的文件模式含义是这里不是文件也不是目录是指向另一个仓库的引用。第二样是仓库根目录的.gitmodules文件。这个是唯一被父仓库正常跟踪的配置文件里面记录了子模块的路径和 URL还可以指定跟踪哪个分支[submodule vendor/lib-shared] path vendor/lib-shared url https://example.com/lib-shared.git branch main第三样是父仓库.git/modules/下的一份独立对象库。子模块的提交对象、分支、远端配置都存在这里而不是写在子目录自己的.git里。子目录下的.git其实是一个文本文件内容就一行gitdir: ../../.git/modules/vendor/lib-shared这个设计的意义在于父仓库的.git目录被完整搬走或备份时子模块的历史也跟着走。但也正因为这样删除子模块时如果只删目录.git/modules/里的残留会一直挂着后面同名添加会报错。把这三样东西记清楚后面的所有问题你都能定位到根上。2.2 从零添加一个子模块的完整命令流假设现在要把一个公共工具库挂到项目的vendor/lib-shared下面# 1. 添加子模块同时会在 .gitmodules 里写入配置 git submodule add https://example.com/lib-shared.git vendor/lib-shared # 2. 如果需要它跟踪某个分支而不是默认分支 git config -f .gitmodules submodule.vendor/lib-shared.branch main # 3. 提交这两处变更.gitmodules 和索引中的 gitlink git add .gitmodules vendor/lib-shared git commit -m chore: 引入公共工具库作为子模块这里有个细节值得说一下git submodule add会自动把子模块的 HEAD 设置成远端默认分支的最新提交但它不会自动切换到那个分支上。子模块在添加完之后处于游离头指针detached HEAD状态这是正常设计——父仓库锁定的是一个具体提交而不是一个分支名。如果你不确定git submodule status会在提交 SHA 前面没有任何前缀表示状态正常。如果我是在一个已经有内容的老项目里加还得注意目标路径不能已存在且非空否则命令会直接拒绝。要么换个路径要么先把老内容清掉。2.3 克隆方要做的事以及漏做之后看到的现象这是子模块最容易翻车的地方。默认的git clone不会拉取子模块的内容你 clone 下来看到的是一个空目录或者一个只有.git文件的空壳。正确的做法有两种。一次性的是git clone --recurse-submodules https://example.com/project-a.git已经 clone 完了才想起来那就补一刀git submodule update --init --recursive--init的作用是根据.gitmodules把配置写进本地配置--recursive是处理子模块里面还嵌套子模块的情况。少写--init时会报错说子模块未注册少写--recursive时外层好了里层还是空的。还有一种情况你已经 clone 了也 init 过了但是父仓库的维护者后来把子模块的指针推到了一个新提交。这时候git pull只会更新父仓库的 gitlink子目录里的文件还是旧的。git submodule status会在这个子模块前面显示一个号意思是工作区的 HEAD 和索引记录的提交不一致。补一条git submodule update就同步了。我见过最典型的事故是 CI 流水线。构建脚本里写了git clone后面直接跑单元测试结果测试引用的那个模块目录是空的报一堆 import 错误。排查的人花了两个小时以为是依赖安装的问题。后来做了一件事在所有项目的 CI 配置里统一加上submodule: true或者在 clone 步骤显式带--recurse-submodules这类问题就绝迹了。3. 子模块的高危动作与日常维护纪律3.1 改了子模块代码却忘记回父仓库提交指针这个坑的触发率我估摸着有七成以上。场景是这样的你在子模块目录里改了一行代码进去git add、git commit、git push一气呵成觉得没问题了。然后回到父仓库看到git status显示vendor/lib-shared有变化modified: vendor/lib-shared (new commits)你可能顺手就git checkout .给还原了或者压根没注意就切分支走人了。结果就是子模块上游有了新提交但父仓库的锁定指针还停在旧位置。别人 clone 下来拿到的还是旧版本然后来问你你改的东西在哪呢。正确的收尾动作是固定的三步# 在子模块内完成改动并推送 cd vendor/lib-shared git add . git commit -m fix: 修正时区解析 git push origin HEAD:main # 回到父仓库把新的指针提交上去 cd ../.. git add vendor/lib-shared git commit -m chore: 同步公共库至最新提交 git pushgit add vendor/lib-shared这一步加进去的不是文件内容而是那个新的提交 SHA。父仓库这次提交之后所有 clone 的人才知道应该检出哪个版本。提示提交前养成git submodule status看一眼的习惯带前缀就说明指针没同步。3.2 分支切换引发的指针错位和游离头指针父仓库在不同分支上锁定的子模块提交可能不一样。当你在两个分支之间来回切时Git 会自动更新 gitlink但不会自动更新工作区里子模块的实际内容。于是你会看到状态一直是子目录里的代码和当前分支预期的版本对不上。解决办法是在切分支时带上递归git checkout feature-x --recurse-submodules # 或者在切换后立刻 git submodule update --recursive第二个容易懵的点是游离头指针。在子模块里直接git status会看到一行提示说当前不在任何分支上。这不算错误但如果你在子模块里改了东西又没有先建分支切走之后提交会丢。我的习惯是进子模块改代码前先执行git switch -c fix/timezone-parse改完推送再按上一节的三步走。多花三秒省掉一次深夜找提交。还有一种边界情况两个分支对同一个子模块锁定的提交差得很远切过去之后 Git 可能会拒绝切换说会覆盖未跟踪的文件。这时候要么先git submodule update把工作区清干净要么把改动 stash 掉。强推-f不是好主意因为子模块里的未提交改动会被直接抹掉找不回来。3.3 批量操作、递归配置与嵌套子模块的处理项目大了之后子模块可能有五六个手动一个一个 update 效率太低。这时候有两个工具。第一个是git submodule foreach遍历所有子模块执行命令# 一次性把所有子模块切到 main 分支并拉取最新 git submodule foreach git switch main git pull # 查看所有子模块当前的状态和远端地址 git submodule foreach git remote -v git log -1 --oneline第二个是全局开启递归。Git 支持把一些命令默认变成递归模式# 让 checkout / fetch 等命令默认处理子模块 git config --global submodule.recurse true # pull 时按需拉取子模块哪个指针变了拉哪个省时间 git pull --recurse-submoduleson-demandon-demand这个选项值得单独说。默认的递归拉取会把所有子模块都 fetch 一遍项目里子模块多了光 fetch 就要几十秒。on-demand只处理父仓库指针发生变化的那些速度快很多。嵌套子模块的处理原则是所有命令都带--recursive包括 clone、update、foreach。子模块里再挂子模块虽然能用但维护成本直线上升超过两层我个人建议直接换方案或者把最内层改成包管理器分发。权限方面也有个坑如果子模块指向的是需要单独授权的仓库CI 环境里的凭据可能只配了主仓库。表现是 clone 主仓库成功update 子模块时提示认证失败。这时候要么给 CI 配能访问所有相关仓库的凭据要么把子模块改成公开可读的形式。批量删除子模块的步骤也顺手记在这里这个操作必须按顺序来少一步都会留垃圾git submodule deinit -f vendor/lib-shared git rm -f vendor/lib-shared rm -rf .git/modules/vendor/lib-shared4. subtree把对方的提交历史揉进自己的仓库4.1 add、pull、push 三段式的操作流程子树的核心思想很朴素不搞指针直接把对方的文件复制进来作为一次普通提交合入。这意味着你 clone 之后什么都不用做代码就在那儿。第一次引入git subtree add --prefixlibs/lib-shared \ https://example.com/lib-shared.git main --squash--prefix指定落地目录--squash表示把上游的整段历史压成一个提交塞进来。不加这个参数会把上游所有提交都并入你的历史可追溯性更好但仓库会明显变大。后续同步上游更新git subtree pull --prefixlibs/lib-shared \ https://example.com/lib-shared.git main --squash反过来你在本地这个目录里改了东西想推回上游git subtree push --prefixlibs/lib-shared \ https://example.com/lib-shared.git main这条命令背后做的事比看起来复杂它会从你的整个提交历史里把所有影响libs/lib-shared这个前缀的提交挑出来重新组织成一段干净的历史再推给上游。所以如果仓库历史很长subtree push会明显慢有时候要等十几秒甚至更久。这是子树方案最容易被吐槽的点但它也确实解决了双向同步这个问题。一个实操建议把这三个命令写成脚本或者 git alias别每次手敲。比如git config alias.spull subtree pull --prefixlibs/lib-shared git config alias.spush subtree push --prefixlibs/lib-shared4.2 split 与 squash 的取舍以及合并冲突怎么处理git subtree split是子树的另一个核心能力——把一个子目录的历史单独抽出来变成一个独立分支git subtree split --prefixlibs/lib-shared -b shared-only这条命令产生的分支历史里只包含影响那个目录的提交其他提交全部被过滤掉。它的典型用途是把一个项目里的公共部分剥离出去独立成库。--squash用不用取决于你是想保留上游的完整提交记录还是只关心内容快照。我的判断标准是这样的如果是内部团队维护的公共库双方都要双向提交那不加 squash历史完整出问题时能查到具体是哪次提交引入的如果是引用的第三方代码基本只读那加 squash仓库干净体积也可控。冲突处理上子树跟普通 merge 完全一样。git subtree pull遇到冲突会停下来你在工作区解决git add然后git commit完成合并。有个小细节冲突标记里的路径是带前缀的完整路径看一眼就知道是哪个文件在打架。解决完不需要额外操作比子模块那套进去出来再提交指针的流程省心不少。4.3 子树方案那些不会写在文档里的隐性成本子树看起来很美好但有几笔账得提前算。第一笔是仓库体积。每次subtree pull都会在你的仓库里增加提交不管你加没加 squash。一个每天都同步的项目一年下来仓库能胖出好几百兆。如果你在意 clone 速度这个成本会慢慢显现。第二笔是历史噪音。不加 squash 时上游的每个提交都会出现在你的git log里作者是别人提交信息跟你项目的规范也对不上。做 blame 分析的时候会被干扰。第三笔是push 的可靠性。subtree push在历史复杂时容易发生奇怪的情况比如推送被拒绝但报错信息看不出原因。最常见的触发条件是你本地这个目录的提交里有合并提交split 出来的历史无法线性应用。遇到时可以先git subtree split出一个中间分支检查一下里面的提交是否合理再从那个分支推。第四笔是前后缀的一致性。--prefix一旦定下来就最好别改因为 split 的逻辑完全依赖路径匹配。改了目录名等于把历史断了后续 pull 会当成一个全新的目录来合并产生大量重复文件。这四笔账不是说子树不好而是说它更适合单向引用为主、偶尔回推的场景。双向高频同步子树的摩擦会更明显。5. 两种依赖方式的横向对照与选型判断5.1 把差异摊成一张表我在团队里做选型评审时习惯用一张表把所有维度摆出来避免讨论跑偏维度子模块 submodule子树 subtree存储方式父仓库只存提交指针完整内容并入库clone 后的可用性需 extra 步骤才能拿到内容直接可用版本锁定精确锁定到提交无锁定同步时才更新仓库体积几乎不增加随同步次数增长历史清晰度各自独立互不干扰混在一起需要 prefix 区分上游更新拉取update --remote或手动切subtree pull本地改动回推上游进子模块提交推送即可subtree push历史复杂时慢冲突处理少见因为不共享文件级历史常见按普通 merge 处理团队上手成本较高容易漏步骤较低跟普通目录没差别CI 配置必须显式加递归参数无需额外配置删除干净程度需要四步清理删目录加提交即可适合场景依赖稳定、版本强约束需要频繁双向同步这张表里最容易被低估的是clone 后的可用性这一行。它带来的连锁反应很大新人入职第一次拉代码失败问一圈没人说得清外部合作方拿到仓库打不开临时在别的机器上验证一个问题还得额外折腾一遍。而版本锁定这一行是子模块最大的价值。它意味着父仓库的每一次提交都精确对应着所有依赖的确定版本。这在需要严格复现历史构建的场景里非常关键——比如要给半年前的一个版本打补丁你检出的那一刻所有依赖的版本都自动还原到当时的模样。子树做不到这一点因为内容已经混进来了你只能靠 commit 时间来猜。5.2 按团队形态和发布节奏做决定抛开表格我给几条更实用的判断标准。看团队的 Git 熟练度。如果队伍里有相当比例的人对 Git 的理解停在add / commit / push三件套子模块会持续制造问题。每次切分支、每次拉代码、每次进子模块改东西都是一个新的出错点。子树在这点上友好得多因为对使用者来说它就是一个普通目录。看依赖的变更频率。公共库一个月发一次版子模块很合适升级时手动 update 一次明确可控。公共库每天都在改而且多个项目都要跟着改那子树的同步成本更低。看发布产物是否需要自包含。如果交付物是一个可以独立部署的包需要把源码整体打包给对方子树更省事因为代码本来就在仓库里。子模块打包时得额外处理或者干脆不走源码交付。看是否有内部包管理体系。如果团队已经有一套成熟的包发布流程私有 registry、版本号语义化、依赖锁文件那最合适的其实是既不用子模块也不用子树直接用包管理器。Git 的这两种机制是没有包管理时的替代方案不是更优解。这点我踩过坑——曾经用一个子模块当依赖管理结果版本升级完全靠人记后来上了包仓库整套流程顺畅十倍。6. 从复制粘贴迁移到依赖管理的落地路径6.1 拆出公共代码的四个阶段存量项目切换到依赖管理最怕的是一步到位。我的经验是分四步走每一步都可回退。第一阶段识别与冻结。把三个项目里重复的那些模块找出来逐个对比差异列一张差异清单。这一步不写代码纯分析。差异超过三成的模块先不动因为合并成本可能高于收益。同时给选中的模块定一个冻结时间点之后不再往旧副本里加新功能。第二阶段建独立仓库。选一个差异最小的副本作为基础新建仓库把代码放进去加上必要的构建配置和文档。这个仓库最好是能独立跑通测试的否则后面每次改动都没法验证。第三阶段逐个项目接入。先在一个项目里接入用哪个方案看前面的选型判断。接入之后跑一轮完整回归确认功能正常。跑通了再动第二个项目。这一步千万别并行出了问题不好定位是哪个接入引入的。第四阶段清理旧副本。所有项目都切换完之后把原来的复制目录删掉同时在团队里公告这个模块的源头在某某仓库改代码请提 PR。这一步很多人省了结果半年后又长出新的副本。整个周期我给个参考三个项目、两三个模块的规模认真做大概两到三周。想一周搞定通常是在第三阶段出问题。6.2 日常维护里那几条不能松的纪律机制选对了还得靠日常习惯兜底。以下几条是我在实际项目里反复验证过的。提交信息带上依赖变更说明。父仓库里那种同步子模块的提交信息里要写清楚从哪个版本到哪个版本改了哪些内容。三个月后有人 bisect 找问题时这条信息能救命。给子模块指针变更单独提交。不要把子模块指针的变更和业务代码混在同一个提交里。分开之后回滚业务代码不会误伤依赖版本反之亦然。每次发版前跑一次全量同步检查。针对子树方案发版前确认本地目录和上游是不是一致的针对子模块确认所有指针是不是都已经推到远端。这个检查可以写成脚本一行命令输出所有子模块的当前提交和远端最新提交的差异。给 CI 加一条断言。在流水线最开始加一步检查子模块目录是否非空或者检查合并后的代码里是否包含预期的版本标记文件。这条断言能在三十秒内暴露大部分依赖拉取问题比事后排查省太多时间。新成员文档里写清楚依赖拉取步骤。不是写请初始化子模块而是给出可以直接复制的完整命令。文档里那段命令我自己每季度会拿出来在一个全新环境里试一遍确认还能用。如果非要在两种机制里挑一个当默认我的倾向是能上包管理器就上包管理器不能上就优先子树只有在需要严格版本锁定和依赖足够稳定的场景才用子模块。这个排序是根据维护成本排的不是根据功能强弱排的。子模块的机制本身很优雅但它把复杂度转嫁给了每一个使用仓库的人而子树把复杂度集中在了少数几次同步操作上——后者在团队协作里通常更容易被消化。