为什么我不推荐你使用 git submodule
最近,笔者在某些项目的开发中碰到了 git submodule 并结结实实地被它恶心到了,从奇怪的版本管理到与父项目的尴尬关系——总之处处透漏着不舒服,本以为只有我是这么觉得,结果随手一搜发现与我有同样想法的人不在少数,这次就让我结合@dizlet的一篇博文,来谈谈为什么 git submodule 那么令人生厌?又有什么别的方式可以替换掉它?
太长不读版
永远别用 git submodule,即便你觉得现在碰到的情况很适合用它,也别用。
下面我会详细解释一下为啥不要用,以及各种替代方案。
Git Submodule 的问题
它的问题主要体现在两点:
- 底层设计上就有问题。它破坏了 git 的数据模型,包括但不限于以下几个方面:
- 你的仓库中的 git 对象不再一定能解析为有意义的数据。(浅克隆也有类似的问题,但那只是对于历史记录来说。而 git submodule 对树也一样有问题。)
- 它不符合 git 的一般规定。子模块中的 URL 和主机名都是由 git 配置文件决定的,而不是通常的 git 仓库本身。
- 它会导致 git 树处于奇怪的状态,而要排除起来则非常痛苦。
- 细节实现上也问题多多。有的是从设计根上带出来的,但更多的是实现上的问题,即便你压根没用
git submodule init
初始化各个子模块,它还是会影响到你的仓库,暴露出的问题如下:- 用于切换分支的
git checkout
命令不再可靠。 - 编辑和提交会变得非常痛苦
- 从主分支拉取代码会变麻烦
git ls-files
的输出会和git log
和git cat-file
产生冲突.gitmodules
中的 URL 可能包含恶意链接,它甚至能被缓存在本地的.git/config
文件里。
总的来说,很多非常常见的 git 操作,诸如git checkout
和git pull
这样的命令就会导致子模块处于非常怪异的状态,你必须要运行某个与子模块相关的命令才能回到正常的状态。对绝大部分人来说,他们可能更愿意直接把仓库删了,然后重新克隆——我也是这么干的,除非你是某位 git 绝地大师,否则千万别试图排除奇怪的子模块问题,纯属浪费时间。
- 用于切换分支的
所以,对我们开发者(库的维护者)而言,就只剩两个方案了:
- 干脆别用,跟各位开发者讲清楚子模块的各种问题,并让他们放弃。
- 捏着鼻子用,并准备好应对它给你带来的各种麻烦核问题,并浪费大量的时间和精力处理这些问题。
有什么替代品吗?
但是,如果真的有类似需求,又不想在子模块上浪费人生要怎么办呢?我推荐下面的这些解决方案,你可以根据自己的需求选择合适的方案。
Git Subtree
Git Subtree 可以解决很多 git submodule 能解决的问题,同时不会破坏 git 的数据模型。
如果你的项目符合这些特点,可以考虑使用 subtree:
- 你想要在你的代码树里跟踪并使用另一个项目,但又想保存它的独立性。
- 与你的项目相比,子项目的大小相对合理。
如果你使用 git subtree,大部分开发者都不需要与子树中的项目交互,他们甚至不会注意到这是一个子树,可以随便提交、切换分支,怎么搞都没问题。git subtree 可以自动从下游分离出对下游分支的更改,以应用于(或提交给)上游分支。
我之前在自己的项目中使用过 git subtree,并发现它强大、方便,且非常简单直接,因此我推荐你也尝试一下!
干脆就用一个仓库
如果你想引入的项目本来维护人就是你,那么选择用一个仓库也是一个不错的选项,因为你可以直接把另一个项目的 git 历史合并进来。
如果你的项目符合这些特点,可以考虑直接使用单个仓库:
- 你仓库中使用的各个项目能够共享同样的 git 历史记录
- 整个项目的大小在合理的范围内
- 你项目中长期存在的分支只是为了维护发版,而不是为了让某个内部项目维持在不同的版本。(例如 PCRE2 与仓库中的 sljit)
使用包管理系统和显式的依赖
与其自己维护依赖,还不如直接用别人打包好的!这个方案的精髓就在于将你的项目与依赖分开,并使用它提供的各种 API 取代之前在树中代码的作用,然后用一个包管理器安装它!(如有必要的话,你可以自己维护一个特殊版本的下游包)
可供选用的包管理系统包括:
- 给发行版用的包管理系统:比如 Debian 的 apt + dpkg + sbuild 的组合。
- 语言专属的包管理系统,比如 pip 或者 cargo。
如果你的项目满足以下要求,可以考虑这个方案:
- 你正在使用、或者很熟悉一个合适的包管理器。
- 这个包管理器可以将你需要的 API 以合理的方式暴露出来。
使用多仓库工具:MR
mr 是一个可以让你方便地管理多个代码树的工具,通常这些树之间是并列关系。
我虽然没有亲自用过这个工具,但它看起来还不错,你可以把它跟我下面要介绍的基于..
的依赖解决方案结合起来。如果你的项目里有很多外部项目的话,可以考虑这个方案。
把依赖放在 ../dependency 里
这个解决方案非常轻量,不需要任何工具,只需要把你项目的依赖放到../dependency
里就好了。并让用户手动选择正确版本的依赖。
如果你的项目符合这些条件,可以考虑这个方案:
- 你的项目处于初期起步阶段,你不想过多操心依赖和构建的问题。
- 依赖默认是被禁用的,并且几乎不会被启用。
每个需要依赖项的程序或人都需要明白如何克隆依赖项并更新,这可能会带来一些麻烦,如果你正在使用 CI(持续集成),则需要编写一些自定义的 CI 脚本。但这还是比 git submodule 强,至少对于大部分人来说,究竟发生了什么、以及如何对依赖项进行更改等都是完全可见的。
提供一个临时的内部脚本来下载依赖项
作为最后的手段,你可以在顶层软件包的构建系统中嵌入用于查找依赖项的 URL 和下载指令。这种方法很笨、也很麻烦,但可能让你惊讶的是,它还是比 git submodule 要强得多。
如果你的项目符合这些特点,可以考虑这个方案:
- 大多数使用/构建你的软件的人根本不需要依赖项。
- 大多数人不需要编辑依赖项。
任何其他的情况下,都不要考虑这个方案。
通常情况下,下游构建过程应该使用 git clone 命令克隆依赖项,下游代码树应该指定所需的准确提交 ID。
尽量避免使用这个方案,这并不是一个优秀的解决方案。但是:
真的,git submodule 还不如 Makefile
临时的 shell 脚本看起来很不让人满意。但是与 git submodule 相比,它还是有一些优点的。比如,与 git submodule 不同,这种方法(就像我提出的大多数其他方法)意味着:
- 所有期望克隆你的存储库、进行更改、构建、跟踪更改等的工具都能正常工作。
- 你可以精确控制下载发生的时间/条件:也就是说,你可以安排在需要依赖项时精确下载它。
- 你可以精确控制依赖项的版本管理和检查:你的脚本控制着使用的依赖项版本以及是否“固定”或动态更新。
再次重申一遍,我并不喜欢临时脚本,也不认为它是一个好主意,只是因为 git submodule 实在是太烂了,总之一句话,千万别用!