上一篇:Git详解(二) 远程操作
裸库
裸库即没有工作区的 Git 仓库,一般用于服务器上。创建裸库很简单,只需要在初始化 Git 仓库时加上 --bare 参数即可:
git init --bare
创建裸库 barerepo,查看该目录下的文件:
可以发现裸库中没有 .git 目录,相对的,原本应该存放在 .git 目录下的文件则直接出现在 Git 仓库中。因此我们一般会将裸库命名为仓库名加上 .git 的形式,说到这里你可能会有似曾相识的感觉。
上一篇文章中在本地添加 github 远程仓库,使用到的远程仓库 HTTPS 地址和 SSH 地址如下:
HTTPS 地址:https://github.com/BWHN/test.git
SSH 地址:git@github.com:BWHN/test.git
远程仓库的命名正是使用 .git 结尾,所以我们在 github 上创建的远程仓库是裸库。
不过光靠一个名字可能无法说服你,因为将 github 远程仓库克隆到本地之后,本地仓库中是有 .git 目录的。口说无凭,眼见为实。下面我们克隆 barerepo 裸库,即在本地克隆本地仓库:
从上图可以看到,克隆裸库,裸库目录下的文件会被放到克隆仓库中的 .git 目录下。
所以 github 上创建的仓库确实是一个裸库,这也就是为什么我在上一篇文章中描述在创建远程仓库时使用“相当于”这个词:
这一步操作相当于在本地创建 testgit 文件夹,然后在文件夹中执行 git init 指令生成 Git 仓库。
需要注意的是,由于裸库中没有工作区,所以我们无法在裸库中直接提交变更,status 之类的指令也无法使用:
submodule
在开发中我们常常会遇到这种情况:某个项目作为共通被多个其他项目使用。假设共通项目和其他项目都是独立的项目,应该如何管理这些项目呢?我能想到两种做法(为了便于描述,下面将共通项目称为子项目,其他项目称为父项目)。
一、将子项目打成 jar 包供父项目使用。然而如果子项目不稳定,需要频繁更新,那么子项目每次更新都需要重新打包,再上传到服务器供父项目使用。
二、直接将子项目复制到父项目中使用。但是这种做法无法得知子项目的更新内容,而且你也不知道子项目的下一次更新时间。
针对这个问题,Git 给出的解决方案是子模块,下面演示子模块的用法。
首先在 github 上创建两个仓库:parent 和 child,然后分别向两个仓库进行推送:
添加子模块
相信通过前两篇文章的学习,克隆仓库、提交更新、分支推送这些操作你应该已经非常熟练了。下面我们开始学习第一个子模块相关的指令:
git submodule add <url>
该指令可以为本地仓库添加子模块。默认情况下,子模块会将子项目放到一个与子项目同名的目录中。当然你也可以在指令的后面加上目录名指定子项目存放的目录: git submodule add <url> <dir> 。
在 parent 仓库中添加子模块:child 项目,查看 Git 工作目录下的文件变化:
从截图中我们可以看到 “Cloning into ‘child_submodule’” 字样,这表示添加子模块的本质上进行的是克隆操作,将远程 child 仓库中的内容克隆到本地 parent 仓库中。但是事实真的这么简单吗?接着查看 Git 工作目录下的文件变化 —— 出现 child_submodule 目录和 .gitmodules 文件。而且如果你观察仔细的话,可以发现新增的目录和文件已经被提交到了暂存区。
查看 child_submodule 目录下的文件:
意料之外的是,child_submodule 目录中没有 .git 隐藏目录,取而代之的是 .git 文件。查看 .git 文件可以看到输出结果是一个路径,继续查看给出该路径下文件:
可以看到该路径下的文件正是原本应该存在于 .git 目录中的文件,因此子模块其实是一个完整的仓库。
比较 child_submodule 目录在版本库和暂存区的不同:
diff 操作的输出内容并不是我们想象中的结果。输出结果为 “Submodule <project_name> <SHA-1>...<SHA-1> (new submodule)” 形式,其含义为子模块在本地仓库的历史版本的变化信息,由于该子模块是第一次添加,所以标注这是一个新的子模块。
这就表示子模块是一个完整的仓库,Git 并没有将子模块当做一个普通目录对待,不跟踪它的内容, 而是跟踪它的提交记录变化信息。
因此当我们进入 child_submodule 目录后,面对的就不再是 parent 仓库而是 child 仓库。在该目录下执行 git log 指令,就可以查看 child 仓库的提交记录:
说到这里,我似乎漏了一个文件没有介绍:.gitmodules。该文件是首次添加子模块后 Git 自动创建的,查看该文件的内容:
该文件记录子项目的 URL 和本地目录之间的映射关系。如果仓库中存在多个子模块,该文件中就会有多条记录。
需要注意的是,该文件和 .gitignore 文件一样受到版本控制。它会作为父项目一部分被拉取和推送,因为这是克隆该项目的人知道去哪获得子模块的依据。
将暂存区的文件提交到版本库:
注意上图中 child_submodule 目录的创建模式为 160000。这是 Git 中的一种特殊模式,它意味着你是将一次提交记作一项目录(a directory,我也不知道该怎么翻译 ╮(╯▽╰)╭)记录的,而非将它记录成一个子目录或者一个文件。
推送完成后,在 github 上查看推送结果:
从图中可以看到,child_submodule 目录后面跟着一个 @ 加上 SHA-1 值表示对 child 项目的引用,点击之后会直接跳转到 child 项目对应的那次提交。
克隆含有子模块的项目
如果克隆一个包含子模块的项目时,虽然克隆仓库中会存在子模块的目录,但是子模块的目录是空的。克隆 parent 项目:
在上一小节“添加子模块”中,我们知道子模块的相关的数据存放在 .git/modules 目录下。查看 .git 目录,可以看到该目录中尚没有 modules 目录,所以此时 parentclone 仓库并不包含子模块相关的数据。同时 .git/config 文件中也没有子模块的记录:
此时我们需要执行两个指令:
git submodule init
git submodule update
前者负责初始化本地的配置文件,后者负责将子项目中对应的提交抓取到父项目的子模块目录中。
执行 git submodule init 指令后,我们可以看到 .git/config 文件中出现子模块相关的记录:
继续执行 git submodule update 指令:
从输出的结果来看,我们不难发现该指令本质上是将子项目克隆到本地仓库,然后在本地仓库进行 checkout 操作。此时子模块就是父项目提交时的状态了。
其实像 git submodule init 和 git submodule update 两个指令,我们经常会一起执行。可以将它们合并成一步:
git submodule update --init
还有一种更简单的方法抓取子模块提交,就是在克隆仓库的时候加上 --recurse-submodules 参数:
git clone --recurse-submodules <url>
加上该参数后,我们在克隆父项目时,就会自动初始化并更新仓库中的每一个子模块,包括可能存在的嵌套子模块。
说到嵌套子模块,如果父项目中存在嵌套子模块,而我们在克隆父项目时没有使用 --recurse-submodules 参数该怎么办呢?如果你已经将父项目克隆到本地,那么此时你依然可以使用下面的指令抓取嵌套子模块的提交:
git submodule update --init --recursive
更新子模块
由于子模块是一个独立的项目,如果子项目有更新,在本地父项目仓库中执行 git fetch 指令并不会抓取子项目的更新。想要获取子项目的更新,需要进入到子模块目录中手动抓取:
此时我们回到父项目,执行 git diff 指令,就可以看到子模块的变化信息:
正如我在“添加子模块”小节所说的,Git 将 child_submodule 目录作为提交看待,而非一个单纯的子目录或是文件。
若你在进行 diff 操作时加上 --submodule 参数,就可以看到更为直观的输出结果:
当然,如果你觉得执行 diff 操作时输入 --submodule 一件很繁琐的事,我们可以将该参数设置为 diff 的默认行为:
git config --global diff.submodule log
如果你不想进入子模块仓库中手动抓取更新,那么还有一种更简单的方式:
git submodule update --remote <submodule>
执行该指令时如果省略子模块名,Git 默认会尝试更新所有的子模块:
需要注意的是,该命令默认更新并检出子模块仓库的 master 分支。如果你还想抓取子项目其他分支的更新,可以在 .git/config 文件或者 .gitmodules 文件中配置。二者的不同在于,配置在 .git/config 文件中只会在本地生效,而配置在 .gitmodules 文件中则会影响团队中其他抓取该文件更新的人。
git config submodule.child_submodule.branch newb // 在 .git/config 中配置
git config -f .gitmodules submodule.child_submodule.branch newb // 在 .gitmodules 中配置
在 .gitmodules 文件中修改默认更新并检出的分支:
当我们执行 git submodule update --remote <submodule> 指令抓取子模块更新之后,进入子模块目录查看当前所在分支,你会发现你当前所在的分支是一个游离的分支:
这就说明该指令不会帮我们自动将更新合并到相应的分支上,合并操作需要我们手动进行。此时将在子模块仓库中切换到我们需要分支,再进行合并即可:
或者你可以在抓取子模块更新之前,将子模块仓库所在分支切换到需要更新的分支,然后在执行 git submodule update --remote <submodule> 指令时加上 --merge 参数,就可以在抓取更新的同时将更新合并到对应的分支:
修改子模块
有时出于父项目的需要我们会修改子模块,那么此时需要将子模块和父项目都推送到远程仓库才能保证代码的正确性。但是我们知道子模块是一个独立的项目,在父项目仓库中执行 git push 指令并不会推送子模块的更新,想要推送子模块的的更新需要进入子模块仓库中手动进行。
然而有时候我们就是会忘记推送子模块的修改,这对本地是没有影响的,因为子模块的修改全部都在本地仓库中放着,但是团队中其他成员却无法获取子模块的修改。
因此你可以在推送父项目时加上 --recurse-submodules=check 参数,这会让 Git 在推送到父项目前检查所有子模块是否已推送:
如上图所示,推送时使用该参数,如果子模块存在新的提交记录而没有推送,就会直接导致父项目的推送失败。此时你需要进入没有推送的子模块仓库中,手动将子模块的更新推送到远程仓库,然后推送父项目。
当然你也可以让 Git 替你做这件事。在推送父项目时加上 --recurse-submodules=on-demand 参数,如果存在有新的提交记录而没有推送的子模块,Git 尝试会帮你将子模块推送到远程仓库,然后在推送父项目。如果子模块推送失败,父项目也会推送失败:
遍历子模块
设想一下,父项目中如果包含大量的子模块,如果需要对所有的子模块进行相同的操作,那一定是一件耗时耗力的操作。好在有一个子模块命令,可以让我们遍历所有的子模块:foreach。
例如,保存所有子模块的进度:
删除子模块
子模块的删除比较繁琐,大体上分为 4 个步骤:
- 删除子模块目录:git rm <submodule>
- 删除子模块的本地配置文件:rm -rf .git/modules/<submodule>
- 删除 .gitmodules 文件中子模块的配置信息
- 删除 .git/config 文件中的配置信息
上面四步完成之后,将父项目推送到远程仓库即可。
subtree
在 submodule 小节开头我提到,管理父项目和子项目有两种方法:第一,将子项目打成 jar 包供父项目使用,子模块本质上采用的就是这种做法。第二,直接将子项目整个复制到父项目中使用,而这正是子树解决该问题所采用的方法。
值得一提的是,subtree 并不是 Git 官方开发的,而是 apenwarr 在 GitHub 上开源的一个项目。
同样的,为了演示子树的用法,在 github 上新建两个仓库:subtree_parent 和 subtree_child,分别向两个仓库进行一次推送:
添加子树
首先为 subtree_parent 仓库添加子项目的远程仓库 subtree_child:
此时本地仓库 subtree_parent 记录了两个远程仓库,一个是本地仓库 subtree_parent 对应的远程仓库 subtree_parent,另一个则是子项目的远程仓库 subtree_child。
为本地仓库添加子树:
git subtree add --prefix=<subtree> <shortname> <remote_branch>
执行该命令后,Git 会将子项目远程仓库中指定分支的提交抓取到本地仓库的子树目录中:
查看本地仓库中的文件信息,可以看到本地仓库中出现子树目录,而子树目录下正是子项目远程仓库中指定分支提交的文件:
但是如果此时查看 Git 工作目录下的文件状态信息,你会惊讶的发现 Git 工作目录是干净的:
取而代之的是,本地仓库中新增两条提交记录。然而至此为止,我们并没有进行过任何提交操作,这两条新增的提交记录是哪儿来的呢?查看本地仓库的提交日志:
从提交日志中我们看到,一条提交记录来自子项目,而另一条提交记录则由本地仓库的提交记录和子项目提交记录合并后生成。看到这里我们就大概能够明白,子树管理子项目的方式就类似于分支合并。
此时在 subtree_parent 仓库中执行 git push 指令,就可以将 subtree_child 目录作为父项目的一部分推送到远程仓库。而这就是 subtree 和 submodule 根本上的不同之处:
左图为使用子树管理子项目,子项目作为父项目的一部分“嵌入其中”;右图为使用子模块管理子项目,子项目和父项目是两个完全独立的项目。
更新子树
使用下面的指令就可以将子项目远程仓库中指定分支的更新抓取到子树目录中:
git subtree pull --prefix=<subtree> <shortname> <remote_branch>
如果你觉得 --prefix 参数太长的话,可以用 -P 代替:
和添加子树的指令一样,执行该命令也会自动将子项目的提交记录合并到父项目中:
这样一来就会带来一个问题:如果子项目的提交记录比较多,那么抓取子项目的更新时,必然会给父项目带来很多不必要的提交记录。例如这样:
为了避免这个问题,我们可以在抓取子项目更新时加上 --squash 参数,使用该参数可以将多条提交记录压缩成一条提交记录。但是另一方面,不恰当的使用 --squash 参数也会出现各种各样的问题。
--squash 参数带来的与失去的
抓取压缩后的子项目更新:
从上图中可以看到,subtree/test.txt 文件出现了冲突,但是到现在为止,我们并没有在父项目中修改过该文件,只是在抓取子项目更新时加上 --squash 参数,为什么会出现冲突呢?查看产生冲突的文件:
解决冲突之后,查看提交日志:
可以看到 subtree_parent 仓库中新增两条提交记录,一条是解决冲突的提交记录,另一条则是压缩子项目提交记录而产生的提交记录。
借助于 gitk(系列第二篇文章中有介绍) 查看提交历史关系图:
执行 git subtree pull 指令时,Git 想要将提交记录 1 合并到提交记录 2 上。由于 Git 采用三方合并策略进行合并,此时需要找到提交记录 1 和提交记录 2 的共同祖先提交,也就是提交记录 3。但是提交记录 2 的祖先提交不止一个,提交记录 4 也是提交记录 2 的祖先提交之一。
此时 Git 会去找提交记录 3 和 4 的共同祖先提交,很明显它们没有共同的祖先提交,冲突由此产生。
再次抓取子项目的更新,不使用 --squash 参数,冲突会再一次出现:
解决冲突之后,查看提交历史关系图:
出现冲突的原因和上一次一样:进行递归三方合并时,找不到两个提交记录的共同祖先提交(这里我就不再分析具体过程了,你可以试着自行分析)。
如果下一次抓取子项目更新时,又使用 --squash 参数,那么还是会出现冲突。所以使用 --squash 参数最忌讳的就是有时用 --squash 参数,有时不用 --squash 参数。如果有时用有时不用,得到的不是两者的优点,而是两者的缺点之和。
推送子树修改
执行下面的指令,就可以将子树目录下的更新推送到子项目的远程仓库:
git subtree push --prefix=<subtree> <shortname> <remote_branch>
修改子树目录下文件:
先将提交记录推送到子项目远程仓库。推送完成后,我们就可以在子项目远程仓库上看到推送结果:
再将本地仓库中所作的修改推送到父项目远程仓库,查看推送结果:
子项目远程仓库上推送的提交记录的 SHA-1 值是 5de7c77,父项目远程仓库上的推送提交记录的 SHA-1 值是 26cdac9,也就是说同一份本地仓库的提交记录分别推送到两个远程仓库上,得到的却是两个不同的提交记录。为什么会这样呢?
首先我们要弄清楚 git subtree push 指令为什么可以推送子树目录下的修改记录,这是因为该指令可以将子树目录下所做的修改从父项目“分割”出来,而“分割”出来的子树目录下的修改是重新生成的提交记录。
这么说可能有些难以理解,再举一个例子。同时修改子树目录下的文件和非子树目录下的文件:
将提交记录分别推送到父项目和子项目的远程仓库,查看推送结果:
可以看到推送到子项目远程仓库的提交记录,虽然提交说明和推送到父项目远程仓库的提交记录的提交说明一样,但是提交记录的文件信息只包含子树目录下的文件信息。这就是上文所说的“分割”的含义。
子树分割
有时我们会遇到这样的情况:项目中的某个功能模块需要独立出来作为共通项目,让其他项目也可以使用。一般碰到这种情况,我们的做法是将该功能模块直接拷贝出来。这样做是存在弊端的,例如:共通项目的历史记录丢失。
子树给我们提供了一个另一种做法:子树分割,该指令如下:
git subtree split --prefix=<dir> -b <temp_branch>
执行该指令 Git 会将仓库目录下指定目录内文件的提交记录拷贝到一条指定分支上,注意这里的拷贝并不是真正意义上的拷贝,拷贝到指定分支上的提交记录的 SHA-1 值是重新生成的,这一点和推送子树修改小节提到的分割是一样的。
在本地仓库中新增 Dbconnection 目录,对该目录下的文件进行修改、提交:
分割 Dbconnection 模块:
切换到 dbmodule 分支查看提交历史:
可以看到 dbmodule 分支上提交记录的提交说明虽然和 master 分支上一样,但是 SHA-1 值是不一样的。
此时我们可以创建一个远程仓库,然后将该分支推送到远程仓库:
subtree 与 submodule 的选择
虽然 subtree 是 submodule 的改进方案,但是并不是说 subtree 就优于 submodule。
整体上来说,submodule 上手不如 subtree 简单,但是 subtree 需要比 submodule 占用更大的空间。
总之还是那句话,没有最好的,只有最合适的。
下一篇:Git详解(四) 分支整合
参考: