git submodule 与 subtree 的异同
前几天有小伙伴在整理某个代码仓库的时候,希望把仓库里的代码和数据分离以便于管理。 由于他使用的是 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 一类的工具在处理仅限某个目录范围的日志时, 不如直接在一个项目的顶层查看来的方便,并且本地的变动也不太容易直接回馈给上游。