4.4. 子模组协同模型

项目的版本库某些情况下需要引用其他版本库中的文件,例如公司积累了一套常用的函数库,被多个项目调用,显然这个函数库的代码不能直接放到某个项目的代码中,而是要独立为一个代码库,那么其他项目要调用公共的函数库,该如何处理呢?分别把公共函数库的文件拷贝到各自的项目中,会造成冗余,丢弃了公共函数库的维护历史,显然不是好的方法。本节要讨论的子模组协同模型,就是解决这个问题的一个方案。

熟悉Subversion的用户马上会想起svn:externals属性,可以实现对外部代码库的引用。Git的子模组(submodule)是类似的一种实现。不过因为Git的特殊性,二者的区别还是满大的。参见表23-1。

表23-1:SVN和Git相似功能对照表

  svn:externals git submodule
如何记录外部版本库地址? 目录的svn:externals属性 项目根目录下的.gitmodules文件
缺省是否自动检出外部版本库? 是。在使用svn checkout检出时若使用参数--ignore-externals可以忽略对外部版本库引用不检出。 否。缺省不克隆外部版本库。 克隆要用git submodule initgit submodule update命令。
是否能部分引用外部版本库内容? 是。因为SVN支持部分检出。 否。必须克隆整个外部版本库。
是否可以指向分支而随之改变? 是。 否。固定于外部版本库某个提交。

4.4.1. 创建子模组

在演示子模组的创建和使用之前,先作些准备工作。先尝试建立两个公共函数库(libA.gitlibB.git)以及一个引用函数库的主版本库(super.git)。

$ git --git-dir=/path/to/repos/libA.git init --bare
$ git --git-dir=/path/to/repos/libB.git init --bare
$ git --git-dir=/path/to/repos/super.git init --bare

向两个公共的函数库中填充些数据。这就需要在工作区克隆两个函数库,提交数据,并推送。

  • 克隆libA.git版本库,添加一些数据,然后提交并推送。

    说明:示例中显示为hack...的地方,做一些改动(如创建新文件等),并将改动添加到暂存区。

    $ git clone /path/to/repos/libA.git /path/to/my/workspace/libA
    $ cd /path/to/my/workspace/libA
    hack ...
    $ git commit -m "add data for libA"
    $ git push origin master
    
  • 克隆libB.git版本库,添加一些数据,然后提交并推送。

    $ git clone /path/to/repos/libB.git /path/to/my/workspace/libB
    $ cd /path/to/my/workspace/libB
    hack ...
    $ git commit -m "add data for libB"
    $ git push origin master
    

版本库super是准备在其中创建子模组的。super版本库刚刚初始化还未包含提交,master分支尚未有正确的引用。需要在super版本中至少创建一个提交。下面就克隆super版本库,在其中完成一个提交(空提交即可),并推送。

$ git clone /path/to/repos/super.git /path/to/my/workspace/super
$ cd /path/to/my/workspace/super
$ git commit --allow-empty -m "initialized."
$ git push origin master

现在就可以在super版本库中使用git submodule add命令添加子模组了。

$ git submodule add /path/to/repos/libA.git lib/lib_a
$ git submodule add /path/to/repos/libB.git lib/lib_b

至此看一下super版本库工作区的目录结构。在根目录下多了一个.gitmodules文件,并且两个函数库分别克隆到lib/lib_a目录和lib/lib_b目录下。

$ ls -aF
./  ../  .git/  .gitmodules  lib/

看看.gitmodules的内容:

$ cat .gitmodules
[submodule "lib/lib_a"]
        path = lib/lib_a
        url = /path/to/repos/libA.git
[submodule "lib/lib_b"]
        path = lib/lib_b
        url = /path/to/repos/libB.git

此时super的工作区尚未提交。

$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#       new file:   .gitmodules
#       new file:   lib/lib_a
#       new file:   lib/lib_b
#

完成提交之后,子模组才算正式在super版本库中创立。运行git push把建立了新模组的本地库推送到远程的版本库。

$ git commit -m "add modules in lib/lib_a and lib/lib_b."
$ git push

在提交过程中,发现作为子模组方式添加的版本库实际上并没有添加版本库的内容。实际上只是以gitlink方式[1]添加了一个链接。至于子模组的实际地址,是由文件.gitmodules中指定的。

可以通过查看补丁的方式,看到lib/lib_alib/lib_b子模组的存在方式(即gitlink)。

$ git show HEAD

commit 19bb54239dd7c11151e0dcb8b9389e146f055ba9
Author: Jiang Xin <jiangxin@ossxp.com>
Date:   Fri Oct 29 10:16:59 2010 +0800

    add modules in lib/lib_a and lib/lib_b.

diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..60c7d1f
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "lib/lib_a"]
+       path = lib/lib_a
+       url = /path/to/repos/libA.git
+[submodule "lib/lib_b"]
+       path = lib/lib_b
+       url = /path/to/repos/libB.git
diff --git a/lib/lib_a b/lib/lib_a
new file mode 160000
index 0000000..126b181
--- /dev/null
+++ b/lib/lib_a
@@ -0,0 +1 @@
+Subproject commit 126b18153583d9bee4562f9af6b9706d2e104016
diff --git a/lib/lib_b b/lib/lib_b
new file mode 160000
index 0000000..3b52a71
--- /dev/null
+++ b/lib/lib_b
@@ -0,0 +1 @@
+Subproject commit 3b52a710068edc070e3a386a6efcbdf28bf1bed5

4.4.2. 克隆带子模组的版本库

之前的表23-1在对比Subversion的svn:externals子模组实现差异时,提到过克隆带子模组的Git库,并不能自动将子模组的版本库克隆出来。对于只关心项目本身数据,对项目引用的外部项目数据并不关心的用户,这个功能非常好,数据也没有冗余而且克隆的速度也更块。

下面在另外的位置克隆super版本库,会发现lib/lib_alib/lib_b并未克隆。

$ git clone /path/to/repos/super.git /path/to/my/workspace/super-clone
$ cd /path/to/my/workspace/super-clone
$ ls -aF
./  ../  .git/  .gitmodules  lib/
$ find lib
lib
lib/lib_a
lib/lib_b

这时如果运行git submodule status可以查看到子模组状态。

$ git submodule status
-126b18153583d9bee4562f9af6b9706d2e104016 lib/lib_a
-3b52a710068edc070e3a386a6efcbdf28bf1bed5 lib/lib_b

看到每个子模组的目录前面是40位的提交ID,在最前面是一个减号。减号的含义是该子模组尚为检出。

如果需要克隆出子模组型式引用的外部库,首先需要先执行git submodule init

$ git submodule init
Submodule 'lib/lib_a' (/path/to/repos/libA.git) registered for path 'lib/lib_a'
Submodule 'lib/lib_b' (/path/to/repos/libB.git) registered for path 'lib/lib_b'

执行git submodule init实际上修改了.git/config文件,对子模组进行了注册。文件.git/config的修改示例如下(以加号开始的行代表新增的行)。

 [core]
         repositoryformatversion = 0
         filemode = true
         bare = false
         logallrefupdates = true
 [remote "origin"]
         fetch = +refs/heads/*:refs/remotes/origin/*
         url = /path/to/repos/super.git
 [branch "master"]
         remote = origin
         merge = refs/heads/master
+[submodule "lib/lib_a"]
+       url = /path/to/repos/libA.git
+[submodule "lib/lib_b"]
+       url = /path/to/repos/libB.git

然后执行git submodule update才完成子模组版本库的克隆。

$ git submodule update
Initialized empty Git repository in /path/to/my/workspace/super-clone/lib/lib_a/.git/
Submodule path 'lib/lib_a': checked out '126b18153583d9bee4562f9af6b9706d2e104016'
Initialized empty Git repository in /path/to/my/workspace/super-clone/lib/lib_b/.git/
Submodule path 'lib/lib_b': checked out '3b52a710068edc070e3a386a6efcbdf28bf1bed5'

4.4.3. 在子模组中修改和子模组的更新

执行git submodule update更新出来的子模组,都以某个具体的提交版本进行检出。进入某个子模组目录,会发现其处于非跟踪状态(分离头指针状态)。

$ cd /path/to/my/workspace/super-clone/lib/lib_a
$ git branch
* (no branch)
  master

显然这种情况下,如果修改lib/lib_a下的文件,提交会丢失。下面介绍一下如何在检出的子模组中修改,以及更新子模组。

在子模组中切换到master分支(或其他想要修改的分支)后,再进行修改。

  • 切换到master分支,然后在工作区做一些改动。

    $ cd /path/to/my/workspace/super-clone/lib/lib_a
    $ git checkout master
    hack ...
    
  • 执行提交。

    $ git commit
    
  • 查看状态,会看到相对于远程分支领先一个提交。

    $ git status
    # On branch master
    # Your branch is ahead of 'origin/master' by 1 commit.
    #
    nothing to commit (working directory clean)
    

git status的状态输出中,可以看出新提交尚未推送到远程版本库。现在暂时不推送,看看在super版本库中执行git submodule update对子模组的影响。

  • 先到super-clone版本库查看一下状态,可以看到子模组已修改,包含更新的提交。

    $ cd /path/to/my/workspace/super-clone/
    $ git status
    # On branch master
    # Changed but not updated:
    #   (use "git add <file>..." to update what will be committed)
    #   (use "git checkout -- <file>..." to discard changes in working directory)
    #
    #       modified:   lib/lib_a (new commits)
    #
    no changes added to commit (use "git add" and/or "git commit -a")
    
  • 通过git submodule stauts命令可以看出lib/lib_a子模组指向了新的提交ID(前面有一个加号),而lib/lib_b模组状态正常(提交ID前是一个空格,不是加号也不是减号)。

    $ git submodule status
    +5dea2693e5574a6e3b3a59c6b0c68cb08b2c07e9 lib/lib_a (heads/master)
     3b52a710068edc070e3a386a6efcbdf28bf1bed5 lib/lib_b (heads/master)
    
  • 这时如果不小心执行了一次git submodule update命令,会将lib/lib_a重新切换到旧的指向。

    $ git submodule update
    Submodule path 'lib/lib_a': checked out '126b18153583d9bee4562f9af6b9706d2e104016'
    
  • 执行git submodule status命令查看子模组状态,看到lib/lib_a子模组被重置了。

    $ git submodule status
     126b18153583d9bee4562f9af6b9706d2e104016 lib/lib_a (remotes/origin/HEAD)
     3b52a710068edc070e3a386a6efcbdf28bf1bed5 lib/lib_b (heads/master)
    

那么刚才在lib/lib_a中的提交丢失了么?实际上因为已经提交到了master主线,因此提交没有丢失,但是如果有数据没有提交,就会造成未提交数据的丢失。

  • 进到lib/lib_a目录,看到工作区再一次进入分离头指针状态。

    $ cd lib/lib_a
    $ git branch
    * (no branch)
      master
    
  • 重新检出master分支找回之前的提交。

    $ git checkout master
    Previous HEAD position was 126b181... add data for libA
    Switched to branch 'master'
    Your branch is ahead of 'origin/master' by 1 commit.
    

现在如果要将lib/lib_a目录下子模组的改动记录到父项目(super版本库)中,就需要在父项目中进行一次提交才能实现。

  • 进入父项目根目录,查看状态。因为lib/lib_a的提交已经恢复,因此再次显示为有改动。

    $ cd /path/to/my/workspace/super-clone/
    $ git status -s
     M lib/lib_a
    
  • 查看差异比较,会看到指向子模组的gitlink有改动。

    $ git diff
    diff --git a/lib/lib_a b/lib/lib_a
    index 126b181..5dea269 160000
    --- a/lib/lib_a
    +++ b/lib/lib_a
    @@ -1 +1 @@
    -Subproject commit 126b18153583d9bee4562f9af6b9706d2e104016
    +Subproject commit 5dea2693e5574a6e3b3a59c6b0c68cb08b2c07e9
    
  • 将gitlink的改动添加到暂存区,然后提交。

    $ git add -u
    $ git commit -m "submodule lib/lib_a upgrade to new version."
    

此时先不要忙着推送,因为如果此时执行git pushsuper版本库推送到远程版本库,会引发一个问题。即推送后的远程super版本库的子模组lib/lib_a指向了一个新的提交,而该提交还在本地的lib/lib_a版本库(尚未向上游推送),这会导致其他人克隆super版本库、更新模组时因为找不到该子模组版本库相应的提交而导致出错。下面就是这类错误的错误信息:

fatal: reference is not a tree: 5dea2693e5574a6e3b3a59c6b0c68cb08b2c07e9
Unable to checkout '5dea2693e5574a6e3b3a59c6b0c68cb08b2c07e9' in submodule path 'lib/lib_a'

为了避免这种可能性的发生,最好先对lib/lib_a中的新提交进行推送,然后再对super的子模组更新的提交进行推送。即:

  • 先推送子模组。

    $ cd /path/to/my/workspace/super-clone/lib/lib_a
    $ git push
    
  • 再推送父版本库。

    $ cd /path/to/my/workspace/super-clone/
    $ git push
    

4.4.4. 隐性子模组

我在开发备份工具Gistore时遇到一个棘手的问题就是隐性子模组的问题。Gistore备份工具的原理是将要备份的目录都挂载(mount)在工作区中,然后执行git add。但是如果有某个目录已经被Git化了,就只会以子模组方式将该目录添加进来,而不会添加该目录下的文件。对于一个备份工具来说,意味着备份没有成功。

例如当前super版本库下有两个子模组:

$ cd /path/to/my/workspace/super-clone/
$ git submodule status
 126b18153583d9bee4562f9af6b9706d2e104016 lib/lib_a (remotes/origin/HEAD)
 3b52a710068edc070e3a386a6efcbdf28bf1bed5 lib/lib_b (heads/master)

然后创建一个新目录others,并把该目录用git初始化并做一次空的提交。

$ mkdir others
$ cd others
$ git init
$ git commit --allow-empty -m initial
[master (root-commit) 90364e1] initial

others目录下创建一个文件newfile

$ date > newfile

回到上一级目录,执行git status,看到有一个others目录没有加入版本库控制,这很自然。

$ cd ..
$ git status
# On branch master
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#       others/
nothing added to commit but untracked files present (use "git add" to track)

但是如果对others目录执行git add后,会发现奇怪的状态。

$ git add others
$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#       new file:   others
#
# Changed but not updated:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
#   (commit or discard the untracked or modified content in submodules)
#
#       modified:   others (untracked content)
#

看看others目录的添加方式,就会发现others目录以gitlink方式添加到版本库中,而没有把该目录下的文件添加到版本库。

$ git diff --cached
diff --git a/others b/others
new file mode 160000
index 0000000..90364e1
--- /dev/null
+++ b/others
@@ -0,0 +1 @@
+Subproject commit 90364e1331abc29cc63e994b4d2cfbf7c485e4ad

之所以git status的显示中others出现两次,就是因为目录others被当做子模组添加到父版本库中。因为others版本库本身“不干净”,存在尚未加入版本控制的文件,所以又在状态输出中显示子模组包含改动的提示信息。

执行提交,将others目录提交到版本库中。

$ git commit -m "add others as submodule."

执行git submoudle status命令,会报错。因为others作为子模组,没有在.gitmodules文件中注册。

$ git submodule status
 126b18153583d9bee4562f9af6b9706d2e104016 lib/lib_a (remotes/origin/HEAD)
 3b52a710068edc070e3a386a6efcbdf28bf1bed5 lib/lib_b (heads/master)
No submodule mapping found in .gitmodules for path 'others'

那么如何在不破坏others版本库的前提下,把others目录下的文件加入版本库呢?即避免others以子模组形式添加入库。

  • 先删除以gitlink形式入库的others子模组。

    $ git rm --cached others
    rm 'others'
    
  • 查看当前状态。

    $ git status
    # On branch master
    # Changes to be committed:
    #   (use "git reset HEAD <file>..." to unstage)
    #
    #       deleted:    others
    #
    # Untracked files:
    #   (use "git add <file>..." to include in what will be committed)
    #
    #       others/
    
  • 重新添加others目录,注意目录后面的斜线(即路径分隔符)非常重要。

    $ git add others/
    
  • 再次查看状态,看到others下的文件被添加到super版本库中。

    $ git status
    # On branch master
    # Changes to be committed:
    #   (use "git reset HEAD <file>..." to unstage)
    #
    #       deleted:    others
    #       new file:   others/newfile
    #
    
  • 执行提交。

    $ git commit -m "add contents in others/."
    [master 1e0c418] add contents in others/.
     2 files changed, 1 insertions(+), 1 deletions(-)
     delete mode 160000 others
     create mode 100644 others/newfile
    

在上面的操作过程中,首先先删除了在库中的others子模组(使用--cached参数执行删除);然后为了添加others目录下的文件,使用了others/(注意others后面的路径分割符“/”)。现在查看一下子模组的状态,会看到只有之前的两个子模组显示出来。

$ git submodule status
 126b18153583d9bee4562f9af6b9706d2e104016 lib/lib_a (remotes/origin/HEAD)
 3b52a710068edc070e3a386a6efcbdf28bf1bed5 lib/lib_b (heads/master)

4.4.5. 子模组的管理问题

子模组最主要的一个问题是不能基于外部版本库的某一个分支进行创建,使得更新后,子模组处于非跟踪状态,不便于在子模组中进行对外部版本库进行改动。尤其对于授权或其他原因将一个版本库拆分为子模组后,使用和管理非常不方便。在第25章“Android式多版本库协同”可以看到管理多版本库的另外一个可行方案。

如果在局域网内维护的版本库所引用的子模组版本库在另外的服务器,甚至在互联网上,克隆子版本库就要浪费很多时间。而且如果子模组指向的版本库不在我们的掌控之内,一旦需要对其进行定制会因为提交无法向远程服务器推送而无法实现。在下一章即第24章“子树合并”中,会给出针对这个问题的解决方案。


[1]gitlink: