delphij's Chaos

选择chaos这个词是因为~~实在很难找到一个更合适的词来形容这儿了……

23 Sep 2025

git submodule 与 subtree 的异同

本文约 1857 字,阅读大致需要 4 分钟

前几天有小伙伴在整理某个代码仓库的时候,希望把仓库里的代码和数据分离以便于管理。 由于他使用的是 git,所以很快大语言模型便引导他用上了包括 filter-repo 在内的一系列禁术, 其间就有了一段关于是应该使用 git submodule 还是 git subtree 的讨论。

先说我的结论,对于绝大多数人,特别是已经动了重写历史这种念头的人来说, 理想的选择是 git submodule。

版本控制系统的目标与改写历史

版本控制系统的核心目标是完整、可追溯地记录代码的演化过程,这有助于在团队协作中方便地进行协作, 并在需要的时候能够容易地回溯或审计每一次变动。分布式版本控制系统赋予了每一个开发者更大的灵活性, 但历史的完整性依然重要。

然而在现实的开发环境中,我们往往有一些「不得已」(例如出于合约要求必须将某些历史消除干净, 或是出于商业目的避免提前暴露某些开发过程,等等)的理由必须要有能力去改写历史。 人们通常认为,在一个分布式版本控制系统中,一条已经发布的历史线就算是「木已成舟」无法再改变了, 因为即使将其从自己这份 git 仓库中彻底抹去,也无法确保互联网上的备份爱好者手中的副本被完全抹掉。 (说句题外话,我个人经常把重要的、远程仓库控制人不是我自己的那些仓库的 git 副本中 .git/config 里对应的 remote 配置中的 fetch 里形如 fetch = +refs/heads/*:refs/remotes/origin/* 这样的配置改为 fetch = refs/heads/*:refs/remotes/origin/*,也就是去掉源 refspec 前面的 +, 以防止上游在篡改历史时被我忽略掉,或是抹去我本地的副本中的历史)。

还有一种比较常见的需要改写历史的场合:将一个库拆成两个甚至更多。对于大型项目来说, 这种事情并不罕见。 git 在处理超大规模的代码仓库时并不总是游刃有余,而人们在考察代码变动历史时, 最近发生的变动往往是他们关注的重点,而另一些项目,特别是那种不太容易 diff 的二进制大块文件, 人们可能基本不关心历史。如果一个代码仓库有比较长的历史,或是里面除了源代码之外还有一系列大块的、 经常进行改动的二进制文件,那么把它拆分一下就可以显著地提高开发者的日常体验了。

关于改写历史的一系列禁术,这里限于篇幅暂时先不讨论。假定我们已经把一个项目中的几个模块拆成了几个单独的 git 仓库,此时应该怎么把它们接回原来的样子呢?现时流行的有两种做法: git submodule 和 git subtree。

git submodule

git submodule 是 git 的内建功能。它可以把一个新的模块作为 submodule 加入到当前的 git 仓库(通常称作「superproject」)中。git submodule 类似于「指针」,在superproject中,git会用一个「gitlink」 对象来保存引入的 submodule 的 commit(SHA1),而不会将 submodule 仓库整个引入。

这一设计的优点是 superproject 与 submodule 之间有清晰的边界,两个项目可以分别独立地进行开发, 在需要的时候,superproject 的开发者可以更新 submodule,并以 commit 这次变动的形式在 superproject 中进行一次变动。这样一来,尽管不同的 submodule 可以不受限制地进行持续的开发, 但最终它们被 superproject 「确认」的那次 commit 可以一次性地把相关的各种变动一次性地做完。 由于 Git 的 commit 是原子操作,配合持续集成等工具,这可以确保 superproject 的每一个版本都是可用的, 对于大型团队来说,这一特性无疑会节省所有开发者的大量时间。

另一方面,由于 superproject 中不包含 submodule 的实际变化内容,而只是提供了一个 commit hash (通常是SHA1), 因此在 submodule 的快节奏开发中,superproject 不至于一起显著地增大。因此,这种模式具有良好的可扩展性。

git submodule 并非没有缺点。例如,开发者需要记得使用 --recursive,否则 checkout 出来的代码树是不完整的。另外,对于个人项目来说,在 submodule 项目中 commit、然后去 superproject 中添加 submodule,再分别push多少有些反直觉。但整体上,这一开发方式对于现有的 git 工作流改变并不算太大, 而且相关工具也很成熟了。

git subtree

git subtree 早期是一项第三方功能,后来引入了 git 主线, 不过大部分发行版的 git 都默认启用了这个功能。与 submodule 思路不同, subtree 是直接将子模块的一部分历史以「subtree」合并策略(将改动应用到某一子目录)合并。

这样做的优点是子项目的全部历史都保存到了项目中,这样只需要简单地做一个 git clone 就可以获得全部需要的历史。 然而这样做的缺点也很明显:仓库包含了所有子项目的已经合并过的修改历史,这会让它迅速膨胀。 对有拆分仓库的需求的人来说,这无疑是与拆分的初衷背道而驰了。

除此之外,对于大型项目(例如 FreeBSD 就采用了 subtree),在本地合并的这份副本中往往会做一些小的改动。 尽管 git merge 通常能正确处理这些合并冲突,但 git log 一类的工具在处理仅限某个目录范围的日志时, 不如直接在一个项目的顶层查看来的方便,并且本地的变动也不太容易直接回馈给上游。