0%

Git子模块

纸上谈兵式的 Git 子模块学习记录。


参考这里这里

为项目添加子模块

1
$ git submodule add https://github.com/chaconinc/DbConnector

它会修改本地配置,并在当前仓库下添加一个 .gitmodules 文件以及一个和子模块仓库同名的目录。

本地配置修改如下,它保存了项目 url 和本地目录之间的映射。

1
2
3
4
5
$ cat .git/config
...
[submodule "DbConnector"]
path = DbConnector
url = https://github.com/chaconinc/DbConnector

新添加的 .gitmodules 文件做了同样的事情。我们可能会想,这不是重复了吗?其实主要是因为,本地配置是本地的,无法被其它项目合作者看到的呀!因此需要一种机制来让他们知道,项目里需要设置哪些子模块,这就是 .gitmodules 的作用。

1
2
3
4
$ cat .gitmodules
[submodule "DbConnector"]
path = DbConnector
url = https://github.com/chaconinc/DbConnector

新添加的 DbConnector 目录里已经获取到了 https://github.com/chaconinc/DbConnector 里的所有内容。

使用 status 或 diff 就能查看到这些宏观的区别:

1
2
3
4
5
$ git status
...
Changes to be committed:
new file: .gitmodules
new file: DbConnector
1
2
3
4
5
6
7
8
9
$ git diff
diff --git a/DbConnector b/DbConnector
# 这里的 160000 是 git 中的特殊模式,它本质上意味着我们是将一次提交记作一项“目录”记录的,而非将它记录成一个“子目录”或者一个“文件”
new file mode 160000
index 0000000..fe64799
--- a/DbConnector
+++ b/DbConnector
@@ -0,0 +1 @@
+Subproject commit fe6479991d214f4d95ac2ae959d7252a866e01a3

但是我们会发现,上面的 git statusgit diff 完全没有体现出子模块内部的变化。这是因为像 log、diff 这种命令,只会关注当前的 git 仓库(目前是外层的仓库)。我们可以附加一些选项来显示子模块信息,或者直接进入子模块目录去查看 status 和 diff。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 方法一
$ git config --global status.submoduleSummary true
$ git status
...
Changes to be committed:
new file: .gitmodules
new file: DbConnector
Submodule changes to be committed:
* DbConnector 0000000...fe64799 (3): # 表明子模块仓库里已有 3 个提交
> fix: xxx # 会显式地展示引入的提交
> feat: xxx
> Initial commit

# 方法二
$ git submodule status

# 方法三
$ cd DbConnector
$ git status
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 方法一
$ git config --global diff.submodule log
$ git diff
Submodule DbConnector 0000000..fe64799:
> fix: xxx
> feat: xxx
> Initial commit

# 方法二
$ git diff --submodule

# 方法三
$ cd DbConnector
$ git diff

那现在我们已经弄好了子模块(它会自动被暂存的),就可以在外层的仓库去提交和推送了。

1
2
$ git commit -m "Adding DbConnector submodule"
$ git push origin master

克隆含子模块的项目

1
$ git clone https://github.com/chaconinc/MainProject

当我们用这种常规的方法 clone 时,默认会包含该子模块目录,但是其中是没有任何文件的。因为新仓库的 .git/config 里是没有子模块相关信息的,那它就无法意识到有 submodule。我们就需要把 .gitmodules 里的内容填充过去,使用的命令如下。

1
$ git submodule init

现在我们的子模块还是空的,所以我们需要把子模块仓库里的内容都抓取过来,使用命令如下。

1
$ git submodule update

这下子模块就 ok 了。

如果不想分步进行,我们可以使用下面的命令来简化这个过程。

1
2
# update + init
$ git submodule update --init

有时候这种手动一个个初始化 submodule 会遇到问题,比如当 submodules 里有自己的 submodules。解决办法是使用 --recursive 选项。

1
2
# update + init (recursively)
$ git submodule update --init --recursive

如果想直接在克隆的时候就立刻 update + init (recursively),可以使用下面的指令,这个和上面是等价的。

1
$ git clone --recurse-submodules https://github.com/chaconinc/MainProject

当子模块的 remote repo 有更新

当子模块的 remote repo 有更新,且项目的另一个副本仓库内已经有了这份更新时,我们可以有两种选择。一是从子模块的远端拉取上游修改,二是从项目远端拉取上游更改。我们下面分开讲。

假设子模块的 remote repo 里新增了两个 commit,按顺序分别为 Pseudo-commit #1(e6f5bb6) 和 Pseudo-commit #2(0e90143),原先的 HEADfix: xxx(fe64799)。

从子模块远端拉取上游更改

1
2
3
$ cd DbConnector
$ git fetch
$ git rebase origin/master

上面就是常规操作了,进入子模块目录,执行 fetch + rebase

如果不想在子目录中手动抓取与合并/变基,可以用下面的命令更新并检出子模块的 master 分支。(如果想设置为别的分支,可以用 git config -f .gitmodules submodule.DbConnector.branch dev)

1
2
$ git submodule update --remote --rebase DbConnector
# 这里用 rebase 和 merge 都可以;子模块名是可选的,不填是更新所有子模块

那我们回到项目跟目录看看状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 当我们将 status.submoduleSummary 设置为 true
$ git status
...
Changes not staged for commit:
modified: DbConnector (new commits)

Submodules changed but not updated:
* DbConnector fe64799...0e90143 (2): # 可以看到新增的 2 个提交
> Pseudo-commit #2
> Pseudo-commit #1

# 当我们将 diff.submodule 设置为 log
$ git diff
Submodule DbConnector fe64799..0e90143:
> Pseudo-commit #2
> Pseudo-commit #1

那现在我们只需要到 container 里去提交和推送了。一般来说,不建议把子模块的修改和不相关非子模块的修改混到一个提交里面,因为分开的话,后续的代码重用迁移等会更加方便。

1
2
$ git commit -m "Updating submodule to v2"
$ git push

从项目远端拉取上游更改

1
2
3
4
5
6
7
8
9
10
$ git pull
# 从这里开始,是子模块外的抓取
...
From https://github.com/chaconinc/MainProject
fb9093c..0a24cfc master -> origin/master
# 从这里开始,是子模块的抓取
Fetching submodule DbConnector
From https://github.com/chaconinc/DbConnector
fe64799..0e90143 master -> origin/master
...

项目的 fetch 会同时自动递归抓取子模块,要注意的是,它自动 fetch 却不自动 update(坑点1)。也就是它会显示 container 有新提交,但尚未在本地的 DbConnector 检出。我们的工作目录没有和暂存区同步,后者意识到了新引入的子模块提交。

1
2
3
4
5
6
7
$ git status
Changes not staged for commit:
modified: DbConnector (new commits)
Submodules changed but not updated:
* DbConnector 0e90143...fe64799 (2): # 注意这里的尖括号向左指了,而不是向右指
< Pseudo-commit #2
< Pseudo-commit #1

这里如果我们不 update 子模块,那么后面的提交就会倒退子模块(我理解应该是忽视它的更新)。为了完成更新,我们需要先 update 一下子模块。

1
$ git submodule update

为了安全起见,我们最好在 update 时添加 --init--recursive 来自动初始化新子模块,并且在需要的时候递归地 update 它们。

1
$ git submodule update --init --recursive

如果想在 pull 后自动 update 子模块,可以在使用 git pull --recurse-submodules ;如果想让 git 总是以 --recurse-submodules 拉取,可以将 submodule.recurse 设置为 true

还要注意的一点是,有可能出现一种特殊情况(坑点2):在我们拉取的提交中,.gitmodules 文件中的 url 有变化,比如子模块改变了托管平台。此时,本地配置 .git/config 和新拉取的 .gitmodules 就不一致了,执行 git pull --recurse-submodulesgit submodule update 就会失败。下面是解决办法:

1
2
$ git submodule sync --recursive          # 将新的 URL 复制到本地配置中
$ git submodule update --init --recursive # 从新 URL 更新子模块

综上所述,最万无一失的做法是:

1
2
3
$ git pull
$ git submodule sync --recursive
$ git submodule update --init --recursive

当子模块的 local repo 有更新

当子模块的 local repo 有更新,也就是我们在本地的子模块中进行了一些工作,并且想要提交推送时,需要注意的是,项目和子模块都要推。这里还是有个建议,不要让子模块和外部耦合过于紧密,也就是要让更新最小化。

假设子模块的 local repo 里新增了一个 commit 叫Pseudo-commit #3(12e3a52) ,项目的 local repo 里也新增了一个 commit 叫 Updating submodule to v3(ad9da82)。

1
2
3
4
5
6
7
8
9
10
$ cd DbConnector
... # do some work
$ git commit -am "Pseudo-commit #3"
[master 12e3a52] Pseudo-commit #3
1 file changed, 1 insertion(+)

$ cd ..
$ git commit -am "Updating submodule to v3"
[master ad9da82] Updating submodule to v3
1 file changed, 1 insertion(+), 1 deletion(-)

现在我们既要 push 外层项目,也要 push 内层子模块。

1
2
3
4
$ cd DbConnector
$ git push
$ cd ..
$ git push

一切 ok!不过有时候我们容易遇到一些问题,下面分别讨论一下。

忘记在子模块 push

也就是在内层和外层都 commit 了,但是只在外层 push 而没在内层 push,这是一个很容易犯的错误(坑点3)。这将导致其它尝试检出我们修改的人遇到麻烦,因为他们无法得到依赖的子模块改动,那些改动只存在于我们本地的拷贝中。

比如我们现在是同事 A,在另一个工作区,想要拉取新的项目代码。

1
2
3
4
5
6
7
8
$ git pull
# 子模块外的抓取
...
From https://github.com/chaconinc/MainProject
766cd47..ad9da82 master -> origin/master
# 子模块的抓取
Fetching submodule DbConnector
Successfully rebased and updated refs/heads/master.

在这里,没有任何迹象表明 git 没有办法拿到远程的引用提交,同事 A 无法在这个时刻意识到问题,这也是比较迷惑人的地方。只有更加深入看,才能发现问题。

1
2
3
4
5
6
7
8
$ git status
...
Changes not staged for commit:
modified: DbConnector (new commits)

Submodules changed but not updated:
* DbConnector 12e3a52...0e90143:
Warn: DbConnector doesn't contain commit 12e3a529698c519b2fab790630f71bd531c45727

当同事 A 运行 status 命令时,就会发现这里有 Warn,它告诉我们无法找到子模块的新提交。

1
2
3
$ git submodule update
fatal: reference is not a tree: 12e3a529698c519b2fab790630f71bd531c45727
Unable to checkout '12e3a529698c519b2fab790630f71bd531c45727' in submodule path 'DbConnector'

当同事 A 运行 update 命令时,同样也会出现 fatal 警告,告诉我们找不到这个新提交。

所以当修改完子模块时,一定要记得要内外都要 push。有一个选项可以在 push 的时候监督这一行为,命令如下,它可以有多种取值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 当任何提交了的子模块改动没有被推送时,push 失败
$ git push --recurse-submodules=check

The following submodule paths contain changes that can
not be found on any remote:
DbConnector
Please try
git push --recurse-submodules=on-demand
or cd to the path and use
git push
to push them to a remote.

# 当任何提交了的子模块改动没有被推送时,会尝试进入相应子模块去推送
$ git push --recurse-submodules=on-demand

Pushing submodule 'DbConnector'
Counting objects: ...
Delta compression using up to ... threads.
Compressing objects: ...
Writing objects: ...
Total 9 (delta 3), reused 0 (delta 0)
To https://github.com/chaconinc/DbConnector
0e90143..12e3a52 master -> master

上面的选项还有等效的配置,如下。

1
2
$ git config push.recurseSubmodules check
$ git config push.recurseSubmodules on-demand

抓取的远端改动未被跟踪

到目前为止,当我们运行 git submodule update 从子模块仓库中抓取修改时, git 将会获得这些改动并更新子目录中的文件,但是会将子仓库留在一个称作“游离的 HEAD”的状态。 这意味着没有本地工作分支(例如 master )跟踪改动。 如果没有工作分支跟踪更改,也就意味着即便我们将更改提交到了子模块,这些更改也很可能会在下次运行 git submodule update 时丢失。

需要做的事情就是安置好 HEAD,也就是显式地切换到一个分支即可。

1
2
$ cd DbConnector
$ git checkout master

在这之后,我们再去拉取 remote 再 rebase 或 merge 地跟新到本地就可以了。

1
2
$ cd ..
$ git submodule update --remote --rebase

如果忘记 --rebase--merge,git 会将子模块更新为服务器上的状态。并且会将项目重置为一个游离的 HEAD 状态。不过没关系,我们只要回到目录、切到某分支、再手动 rebase 或 merge 到 origin/master 即可。

local 和 remote 常规冲突

进入子模块目录中然后就像平时那样修复冲突即可。

local 和 remote 非常规冲突

这里我其实没看懂。参考 pro git book 这一章的的“合并子模块改动”部分。

当想要删除子模块

分两种情况,一是清理掉子模块的工作区(将它从.git/config 删去),但保留以后恢复它的可能性(将它保留在 .gitmodules.git/modules 中);二是彻底从当前分支删去。

暂时删去

对于前一种情况,如下:

1
2
3
$ git submodule deinit DbConnector
Cleared directory 'DbConnector'
Submodule 'DbConnector' (https://github.com/chaconinc/DbConnector) unregistered for path 'DbConnector'

它对外层项目完全没有影响。由于 .git/config 中没有保存它,本地不再存有它的信息、它被从工作区删去、目录还在但是内容已空。后续的 subcommand (如 update、foreach、sync)都会忽略这个子模块直到它重新被 init。

不过它还可以重新被恢复,如下:

1
2
3
$ git submodule update --init
Submodule 'DbConnector' (https://github.com/chaconinc/DbConnector) registered for path 'DbConnector'
Submodule path 'DbConnector': checked out '12e3a529698c519b2fab790630f71bd531c45727'

永久删去

对于后一种情况,如下:

1
2
3
4
5
6
7
8
9
10
11
12
$ git rm DbConnector
rm 'DbConnector'

$ git status
...
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
modified: .gitmodules # .gitmodules 被更新
deleted: DbConnector # 子模块目录整个被删除
fatal: Not a git repository: 'DbConnector/.git' # 子模块 status 出错因为已无 .git
Submodule changes to be committed:
* DbConnector 12e3a52...0000000:

它不仅会将其从工作区删除,还会更新 .gitmodules 文件来删去其中对该子模块的引用。

不过本地的配置文件(.git/config)还会保存子模块信息,所以如果想彻底删除,最好按顺序执行这两个命令:

1
2
$ git submodule deinit path/to/module # 清除 local config
$ git rm path/to/module # 清除 working directory 和 .gitmodules

不管执行怎样的命令,子模块的信息都还会存在于 .git/modules 中,不过随时都可以去修改它。

使用技巧

遍历

使用 foreach 可以在每个子模块中运行任何命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 比如用来保存所有子模块的工作进度
$ git submodule foreach 'git stash'
Entering 'CryptoLibrary' # 子模块 1
No local changes to save
Entering 'DbConnector' # 子模块 2
Saved working directory and index state WIP on stable: 82d2ad3 Merge from origin/stable
HEAD is now at 82d2ad3 Merge from origin/stable

# 比如创建新分支,让所有子模块都切换过去
$ git submodule foreach 'git checkout -b featureA'
Entering 'CryptoLibrary' # 子模块 1
Switched to a new branch 'featureA'
Entering 'DbConnector' # 子模块 2
Switched to a new branch 'featureA'

别名

有些命令很长,或者有很多步骤,设置别名会方便一点。

1
2
3
$ git config alias.sdiff '!'"git diff && git submodule foreach 'git diff'"
$ git config alias.spush 'push --recurse-submodules=on-demand'
$ git config alias.supdate 'submodule update --remote --merge'

这样想要更新子模块时,可以使用 git supdate;想要检查子模块依赖后推送时,可以使用 git spush

切换分支

如果你创建一个新分支,在其中添加一个子模块,之后切换到没有该子模块的分支上时,你仍然会有一个还未跟踪的子模块目录。

这种情况,我们通常会在没有该子模块的分支移除那个目录。不过当我们再切回有该子模块的分支后,我们需要运行 git submodule update --init 来重新建立和填充,因为子模块的默认状态不会在切换分支时保留。

为了简化这个操作,git 提供了两种方法,可以让子模块处于正确的状态(也就是该有子模块的分支有子模块,该没有子模块的分支没有子模块)。

1
2
3
4
5
# 方法一,checkout 时使用 --recurse-submodules 选项
$ git checkout --recurse-submodules

# 方法二,告诉 git 总是使用 --recurse-submodules 选项
$ git config submodule.recurse true

TL;DR

Configuration settings

  • status.submoduleSummary=true:在内部更新时可以在外部得到内部细节的 status
  • diff.submodule=log:在内部更新时可以在外部得到内部细节的 diff
  • fetch.recurseSubmodules=on-demand:在外部更新时可以抓取到内部更新

Adding or cloning

  • 初始 add:git submodule add <url> <path>
  • 初始 container clone:git clone --recursive <url> <path>

Grabbing updates inside a submodule

1
2
3
4
5
$ cd path/to/module
$ git fetch
$ git checkout -q <commit-sha1>
$ cd ..
$ git commit -am "Updated submodule X to: xxx"

Grabbing container updates

1
2
3
$ git pull
$ git submodule sync --recursive
$ git submodule update --init --recursive

Updating a submodule inside container code

1
2
3
4
5
6
7
$ git submodule update --remote --rebase -- path/to/module
$ cd path/to/module
# local work, testing, eventually staging
$ git commit -am "Update to central submodule: xxx"
$ git push
$ cd ..
$ git commit -am "Updated submodule X to:xxx"

Permanently removing a submodule

1
2
3
$ git submodule deinit path/to/module
$ git rm path/to/module
$ git commit -am "Removed submodule X"