【Git教程】(十)版本库之间的依赖 —— 项目与子模块之间的依赖、与子树之间的依赖 ~-LMLPHP

在 Git 中,版本库是发行单位,代表的是一个版本,而分支或标签则只能被创建在版本库这个整体中。如果一个项目中包含了若干个子项目,它们有各自的发布周期和属于自己的版本,那我们就必须要为每个子项目建立对应的版本库了。

对于主项目和子项目之间的关系,我们可以通过Git 中的 submodulesubtree 命令来实现。
请注意,subtree 命令是在1.7.11这一版本中首先被正式纳入Git 的。但该命令只是 contrib 目录下的一个可选组件。有些 Git 的安装包会自动包含的subtree 命令,而另一些则需要我们去手动安装。

子模块和子树这两个概念之间的主要区别在于:带子模块的主版本库只能发布模块版本库,而模块版本库的内容中带有子树的话,该模块版本库就被导入了主版本库中。


1️⃣ 与子模块之间的依赖

对于子模块来说,其模块版本库可以被嵌入到主版本库中去。为了实现这一点,模块版本库中的提交会以目录的形式被链接到主版本库中。

下面,我们通过下图来看看其基本结构。该图中有main 和 sub 两个版本库。在主版本库中,sub 目录将会与模块版本库相链接。这样,主版本库工作区的 sub 目录下就有了一个 完整的模块版本库。但事实上主版本库其实只是引用了模块版本库。为了实现这一目标,我们就得有一个名为 .gitmodules 的文件,以便用来定义各模块版本库所在的绝对路径。

[submodule "sub"]
path=sub
url=/project/sub

【Git教程】(十)版本库之间的依赖 —— 项目与子模块之间的依赖、与子树之间的依赖 ~-LMLPHP

除了.gitmodules 文件之外,子模块的引用信息还会被被保存在 .git/config 文件中。该文件会在我们调用 submodule init 命令时完成存储,届时该命令会将从 .gitmodules 文件中读取的信息写入到 .git/config文件中。有了这样的间接配置,我们就可以在 git/config 文件对模块版本库的路径进行本地化调整了。

[core]
repositoryformatversion=0
filemode=true
bare=false
logallrefupdates=true
ignorecase=true
[submodule "sub"]
url=/project/sub

凭借上述信息,我们是不可能为主版本库中的每次提交都重现相应模块版本库的版本的。 也正因为如此,模块版本库中的提交才仍会被需要。这些都将会被存储在主版本库的对象树 中。下面我们来看看该对象树。其第三项 sub就是一个子模块,它可以被识别成 commit类型,随后的散列值引用的就是模块版本库中的提交 。

100644 blob le2bld1d51392717a479eaaaa79c82df1c35d442    .gitmodules
100644 tree 19102815663d23f8b75a47e7a01965dcdc96468c   src
160000 commit 7fa7elclbd6c920ba71bd791f35969425d28b91b sub

如果我们克隆了一个带子模块的版本库,就必须调用一下 submodule init 命令。该命令 会将.git/config 文件中各子模块的 URL 传送过来。之后,我们就可以调用 submodule update 命令来克隆模块版本库所在的目录了。

我们可以用 submodule status 命令查看子模块中被引用提交的散列值。其中如果存在标签的话,也会以括号的形式显示在输出的结尾处。

> git submodule status
091559ec65c0ded42556714c3e6936c3bla90422 sub(v1.0)

在这里,Git 往往引用了模块版本库中的一次提交。而与此同时,该提交对象的散列值也是主版本库中每次提交的一个部分。模块版本库中随后的新提交并不会自动被记录在主版本库中。这种操作必须要显式执行,以便我们在主版本库中恢复某一项目版本时可以获取与之相匹配的、模块版本库中的项目版本。

如果我们想在主版本库中使用模块版本库的某一新版本,就必须要对其进行显式修改。如果我们同时在主版本库与模块版本库中工作,就必须要将修改同时提交到两个版本库中。如果你还有一个中央版本库,那么这两个版本库都必须分别执行 push 命令,各自单独完成传送。

每次在对包含子模块的工作区执行更新之后后,我们应该随之调用 submodule update 命令来获得各子模块的正确版本。
如果这次是添加了一个全新的子模块,那么在执行submodule update 命令之前,我们还应该先调用一下 submodule init 命令。
另外作为开发者,如果我们在每次更新工作区内容(包括签出、合并、变基、重置、拉取等操作)之后都要执行一次初始化-更新命令序列,就说明事情做得不够好。

当然,只有在当前工作区中没有相应的模块项的时候, submodule init 命令才会将 .gitmodules 文件中的信息传送给.git/config 文件。这样一来,我们就可以对模块版本库的路径进行本地化调整了。但如果这时有另一个开发者已经修改了.gitmodules 文件中的正式路径, 我们的修改就不会被接受。这就必须要通过 submodule sync 命令来完成此任务了。该命令会更新.git/config 文件中的路径并覆盖掉所有的本地修改。


2️⃣ 与子树之间的依赖

利用子树的概念,我们可以将一些模块版本库嵌入到某一个 Git 版本库中。为了实现这一点,我们必须要将该版本库中的某一目录与模块版本库中的某一提交、标签或分支关联起来。但与子模块不同的是,这回是一个被嵌入的模块版本库,其全部内容是被导入主版本库,而不在仅仅是引用了。这使得主目录中的工作相对更为自给自足了。

下面,我们通过下图来看一下子树处理的基本结构。在该图中,我们有 main 和 sub 两个版本库:我们(通过subtree add命令) 将主目录中的sub 目录与模块目录链接了起来。
而在主版本库的 sub 目录下,我们看到了来自模块版本库中某一版本的文件。

【Git教程】(十)版本库之间的依赖 —— 项目与子模块之间的依赖、与子树之间的依赖 ~-LMLPHP

从技术上来说, subtree add 命令会将模块版本库中所有的提交都导入到主版本库中(即 提交 S1 和 S2) 。然后,主版本库的当前分支就被链接到了模块版本库的特定提交上(即合并提交 G3) 。 在内部, Git 用到了它的子树合并策略(-strategy=subtree )。这样一来就在特定的目录里出现了一次合并,将模块版本库中的内容载入到了sub 目录下。

与子模块不同的是,当某一带子树的版本库被克隆时,我们通常并不会观察到什么特殊情况。 一般情况下, clone 命令都会去捡取整个主版本库以及它所包含的所有模块版本库。

> git clone /path-to/main

另外通过子树,我们才有可能直接在嵌入式模块的目录中做某些修改。在这里,如果我 们并没有什么特别需求的话。只需调用一般性的commit 命令就可以了。当然,我们也可以将主版本库中的相关修改或者某一提交中一个或多个模块目录版本化。
只有在重发各版本库中对模块所所做的修改时,我们才需要采取一些预防性措施。

从上述内容,我们可以清楚地看到,大部分子树操作都要比那些相应的子模块简单一些,两者只有在提取修改方面的复杂度是差不多的。
但在多数情况下,我们是不会用到提取操作的,因为我们是在主版本库上工作,而不是 模块目录中。

🌾 总结

  • 嵌入子模块:我们可以通过submodule addsubmodule init 命令来嵌入一个子模块。
  • 克隆包含子模块的项目:我们可以在克隆该项目后,对其调用 submodule initsubmodule update 命令。
  • 选择子模块中的某个新版本:首先,我们要(通过 checkout 命令来看) 选择在子模块目录中的新提交。然后,在主版本库中对其做一次提交。
  • 同时处理模块版本库与主版本库:我们必须要先在模块版本库中执行提交,然后才能在主版本库中执行提交。另外,两个版本库的推送操作也必须要各自执行 push 命令。
  • 嵌入子树:我们可以通过subtree add 命令来嵌入子树。
  • 选择子树中的某个新版本:我们可以通过subtree pull命令来将模块目录更新到所需的分支或标签上。
  • 提取模块目录中的修改:我们可以通过subtree split 命令创建一个单独的分支,用于 包含模块目录在的修改。然后再使用 merge 命令将这些修改与其他修改合并,并用 push 命令完成推送操作。


【Git教程】(十)版本库之间的依赖 —— 项目与子模块之间的依赖、与子树之间的依赖 ~-LMLPHP
04-13 14:28