0%

图解 Git

Git是目前世界上最先进的分布式版本控制系统,本文是对 git 的原理与用法的总结笔记。

版本控制系统

版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。通过版本控制系统,你就可以将选定的文件回溯到之前的状态,甚至将整个项目都回退到过去某个时间点的状态;你可以比较文件的变化细节,查出最后是谁修改了哪个地方,从而找出导致怪异问题出现的原因,又是谁在何时报告了某个功能缺陷等等。 使用版本控制系统通常还意味着,就算你乱来一气把整个项目中的文件改的改删的删,你也照样可以轻松恢复到原先的样子,但额外增加的工作量却微乎其微。

本地版本控制系统

许多人习惯用复制整个项目目录的方式来保存不同的版本,或许还会改名加上备份时间以示区别。 这么做唯一的好处就是简单,但是特别容易犯错。 有时候会混淆所在的工作目录,一不小心会写错文件或者覆盖意想外的文件。

为了解决这个问题,人们很久以前就开发了许多种本地版本控制系统,大多都是采用某种简单的数据库来记录文件的历次更新差异。其中最流行的一种叫做 RCS,现今许多计算机系统上都还看得到它的踪影。 RCS 的工作原理是在硬盘上保存补丁集;通过应用所有的补丁,可以重新计算出各个版本的文件内容。

Local version control diagram

集中式版本控制系统

接下来人们又遇到一个问题,如何让在不同系统上的开发者协同工作?于是,集中化的版本控制系统(Centralized Version Control Systems,简称 CVCS)应运而生。 这类系统,诸如 CVS、SVN 以及 Perforce 等,都有一个单一的集中管理的服务器,保存所有文件的修订版本,而协同工作的人们都通过客户端连到这台服务器,取出最新的文件或者提交更新。 多年以来,这已成为版本控制系统的标准做法。

Centralized version control diagram

但这种模式有一个致命的缺点,就是中心的单点,如果服务器恰好坏了,那代码全部丢失,所以大公司一般都有很好的容灾机制。

分布式版本控制系统

随着开源运动的爆发,中心仓库模式很难适应全球多人写作的模式,于是乎分布式版本控制诞生了

DVCS结合了LVCS和CVCS两者的优点,本地仓库让一切都在本地,同时分布式的设计又让每一个节点都能成为远端

DVCS的缺点也是不容忽视的,本地仓库会导致首次clone变慢,其学习曲线优点陡峭(相对而言)

Distributed version control diagram

Git 工作模型

Git 工作模型

Snapshots, Not Differents

Git 和其他版本控制系统的主要差别在于,Git 只关心文件数据的整体是否发生变化,而大多数其他系统则只关心文件内容的具体差异。这类系统(CVS,Subversion,Perforce,Bazaar 等等)每次记录有哪些文件作了更新,以及都更新了哪些行的什么内容:

Conceptually, most other systems store information as a list of file-based changes.

Storing data as changes to a base version of each file.

Git 并不保存这些前后变化的差异数据。实际上,Git 更像是把变化的文件作快照后,记录在一个微型的文件系统中。每次提交更新时,它会纵览一遍所有文件的指纹信息并对文件作一快照,然后保存一个指向这次快照的索引。为提高性能,若文件没有变化,Git 不会再次保存,而只对上次保存的快照作一链接。Git 的工作方式就如下图所示:

Git thinks about its data more like a stream of snapshots.

Git stores data as snapshots of the project over time.

This makes Git more like a mini filesystem with some incredibly powerful tools built on top of it, rather than simply a VCS.

The Three States

Working tree, staging area, and Git directory.

对任何一个文件,在Git内都有三种状态:

  • 已提交(committed):表示该文件已经被安全的保存在本地数据库中了
  • 已修改(modified):表示修改了某个文件,但还没有提交保存
  • 已暂存(staged/index):表示把已修改的文件放在下次提交时要保存的清单中

Git 命令图解

Visualize Convention

git diff

git diff显示两次提交之间的变动。

git diff

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 显示暂存区和工作区的代码差异
$ git diff

# 显示暂存区和上一个commit的差异
$ git diff --cached file

# 显示工作区与当前分支最新commit之间的差异
$ git diff HEAD

# 显示两次提交之间的差异
$ git diff [first-branch]...[second-branch]

# 显示今天你写了多少行代码
$ git diff --shortstat "@{0 day ago}"

git commit

命令图解

提交时,git用暂存区域的文件创建一个新的提交,并把此时的节点设为父节点。然后把当前分支指向新的提交节点。下图中,当前分支是master。 在运行命令之前,master指向ed489,提交后,master指向新的节点f0cec并以ed489作为父节点。

git commit

即便当前分支是某次提交的祖父节点,git会同样操作。下图中,在master分支的祖父节点maint分支进行一次提交,生成了1800b。 这样,maint分支就不再是master分支的祖父节点。

git commit

更改最近的commit

如果想更改一次提交,使用 git commit --amend。git会使用与当前提交相同的父节点进行一次新提交,旧的提交会被取消。

git commit --amend

git checkout

checkout命令用于从历史提交(或者暂存区域)中拷贝文件到工作目录,也可用于切换分支。

当给定某个文件名(或者打开-p选项,或者文件名和-p选项同时打开)时,git会从指定的提交中拷贝文件到暂存区域和工作目录。比如,git checkout HEAD~ foo.c会将提交节点HEAD~(即当前提交节点的父节点)中的foo.c复制到工作目录并且加到暂存区域中。(如果命令中没有指定提交节点,则会从暂存区域中拷贝内容。)注意当前分支不会发生变化。

img

当不指定文件名,而是给出一个(本地)分支时,那么HEAD标识会移动到那个分支(也就是说,我们“切换”到那个分支了),然后暂存区域和工作目录中的内容会和HEAD对应的提交节点一致。新提交节点(下图中的a47c3)中的所有文件都会被复制(到暂存区域和工作目录中);只存在于老的提交节点(ed489)中的文件会被删除;不属于上述两者的文件会被忽略,不受影响。

img

如果既没有指定文件名,也没有指定分支名,而是一个标签、远程分支、SHA-1值或者是像master~3类似的东西,就得到一个匿名分支,称作detached HEAD(被分离的HEAD标识)。这样可以很方便地在历史版本之间互相切换。比如说你想要编译1.6.6.1版本的git,你可以运行git checkout v1.6.6.1(这是一个标签,而非分支名),编译,安装,然后切换回另一个分支,比如说git checkout master。然而,当提交操作涉及到“分离的HEAD”时,其行为会略有不同,详情见在下面

git checkout master~3

HEAD标识处于分离状态时的提交操作

HEAD处于分离状态(不依附于任一分支)时,提交操作可以正常进行,但是不会更新任何已命名的分支。(你可以认为这是在更新一个匿名分支。)

img

一旦此后你切换到别的分支,比如说master,那么这个提交节点(可能)再也不会被引用到,然后就会被丢弃掉了。注意这个命令之后就不会有东西引用2eecb

img

但是,如果你想保存这个状态,可以用命令git checkout -b *name*来创建一个新的分支。

img

git reset

reset命令把HEAD指针指向另一个位置,并且有选择的变动工作目录和索引。也用来在从历史仓库中复制文件到索引,而不动工作目录。

Soft Reset

soft reset 会移动当前 HEAD 指针到某个特定的commit,与此同时当前工作目录的文件不变。

举个例子,我们在035cc commit 新增了一个 index.js 文件,在 9e78i commit 新增了一个 styles.css文件,这个时候我们像撤销commit,不再要index.js文件,但是想保留 styles.css文件,这个时候我们执行 git reset --soft HEAD~2,HEAD指针前移到ec5be,但是在commit ec5be之后添加的文件仍然保留 commit 9e78i 的状态。

Soft Reset

Hard Reset

hard reset 不仅仅会移动当前 HEAD 指针到某个特定的commit,与此同时在该commit之后提交的所有文件修改都撤销。

Hard Reset

撤销 add

1
2
$ git reset HEAD # 撤销add的所有文件
$ git reset HEAD <fileA> # 撤销add的fileA

git revert

git revert

git merge

git merge

命令图解

Merge操作会从目标 commit 和当前 commit (即 HEAD 所指向的 commit)分叉的位置起,把目标 commit 的路径上的所有 commit 的内容一并应用到当前 commit,然后自动生成一个新的 commit。

如下图所示,master 分支当前提交为ed489,other 分支提交为33104,他们的共同祖父节点为b325c,在master 分支执行 git merge other会进行一次三方合并。结果是先保存当前目录和索引,然后和父节点33104一起做一次新提交。具体来说,执行git merge other的过程中,会对比other分支的两次commit,相对于当前master分支的最新commit,得到改变的内容,合并成一次新的commit提交到master分支。

git merge other

解决冲突

如果两个分支修改了相同的内容,merge 的时候就会发生冲突,git 不知道应该以哪个为准,会告诉你 merge 失败的原因,需要你来手动解决掉冲突,并重新 add、commit(改动不同文件或同一文件的不同行都不会产生冲突);或者使用git merge --abort放弃解决冲突,取消merge。

git merge conflict

Fast Forward

Fast Forward是git在合并分支时候为了提高性能的默认方式。当待合并的分支有当前分支的所有commit的时候,不会创建新的commit,而是直接移动HEAD指针。

git merge fast forward

Fast Forward 会丢失在dev分支上的提交信息,为了保持原有dev分支上提交链的完成性,最佳实践是采用 no-fast-forward模式来执行merge操作。

git merge no-fast-forward

git cherry-pick

git cherry-pick

命令图解

cherry-pick命令”复制”一个提交节点并在当前分支做一次完全一样的新提交。

git cherry-pick 2c33a

以上图为例,分别有mastertopic 两个分支,在 master 分支的 ed489 commit 之后执行 git cherry-pick 2c33a,可以直接将topic 分支上2c33a的commit的内容应用到master分支上,同时创建了新的commit f142b

基本用法

1
2
3
4
5
6
7
8
9
# 在target branch 上应用来自 source branch 的对应 commit 的内容
$ git cherry-pick <commitHash>
# 一次转移多个commit
$ git cherry-pick <HashA> <HashB>
# 一次转移commit A到commit B间的所有commit内容
# 要求:commit A 必须早于 commit B
# 注意:下面这个命令中 commit A 不会被包含在 cherry-pick 中
# 如果想要包含commit A,需执行 `git cherry-pick A^..B`
$ git cherry-pick A..B

命令参数

git cherry-pick命令的常用配置项如下。

(1)-e--edit

打开外部编辑器,编辑提交信息。

(2)-n--no-commit

只更新工作区和暂存区,不产生新的提交。

(3)-x

在提交信息的末尾追加一行(cherry picked from commit ...),方便以后查到这个提交是如何产生的。

(4)-s--signoff

在提交信息的末尾追加一行操作者的签名,表示是谁进行了这个操作。

(5)-m parent-number--mainline parent-number

如果原始提交是一个合并节点,来自于两个分支的合并,那么 Cherry pick 默认将失败,因为它不知道应该采用哪个分支的代码变动。

-m配置项告诉 Git,应该采用哪个分支的变动。它的参数parent-number是一个从1开始的整数,代表原始提交的父分支编号。

1
$ git cherry-pick -m 1 <commitHash>

上面命令表示,Cherry pick 采用提交commitHash来自编号1的父分支的变动。

一般来说,1号父分支是接受变动的分支(the branch being merged into),2号父分支是作为变动来源的分支(the branch being merged from)。

解决冲突

如果操作过程中发生代码冲突,Cherry pick 会停下来,让用户决定如何继续操作。

(1)--continue

用户解决代码冲突后,第一步将修改的文件重新加入暂存区(git add .),第二步使用下面的命令,让 Cherry pick 过程继续执行。

1
$ git cherry-pick --continue

(2)--abort

发生代码冲突后,放弃合并,回到操作前的样子。

(3)--quit

发生代码冲突后,退出 Cherry pick,但是不回到操作前的样子

转移到另一个代码库

Cherry pick 也支持转移另一个代码库的提交,方法是先将该库加为远程仓库。

1
$ git remote add target git://gitUrl

上面命令添加了一个远程仓库target

然后,将远程代码抓取到本地。

1
$ git fetch target

上面命令将远程代码仓库抓取到本地。

接着,检查一下要从远程仓库转移的提交,获取它的哈希值。

1
$ git log target/master

最后,使用git cherry-pick命令转移提交。

1
$ git cherry-pick <commitHash>

git rebase

git rebase

命令图解

Rebase 是合并命令的另一种选择。git merge把两个父分支合并后进行一次提交,提交历史不是线性的。Rebase在当前分支上重演另一个分支的历史,提交历史是线性的。 本质上,这是线性化的自动的cherry-pick。

git rebase master

上面的命令都在topic分支中进行,而不是master分支,将topic分支的基准先设置为master的最新commit,然后重演自己的提交,并且把分支指向新的节点。注意旧提交没有被引用,将被回收。具体来说,你从 a47c3 处从master分支创建了你自己的 topic来开发,提交了两次之后到了2c33a。在这个期间,master分支已经合并了来自于多个开发者的提交,进行到了da985这个commit。这个时候,你在自己的topic分支上执行 git rebase master,重新设置了自己的基线。topic分支的基线改为da985,然后重演169a62c33a两个提交,形成新的commit。

要限制回滚范围,使用--onto选项。下面的命令在master分支上重演当前分支从169a6以来的最近几个提交,即2c33a

git rebase --onto master 169a6

冲突解决

rebase过程中,也许会出现冲突(conflict)

  • git会停止rebase,需要解决冲突
  • 解决完,使用git add添加冲突的文件,更新暂存区
  • git rebase --continue继续剩下的rebase
  • git rebase --abort终止rebase行为,并且feature会回到rebase开始之前的状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git rebase develop
CONFLICT (content): Rebase conflict in readme.txt
Automatic rebase failed; fix conflicts and then commit the result.

$ git status
On branch feature

You have unmerged paths.
(fix conflicts and run "git rebase --continue")
(use "git merge --abort" to abort the merge)

Unmerged paths:
(use "git add <file>..." to mark resolution)

both modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

查看readme.md 内容

1
2
3
4
5
6
Git tracks changes of files.
<<<<<<< HEAD
Creating a new branch is quick & simple.
=======
Creating a new branch is quick AND simple.
>>>>>>> feature

选择保留HEAD或者feature的版本

1
2
Git tracks changes of files.
Creating a new branch is quick AND simple.

再提交:

1
2
$ git add readme.md
$ git rebase --contine

同样有git rebase --interactive让你更方便的完成一些复杂操作,比如丢弃、重排、修改、合并提交。交互式的rebase有六个可以执行的操作。

  • reword: Change the commit message
  • edit: Amend this commit
  • squash: Meld commit into the previous commit
  • fixup: Meld commit into the previous commit, without keeping the commit’s log message
  • exec: Run a command on each commit we want to rebase
  • drop: Remove the commit

压缩commit

git interactive rebase squash

丢弃commit

git rebase drop commit

git remote

为了便于管理,Git要求每个远程主机都必须指定一个主机名。git remote命令就用于管理主机名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 显示所有远程仓库
$ git remote -v
origin git@github.com:jquery/jquery.git (fetch)
origin git@github.com:jquery/jquery.git (push)

# 显示某个远程仓库的信息
$ git remote show origin
* remote origin
Fetch URL: git@github.com:SimpCosm/kubernetes.git
Push URL: git@github.com:SimpCosm/kubernetes.git
HEAD branch: master
Remote branches:
master tracked
release-0.10 tracked
release-0.12 tracked
...
Local branches configured for 'git pull':
release-1.16 merges with remote release-1.16
Local refs configured for 'git push':
release-1.18 pushes to release-1.18 (up to date)
...

# 增加一个新的远程仓库,并命名
$ git remote add <主机名> <网址>

# 删除远程仓库名
$ git remote rm <主机名>

# 给远程主机改名
$ git remote rename <原主机名> <新主机名>

git fetch

一旦远程主机的版本库有了更新,需要将这些更新取回本地,这时就要用到git fetch命令。

git fetch

1
2
3
4
5
6
# 一般用法
$ git fetch [<repository> [<refspec>...]

$ git fetch origin # 拉回origin仓库的所有分支
$ git fetch origin release-1.19 # 拉回origin仓库的release-1.19分支
$ git checkout -b release-1.19 origin/release-1.19 # 基于远程1.19分支创建本地分支

git pull

git pull命令的作用是,取回远程主机某个分支的更新,再与本地的指定分支合并。可以认为git pull是git fetch和git merge两个步骤的结合。

git pull

1
2
3
4
5
6
$ git pull <远程主机名> <远程分支名>:<本地分支名>
# 取回origin主机的next分支,与本地的master分支合并
$ git pull origin next:master
# 如果远程分支是与当前分支合并,则冒号后面的部分可以省略。
# 下面命令表示,取回origin/next分支,再与当前分支合并。实质上,这等同于先做git fetch,再做git merge。
$ git pull origin next

在某些场合,Git会自动在本地分支与远程分支之间,建立一种追踪关系(tracking)。比如,在git clone的时候,所有本地分支默认与远程主机的同名分支,建立追踪关系,也就是说,本地的master分支自动”追踪”origin/master分支。

Git也允许手动建立追踪关系。

1
$ git branch --set-upstream master origin/next

上面命令指定master分支追踪origin/next分支。

如果当前分支与远程分支存在追踪关系,git pull就可以省略远程分支名。

1
$ git pull origin

上面命令表示,本地的当前分支自动与对应的origin主机”追踪分支”(remote-tracking branch)进行合并。

git push

git push命令用于将本地分支的更新,推送到远程主机。它的格式与git pull命令相仿。

1
2
# 上传本地指定分支到远程仓库
$ git push <远程主机名> <本地分支名>:<远程分支名>

注意,分支推送顺序的写法是<来源地>:<目的地>,所以git pull是<远程分支>:<本地分支>,而git push是<本地分支>:<远程分支>。

如果省略远程分支名,则表示将本地分支推送与之存在”追踪关系”的远程分支(通常两者同名),如果该远程分支不存在,则会被新建。

1
2
$ git push origin master
`

上面命令表示,将本地的master分支推送到origin主机的master分支。如果后者不存在,则会被新建。

如果省略本地分支名,则表示删除指定的远程分支,因为这等同于推送一个空的本地分支到远程分支。

1
2
3
4
$ git push origin :master
# 等同于
$ git push origin --delete master
`

上面命令表示删除origin主机的master分支。

如果远程主机的版本比本地版本更新,推送时Git会报错,要求先在本地做git pull合并差异,然后再推送到远程主机。这时,如果你一定要推送,可以使用--force选项。

1
$ git push --force origin

上面命令使用--force选项,结果导致远程主机上更新的版本被覆盖。除非你很确定要这样做,否则应该尽量避免使用--force选项。

git log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 显示当前分支的版本历史
$ git log

# 显示commit历史,以及每次commit发生变更的文件
$ git log --stat

# 搜索提交历史,根据关键词
$ git log -S [keyword]

# 显示某个commit之后的所有变动
$ git log (tag-name||commit-SHA) HEAD

# 显示某个文件的版本历史,包括文件改名
$ git log --follow file
$ git whatchanged file

# 显示指定文件相关的每一次diff
$ git log -p file

# 显示过去5次提交
$ git log -5 --pretty --oneline

# 显示所有提交过的用户,按提交次数排序
$ git shortlog -sn

git reflog

git reflog

git show

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 显示有变更的文件
$ git status

# 显示指定文件是什么人在什么时间修改过
$ git blame file

# 显示某次提交的元数据和内容变化
$ git show commit-SHA

# 显示某次提交发生变化的文件
$ git show --name-only commit-SHA

# 显示某次提交时,某个文件的内容
$ git show commit-SHA:filename

git stash

git stash会把所有未提交的修改(包括暂存和未暂存的)都保存起来,用于日后恢复当前工作目录

  • 保存一个不必要但日后又想查看的提交
  • 切换分支前先暂存,处理分支的其他事情
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git status
On branch develop
Changes to be committed:

new file: README.md

Changes not staged for commit:

modified: index.html

$ git stash
Saved working directory and index state WIP on master: 5002d47 ...

$ git status
On branch master
nothing to commit, working tree clean

stage是本地的,不会上传到git server

实际应用中,推荐给每个stash加一个message,使用git stash save 取代 git stash

1
2
3
4
5
$ git stash save "test stash"
Saved working directory and index state On autoswitch: test stash
HEAD 现在位于 296e8d4 remove unnecessary postion reset in onResume function
$ git stash list
stash@{0}: On autoswitch: test stash
  • 可以使用git stash list命令,查看stash列表
1
2
3
4
$ git stash list
stash@{0}: WIP on master: 049d078 stash_0
stash@{1}: WIP on master: c264051 stash_1
stash@{2}: WIP on master: 21d80a5 stash_2
  • 使用git stash apply命令可以通过名字指定那个stash,默认指定最近的(stash@{0})
  • 使用git stash pop将stash中第一个stash删除,并将对应修改应用到当前的工作目录中
  • 使用git stash drop,后面加上stash名,可以移除相应的stash;或者使用git stash clear清空所有stash

默认情况下,git stash会缓存:

  • 添加到暂存区的修改(staged changes )
  • Git跟踪但并未添加到暂存区的修改(unstaged changes)

但不会缓存:

  • 在工作目录中新的文件(untracked files)
  • 被忽略的文件(ignored files)

此时,使用-u或者--include-untracked可以stash untracked 文件;使用-a或者--all可以stash当前目录下的所有修改(慎用

Git 工作流

Git Flow

Git Flow工作流的特点是项目会长期存在两个分支:

  • 主分支master:对外发布版本;
  • 开发分支develop:日常开发版本;

除了以上两个长期分支外,还会存在三种短期分支,开发完即合入master or develop,然后删除:

  • 功能分支(feature branch)
  • 补丁分支(hotfix branch)
  • 预发分支(release branch)

Git Flow

Git flow的优点是清晰可控,缺点是需要同时维护两个长期分支。且该模式适合于”版本发布”的工作模式,即周期新的产出一个版本,即有特定的发布窗口。但对于“持续发布”,每次代码在master提交都需要进行部署发布的项目就没有意义,因为master和develop分支差别不大,还要维护两个长期版本。

Github Flow

顾名思义,这是GitHub推荐的一种工作流模式。长期只有一个master分支,根据需求从master拉取新分支,开发完成后向master发起一个Pull Request(PR),PR是一个通知,大家一起进行代码的评审和讨论,此过程中也可以不断提交修改,最后PR被接受,合入master,然后进行部署,删除分支,整个流程就结束了。如下图:

Github Flow

和Git Flow相比,正好相反,GitHub Flow适合于“持续发布”,每次代码在master提交都需要进行部署发布的项目。而对于“版本发布”的项目并不合适

Gitlab Flow

顾名思义,这是GitLab推荐的工作流。它其实是一个Git Flow和GitHub Flow的一个结合。它上游只有一个master分支,且其作为上游分支,根据不同的环境建立不同的分支,所有的修改必须由”上游”向”下游”进行

  • 针对”持续发布”的项目,每个不同的环境建立不同的分支,例如:开发环境:master,预发布:pre_release,真实环境:online等等。
  • 针对”版本发布”的项目,除了master分支外,针对稳定版本拉取一个分支,例如proj_stable_1.1。

img

所有的修改都必须先在上游master进行修复,然后合入对应的分支。这样GitLab Flow就可以很好的支持Git Flow和GitHub Flow。

Git 工具

Rewriting History

Signing Your Work

Submodules

Git Submodule 实质上是 Git 的包管理器,它允许你将一个 Git 仓库作为另一个 Git 仓库的子目录。 它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立。

开始使用子模块

我们将要演示如何在一个被分成一个主项目与几个子项目的项目上开发。

我们首先将一个已存在的 Git 仓库添加为正在工作的仓库的子模块。 你可以通过在 git submodule add 命令后面加上想要跟踪的项目的相对或绝对 URL 来添加新的子模块。 在本例中,我们将会添加一个名为 “DbConnector” 的库。

1
2
3
4
5
6
7
$ git submodule add https://github.com/chaconinc/DbConnector
Cloning into 'DbConnector'...
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 11 (delta 0), reused 11 (delta 0)
Unpacking objects: 100% (11/11), done.
Checking connectivity... done.

默认情况下,子模块会将子项目放到一个与仓库同名的目录中,本例中是 “DbConnector”。 如果你想要放到其他地方,那么可以在命令结尾添加一个不同的路径。

如果这时运行 git status,你会注意到几件事。

1
2
3
4
5
6
7
8
9
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

new file: .gitmodules
new file: DbConnector

首先应当注意到新的 .gitmodules 文件。 该配置文件保存了项目 URL 与已经拉取的本地目录之间的映射:

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

如果有多个子模块,该文件中就会有多条记录。 要重点注意的是,该文件也像 .gitignore 文件一样受到(通过)版本控制。 它会和该项目的其他部分一同被拉取推送。 这就是克隆该项目的人知道去哪获得子模块的原因。

由于 .gitmodules 文件中的 URL 是人们首先尝试克隆/拉取的地方,因此请尽可能确保你使用的 URL 大家都能访问。 例如,若你要使用的推送 URL 与他人的拉取 URL 不同,那么请使用他人能访问到的 URL。 你也可以根据自己的需要,通过在本地执行 git config submodule.DbConnector.url <私有URL> 来覆盖这个选项的值。 如果可行的话,一个相对路径会很有帮助。

git status 输出中列出的另一个是项目文件夹记录。 如果你运行 git diff,会看到类似下面的信息:

1
2
3
4
5
6
7
8
$ git diff --cached DbConnector
diff --git a/DbConnector b/DbConnector
new file mode 160000
index 0000000..c3f01dc
--- /dev/null
+++ b/DbConnector
@@ -0,0 +1 @@
+Subproject commit c3f01dc8862123d317dd46284b05b6892c7b29bc

虽然 DbConnector 是工作目录中的一个子目录,但 Git 还是会将它视作一个子模块。当你不在那个目录中时,Git 并不会跟踪它的内容, 而是将它看作子模块仓库中的某个具体的提交。

如果你想看到更漂亮的差异输出,可以给 git diff 传递 --submodule 选项。

1
2
3
4
5
6
7
8
9
10
11
$ git diff --cached --submodule
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..71fc376
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "DbConnector"]
+ path = DbConnector
+ url = https://github.com/chaconinc/DbConnector
Submodule DbConnector 0000000...c3f01dc (new submodule)

当你提交时,会看到类似下面的信息:

1
2
3
4
5
$ git commit -am 'added DbConnector module'
[master fb9093c] added DbConnector module
2 files changed, 4 insertions(+)
create mode 100644 .gitmodules
create mode 160000 DbConnector

注意 DbConnector 记录的 160000 模式。 这是 Git 中的一种特殊模式,它本质上意味着你是将一次提交记作一项目录记录的,而非将它记录成一个子目录或者一个文件。

最后,推送这些更改:

1
$ git push origin master

克隆含有子模块的项目

接下来我们将会克隆一个含有子模块的项目。 当你在克隆这样的项目时,默认会包含该子模块目录,但其中还没有任何文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ git clone https://github.com/chaconinc/MainProject
Cloning into 'MainProject'...
remote: Counting objects: 14, done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 14 (delta 1), reused 13 (delta 0)
Unpacking objects: 100% (14/14), done.
Checking connectivity... done.
$ cd MainProject
$ ls -la
total 16
drwxr-xr-x 9 schacon staff 306 Sep 17 15:21 .
drwxr-xr-x 7 schacon staff 238 Sep 17 15:21 ..
drwxr-xr-x 13 schacon staff 442 Sep 17 15:21 .git
-rw-r--r-- 1 schacon staff 92 Sep 17 15:21 .gitmodules
drwxr-xr-x 2 schacon staff 68 Sep 17 15:21 DbConnector
-rw-r--r-- 1 schacon staff 756 Sep 17 15:21 Makefile
drwxr-xr-x 3 schacon staff 102 Sep 17 15:21 includes
drwxr-xr-x 4 schacon staff 136 Sep 17 15:21 scripts
drwxr-xr-x 4 schacon staff 136 Sep 17 15:21 src
$ cd DbConnector/
$ ls
$

其中有 DbConnector 目录,不过是空的。 你必须运行两个命令:git submodule init 用来初始化本地配置文件,而 git submodule update 则从该项目中抓取所有数据并检出父项目中列出的合适的提交。

1
2
3
4
5
6
7
8
9
10
$ git submodule init
Submodule 'DbConnector' (https://github.com/chaconinc/DbConnector) registered for path 'DbConnector'
$ git submodule update
Cloning into 'DbConnector'...
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 11 (delta 0), reused 11 (delta 0)
Unpacking objects: 100% (11/11), done.
Checking connectivity... done.
Submodule path 'DbConnector': checked out 'c3f01dc8862123d317dd46284b05b6892c7b29bc'

现在 DbConnector 子目录是处在和之前提交时相同的状态了。

不过还有更简单一点的方式。 如果给 git clone 命令传递 --recurse-submodules 选项,它就会自动初始化并更新仓库中的每一个子模块, 包括可能存在的嵌套子模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git clone --recurse-submodules https://github.com/chaconinc/MainProject
Cloning into 'MainProject'...
remote: Counting objects: 14, done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 14 (delta 1), reused 13 (delta 0)
Unpacking objects: 100% (14/14), done.
Checking connectivity... done.
Submodule 'DbConnector' (https://github.com/chaconinc/DbConnector) registered for path 'DbConnector'
Cloning into 'DbConnector'...
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 11 (delta 0), reused 11 (delta 0)
Unpacking objects: 100% (11/11), done.
Checking connectivity... done.
Submodule path 'DbConnector': checked out 'c3f01dc8862123d317dd46284b05b6892c7b29bc'

如果你已经克隆了项目但忘记了 --recurse-submodules,那么可以运行 git submodule update --initgit submodule initgit submodule update 合并成一步。如果还要初始化、抓取并检出任何嵌套的子模块, 请使用简明的 git submodule update --init --recursive

在包含子模块的项目上工作

现在我们有一份包含子模块的项目副本,我们将会同时在主项目和子模块项目上与队员协作。

从子模块的远端拉取上游修改

在项目中使用子模块的最简模型,就是只使用子项目并不时地获取更新,而并不在你的检出中进行任何更改。 我们来看一个简单的例子。

如果想要在子模块中查看新工作,可以进入到目录中运行 git fetchgit merge,合并上游分支来更新本地代码。

1
2
3
4
5
6
7
8
9
$ git fetch
From https://github.com/chaconinc/DbConnector
c3f01dc..d0354fc master -> origin/master
$ git merge origin/master
Updating c3f01dc..d0354fc
Fast-forward
scripts/connect.sh | 1 +
src/db.c | 1 +
2 files changed, 2 insertions(+)

如果你现在返回到主项目并运行 git diff --submodule,就会看到子模块被更新的同时获得了一个包含新添加提交的列表。 如果你不想每次运行 git diff 时都输入 --submodle,那么可以将 diff.submodule 设置为 “log” 来将其作为默认行为。

1
2
3
4
5
$ git config --global diff.submodule log
$ git diff
Submodule DbConnector c3f01dc..d0354fc:
> more efficient db routine
> better connection routine

如果在此时提交,那么你会将子模块锁定为其他人更新时的新代码。

如果你不想在子目录中手动抓取与合并,那么还有种更容易的方式。 运行 git submodule update --remote,Git 将会进入子模块然后抓取并更新。

1
2
3
4
5
6
7
8
$ git submodule update --remote DbConnector
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 2), reused 4 (delta 2)
Unpacking objects: 100% (4/4), done.
From https://github.com/chaconinc/DbConnector
3f19983..d0354fc master -> origin/master
Submodule path 'DbConnector': checked out 'd0354fc054692d3906c85c3af05ddce39a1c0644'

此命令默认会假定你想要更新并检出子模块仓库的 master 分支。 不过你也可以设置为想要的其他分支。 例如,你想要 DbConnector 子模块跟踪仓库的 “stable” 分支,那么既可以在 .gitmodules 文件中设置 (这样其他人也可以跟踪它),也可以只在本地的 .git/config 文件中设置。 让我们在 .gitmodules 文件中设置它:

1
2
3
4
5
6
7
8
9
10
$ git config -f .gitmodules submodule.DbConnector.branch stable

$ git submodule update --remote
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 2), reused 4 (delta 2)
Unpacking objects: 100% (4/4), done.
From https://github.com/chaconinc/DbConnector
27cf5d3..c87d55d stable -> origin/stable
Submodule path 'DbConnector': checked out 'c87d55d4c6d4b05ee34fbc8cb6f7bf4585ae6687'

如果不用 -f .gitmodules 选项,那么它只会为你做修改。但是在仓库中保留跟踪信息更有意义一些,因为其他人也可以得到同样的效果。

这时我们运行 git status,Git 会显示子模块中有“新提交”。

1
2
3
4
5
6
7
8
9
10
11
12
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: .gitmodules
modified: DbConnector (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

如果你设置了配置选项 status.submodulesummary,Git 也会显示你的子模块的更改摘要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git config status.submodulesummary 1

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: .gitmodules
modified: DbConnector (new commits)

Submodules changed but not updated:

* DbConnector c3f01dc...c87d55d (4):
> catch non-null terminated lines

这时如果运行 git diff,可以看到我们修改了 .gitmodules 文件,同时还有几个已拉取的提交需要提交到我们自己的子模块项目中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git diff
diff --git a/.gitmodules b/.gitmodules
index 6fc0b3d..fd1cc29 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,4 @@
[submodule "DbConnector"]
path = DbConnector
url = https://github.com/chaconinc/DbConnector
+ branch = stable
Submodule DbConnector c3f01dc..c87d55d:
> catch non-null terminated lines
> more robust error handling
> more efficient db routine
> better connection routine

这非常有趣,因为我们可以直接看到将要提交到子模块中的提交日志。 提交之后,你也可以运行 git log -p 查看这个信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ git log -p --submodule
commit 0a24cfc121a8a3c118e0105ae4ae4c00281cf7ae
Author: Scott Chacon <schacon@gmail.com>
Date: Wed Sep 17 16:37:02 2014 +0200

updating DbConnector for bug fixes

diff --git a/.gitmodules b/.gitmodules
index 6fc0b3d..fd1cc29 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,4 @@
[submodule "DbConnector"]
path = DbConnector
url = https://github.com/chaconinc/DbConnector
+ branch = stable
Submodule DbConnector c3f01dc..c87d55d:
> catch non-null terminated lines
> more robust error handling
> more efficient db routine
> better connection routine

当运行 git submodule update --remote 时,Git 默认会尝试更新 所有 子模块, 所以如果有很多子模块的话,你可以传递想要更新的子模块的名字。

从项目远端拉取上游更改

现在,让我们站在协作者的视角,他有自己的 MainProject 仓库的本地克隆, 只是执行 git pull 获取你新提交的更改还不够:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ git pull
From https://github.com/chaconinc/MainProject
fb9093c..0a24cfc master -> origin/master
Fetching submodule DbConnector
From https://github.com/chaconinc/DbConnector
c3f01dc..c87d55d stable -> origin/stable
Updating fb9093c..0a24cfc
Fast-forward
.gitmodules | 2 +-
DbConnector | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: DbConnector (new commits)

Submodules changed but not updated:

* DbConnector c87d55d...c3f01dc (4):
< catch non-null terminated lines
< more robust error handling
< more efficient db routine
< better connection routine

no changes added to commit (use "git add" and/or "git commit -a")

默认情况下,git pull 命令会递归地抓取子模块的更改,如上面第一个命令的输出所示。 然而,它不会 更新 子模块。这点可通过 git status 命令看到,它会显示子模块“已修改”,且“有新的提交”。 此外,左边的尖括号(<)指出了新的提交,表示这些提交已在 MainProject 中记录,但尚未在本地的 DbConnector 中检出。 为了完成更新,你需要运行 git submodule update

1
2
3
4
5
6
7
$ git submodule update --init --recursive
Submodule path 'vendor/plugins/demo': checked out '48679c6302815f6c76f1fe30625d795d9e55fc56'

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working tree clean

请注意,为安全起见,如果 MainProject 提交了你刚拉取的新子模块,那么应该在 git submodule update 后面添加 --init 选项,如果子模块有嵌套的子模块,则应使用 --recursive 选项。

如果你想自动化此过程,那么可以为 git pull 命令添加 --recurse-submodules 选项(从 Git 2.14 开始)。 这会让 Git 在拉取后运行 git submodule update,将子模块置为正确的状态。 此外,如果你想让 Git 总是以 --recurse-submodules 拉取,可以将配置选项 submodule.recurse 设置为 true (从 Git 2.15 开始可用于 git pull)。此选项会让 Git 为所有支持 --recurse-submodules 的命令使用该选项(除 clone 以外)。

在为父级项目拉取更新时,还会出现一种特殊的情况:在你拉取的提交中, 可能 .gitmodules 文件中记录的子模块的 URL 发生了改变。 比如,若子模块项目改变了它的托管平台,就会发生这种情况。 此时,若父级项目引用的子模块提交不在仓库中本地配置的子模块远端上,那么执行 git pull --recurse-submodulesgit submodule update 就会失败。 为了补救,git submodule sync 命令需要:

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

你很有可能正在使用子模块,因为你确实想在子模块中编写代码的同时,还想在主项目上编写代码(或者跨子模块工作)。 否则你大概只能用简单的依赖管理系统(如 Maven 或 Rubygems)来替代了。

现在我们将通过一个例子来演示如何在子模块与主项目中同时做修改,以及如何同时提交与发布那些修改。

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

为了将子模块设置得更容易进入并修改,你需要做两件事。 首先,进入每个子模块并检出其相应的工作分支。 接着,若你做了更改就需要告诉 Git 它该做什么,然后运行 git submodule update --remote 来从上游拉取新工作。 你可以选择将它们合并到你的本地工作中,也可以尝试将你的工作变基到新的更改上。

首先,让我们进入子模块目录然后检出一个分支。

1
2
3
$ cd DbConnector/
$ git checkout stable
Switched to branch 'stable'

然后尝试用 “merge” 选项来更新子模块。 为了手动指定它,我们只需给 update 添加 --merge 选项即可。 这时我们将会看到服务器上的这个子模块有一个改动并且它被合并了进来。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cd ..
$ git submodule update --remote --merge
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 2), reused 4 (delta 2)
Unpacking objects: 100% (4/4), done.
From https://github.com/chaconinc/DbConnector
c87d55d..92c7337 stable -> origin/stable
Updating c87d55d..92c7337
Fast-forward
src/main.c | 1 +
1 file changed, 1 insertion(+)
Submodule path 'DbConnector': merged in '92c7337b30ef9e0893e758dac2459d07362ab5ea'

如果我们进入 DbConnector 目录,可以发现新的改动已经合并入本地 stable 分支。 现在让我们看看当我们对库做一些本地的改动而同时其他人推送另外一个修改到上游时会发生什么。

1
2
3
4
5
$ cd DbConnector/
$ vim src/db.c
$ git commit -am 'unicode support'
[stable f906e16] unicode support
1 file changed, 1 insertion(+)

如果我们现在更新子模块,就会看到当我们在本地做了更改时上游也有一个改动,我们需要将它并入本地。

1
2
3
4
5
$ cd ..
$ git submodule update --remote --rebase
First, rewinding head to replay your work on top of it...
Applying: unicode support
Submodule path 'DbConnector': rebased into '5d60ef9bbebf5a0c1c1050f242ceeb54ad58da94'

如果你忘记 --rebase--merge,Git 会将子模块更新为服务器上的状态。并且会将项目重置为一个游离的 HEAD 状态。

1
2
$ git submodule update --remote
Submodule path 'DbConnector': checked out '5d60ef9bbebf5a0c1c1050f242ceeb54ad58da94'

即便这真的发生了也不要紧,你只需回到目录中再次检出你的分支(即还包含着你的工作的分支)然后手动地合并或变基 origin/stable(或任何一个你想要的远程分支)就行了。

如果你没有提交子模块的改动,那么运行一个子模块更新也不会出现问题,此时 Git 会只抓取更改而并不会覆盖子模块目录中未保存的工作。

1
2
3
4
5
6
7
8
9
10
11
12
$ git submodule update --remote
remote: Counting objects: 4, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 4 (delta 0), reused 4 (delta 0)
Unpacking objects: 100% (4/4), done.
From https://github.com/chaconinc/DbConnector
5d60ef9..c75e92a stable -> origin/stable
error: Your local changes to the following files would be overwritten by checkout:
scripts/setup.sh
Please, commit your changes or stash them before you can switch branches.
Aborting
Unable to checkout 'c75e92a2b3855c9e5b66f915308390d9db204aca' in submodule path 'DbConnector'

如果你做了一些与上游改动冲突的改动,当运行更新时 Git 会让你知道。

1
2
3
4
5
6
$ git submodule update --remote --merge
Auto-merging scripts/setup.sh
CONFLICT (content): Merge conflict in scripts/setup.sh
Recorded preimage for 'scripts/setup.sh'
Automatic merge failed; fix conflicts and then commit the result.
Unable to merge 'c75e92a2b3855c9e5b66f915308390d9db204aca' in submodule path 'DbConnector'

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

发布子模块改动

现在我们的子模块目录中有一些改动。 其中有一些是我们通过更新从上游引入的,而另一些是本地生成的,由于我们还没有推送它们,所以对任何其他人都不可用。

1
2
3
4
5
6
7
$ git diff
Submodule DbConnector c87d55d..82d2ad3:
> Merge from origin/stable
> updated setup script
> unicode support
> remove unnecessary method
> add new option for conn pooling

如果我们在主项目中提交并推送但并不推送子模块上的改动,其他尝试检出我们修改的人会遇到麻烦, 因为他们无法得到依赖的子模块改动。那些改动只存在于我们本地的拷贝中。

为了确保这不会发生,你可以让 Git 在推送到主项目前检查所有子模块是否已推送。 git push 命令接受可以设置为 “check” 或 “on-demand” 的 --recurse-submodules 参数。 如果任何提交的子模块改动没有推送那么 “check” 选项会直接使 push 操作失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ 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 config push.recurseSubmodules check 让它成为默认行为。

另一个选项是使用 “on-demand” 值,它会尝试为你这样做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git push --recurse-submodules=on-demand
Pushing submodule 'DbConnector'
Counting objects: 9, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (9/9), 917 bytes | 0 bytes/s, done.
Total 9 (delta 3), reused 0 (delta 0)
To https://github.com/chaconinc/DbConnector
c75e92a..82d2ad3 stable -> stable
Counting objects: 2, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 266 bytes | 0 bytes/s, done.
Total 2 (delta 1), reused 0 (delta 0)
To https://github.com/chaconinc/MainProject
3d6d338..9a377d1 master -> master

如你所见,Git 进入到 DbConnector 模块中然后在推送主项目前推送了它。 如果那个子模块因为某些原因推送失败,主项目也会推送失败。 你也可以通过设置 git config push.recurseSubmodules on-demand 让它成为默认行为。

合并子模块改动

如果你其他人同时改动了一个子模块引用,那么可能会遇到一些问题。 也就是说,如果子模块的历史已经分叉并且在父项目中分别提交到了分叉的分支上,那么你需要做一些工作来修复它。

如果一个提交是另一个的直接祖先(一个快进式合并),那么 Git 会简单地选择之后的提交来合并,这样没什么问题。

不过,Git 甚至不会尝试去进行一次简单的合并。 如果子模块提交已经分叉且需要合并,那你会得到类似下面的信息:

1
2
3
4
5
6
7
8
9
10
11
12
$ git pull
remote: Counting objects: 2, done.
remote: Compressing objects: 100% (1/1), done.
remote: Total 2 (delta 1), reused 2 (delta 1)
Unpacking objects: 100% (2/2), done.
From https://github.com/chaconinc/MainProject
9a377d1..eb974f8 master -> origin/master
Fetching submodule DbConnector
warning: Failed to merge submodule DbConnector (merge following commits not found)
Auto-merging DbConnector
CONFLICT (submodule): Merge conflict in DbConnector
Automatic merge failed; fix conflicts and then commit the result.

所以本质上 Git 在这里指出了子模块历史中的两个分支记录点已经分叉并且需要合并。 它将其解释为 “merge following commits not found” (未找到接下来需要合并的提交),虽然这有点令人困惑,不过之后我们会解释为什么是这样。

为了解决这个问题,你需要弄清楚子模块应该处于哪种状态。 奇怪的是,Git 并不会给你多少能帮你摆脱困境的信息,甚至连两边提交历史中的 SHA-1 值都没有。 幸运的是,这很容易解决。 如果你运行 git diff,就会得到试图合并的两个分支中记录的提交的 SHA-1 值。

1
2
3
4
5
$ git diff
diff --cc DbConnector
index eb41d76,c771610..0000000
--- a/DbConnector
+++ b/DbConnector

所以,在本例中,eb41d76 是我们的子模块中大家共有的提交,而 c771610 是上游拥有的提交。 如果我们进入子模块目录中,它应该已经在 eb41d76 上了,因为合并没有动过它。 如果不是的话,无论什么原因,你都可以简单地创建并检出一个指向它的分支。

来自另一边的提交的 SHA-1 值比较重要。 它是需要你来合并解决的。 你可以尝试直接通过 SHA-1 合并,也可以为它创建一个分支然后尝试合并。 我们建议后者,哪怕只是为了一个更漂亮的合并提交信息。

所以,我们将会进入子模块目录,基于 git diff 的第二个 SHA-1 创建一个分支然后手动合并。

1
2
3
4
5
6
7
8
9
10
11
$ cd DbConnector

$ git rev-parse HEAD
eb41d764bccf88be77aced643c13a7fa86714135

$ git branch try-merge c771610
(DbConnector) $ git merge try-merge
Auto-merging src/main.c
CONFLICT (content): Merge conflict in src/main.c
Recorded preimage for 'src/main.c'
Automatic merge failed; fix conflicts and then commit the result.

我们在这儿得到了一个真正的合并冲突,所以如果想要解决并提交它,那么只需简单地通过结果来更新主项目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ vim src/main.c (1)
$ git add src/main.c
$ git commit -am 'merged our changes'
Recorded resolution for 'src/main.c'.
[master 9fd905e] merged our changes

$ cd .. (2)
$ git diff (3)
diff --cc DbConnector
index eb41d76,c771610..0000000
--- a/DbConnector
+++ b/DbConnector
@@@ -1,1 -1,1 +1,1 @@@
- Subproject commit eb41d764bccf88be77aced643c13a7fa86714135
-Subproject commit c77161012afbbe1f58b5053316ead08f4b7e6d1d
++Subproject commit 9fd905e5d7f45a0d4cbc43d1ee550f16a30e825a
$ git add DbConnector (4)

$ git commit -m "Merge Tom's Changes" (5)
[master 10d2c60] Merge Tom's Changes
  1. 首先解决冲突
  2. 然后返回到主项目目录中
  3. 再次检查 SHA-1 值
  4. 解决冲突的子模块记录
  5. 提交我们的合并

这可能会让你有点儿困惑,但它确实不难。

有趣的是,Git 还能处理另一种情况。 如果子模块目录中存在着这样一个合并提交,它的历史中包含了的两边的提交,那么 Git 会建议你将它作为一个可行的解决方案。 它看到有人在子模块项目的某一点上合并了包含这两次提交的分支,所以你可能想要那个。

这就是为什么前面的错误信息是 “merge following commits not found”,因为它不能 这样 做。 它让人困惑是因为谁能想到它会尝试这样做?

如果它找到了一个可以接受的合并提交,你会看到类似下面的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git merge origin/master
warning: Failed to merge submodule DbConnector (not fast-forward)
Found a possible merge resolution for the submodule:
9fd905e5d7f45a0d4cbc43d1ee550f16a30e825a: > merged our changes
If this is correct simply add it to the index for example
by using:

git update-index --cacheinfo 160000 9fd905e5d7f45a0d4cbc43d1ee550f16a30e825a "DbConnector"

which will accept this suggestion.
Auto-merging DbConnector
CONFLICT (submodule): Merge conflict in DbConnector
Automatic merge failed; fix conflicts and then commit the result.

Git 建议的命令是更新索引,就像你运行了 git add 那样,这样会清除冲突然后提交。 不过你可能不应该这样做。你可以轻松地进入子模块目录,查看差异是什么,快进到这次提交,恰当地测试,然后提交它。

1
2
3
4
5
6
7
8
$ cd DbConnector/
$ git merge 9fd905e
Updating eb41d76..9fd905e
Fast-forward

$ cd ..
$ git add DbConnector
$ git commit -am 'Fast forwarded to a common submodule child'

这些命令完成了同一件事,但是通过这种方式你至少可以验证工作是否有效,以及当你在完成时可以确保子模块目录中有你的代码。

子模的块技巧

你可以做几件事情来让用子模块工作轻松一点儿。

子模块遍历

有一个 foreach 子模块命令,它能在每一个子模块中运行任意命令。 如果项目中包含了大量子模块,这会非常有用。

例如,假设我们想要开始开发一项新功能或者修复一些错误,并且需要在几个子模块内工作。 我们可以轻松地保存所有子模块的工作进度。

1
2
3
4
5
6
$ git submodule foreach 'git stash'
Entering 'CryptoLibrary'
No local changes to save
Entering 'DbConnector'
Saved working directory and index state WIP on stable: 82d2ad3 Merge from origin/stable
HEAD is now at 82d2ad3 Merge from origin/stable

然后我们可以创建一个新分支,并将所有子模块都切换过去。

1
2
3
4
5
$ git submodule foreach 'git checkout -b featureA'
Entering 'CryptoLibrary'
Switched to a new branch 'featureA'
Entering 'DbConnector'
Switched to a new branch 'featureA'

你应该明白。 能够生成一个主项目与所有子项目的改动的统一差异是非常有用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ git diff; git submodule foreach 'git diff'
Submodule DbConnector contains modified content
diff --git a/src/main.c b/src/main.c
index 210f1ae..1f0acdc 100644
--- a/src/main.c
+++ b/src/main.c
@@ -245,6 +245,8 @@ static int handle_alias(int *argcp, const char ***argv)

commit_pager_choice();

+ url = url_decode(url_orig);
+
/* build alias_argv */
alias_argv = xmalloc(sizeof(*alias_argv) * (argc + 1));
alias_argv[0] = alias_string + 1;
Entering 'DbConnector'
diff --git a/src/db.c b/src/db.c
index 1aaefb6..5297645 100644
--- a/src/db.c
+++ b/src/db.c
@@ -93,6 +93,11 @@ char *url_decode_mem(const char *url, int len)
return url_decode_internal(&url, len, NULL, &out, 0);
}

+char *url_decode(const char *url)
+{
+ return url_decode_mem(url, strlen(url));
+}
+
char *url_decode_parameter_name(const char **query)
{
struct strbuf out = STRBUF_INIT;

在这里,我们看到子模块中定义了一个函数并在主项目中调用了它。 这明显是个简化了的例子,但是希望它能让你明白这种方法的用处。

有用的别名

你可能想为其中一些命令设置别名,因为它们可能会非常长而你又不能设置选项作为它们的默认选项。 我们在 Git 别名 介绍了设置 Git 别名, 但是如果你计划在 Git 中大量使用子模块的话,这里有一些例子。

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 2.13 以前的版本时,在有子模块的项目中切换分支可能会造成麻烦。 如果你创建一个新分支,在其中添加一个子模块,之后切换到没有该子模块的分支上时,你仍然会有一个还未跟踪的子模块目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ git --version
git version 2.12.2

$ git checkout -b add-crypto
Switched to a new branch 'add-crypto'

$ git submodule add https://github.com/chaconinc/CryptoLibrary
Cloning into 'CryptoLibrary'...
...

$ git commit -am 'adding crypto library'
[add-crypto 4445836] adding crypto library
2 files changed, 4 insertions(+)
create mode 160000 CryptoLibrary

$ git checkout master
warning: unable to rmdir CryptoLibrary: Directory not empty
Switched to branch 'master'
Your branch is up-to-date with 'origin/master'.

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

Untracked files:
(use "git add <file>..." to include in what will be committed)

CryptoLibrary/

nothing added to commit but untracked files present (use "git add" to track)

移除那个目录并不困难,但是有一个目录在那儿会让人有一点困惑。 如果你移除它然后切换回有那个子模块的分支,需要运行 submodule update --init 来重新建立和填充。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git clean -fdx
Removing CryptoLibrary/

$ git checkout add-crypto
Switched to branch 'add-crypto'

$ ls CryptoLibrary/

$ git submodule update --init
Submodule path 'CryptoLibrary': checked out 'b8dda6aa182ea4464f3f3264b11e0268545172af'

$ ls CryptoLibrary/
Makefile includes scripts src

再说一遍,这真的不难,只是会让人有点儿困惑。

新版的 Git(>= 2.13)通过为 git checkout 命令添加 --recurse-submodules 选项简化了所有这些步骤, 它能为了我们要切换到的分支让子模块处于的正确状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ git --version
git version 2.13.3

$ git checkout -b add-crypto
Switched to a new branch 'add-crypto'

$ git submodule add https://github.com/chaconinc/CryptoLibrary
Cloning into 'CryptoLibrary'...
...

$ git commit -am 'adding crypto library'
[add-crypto 4445836] adding crypto library
2 files changed, 4 insertions(+)
create mode 160000 CryptoLibrary

$ git checkout --recurse-submodules master
Switched to branch 'master'
Your branch is up-to-date with 'origin/master'.

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.

nothing to commit, working tree clean

当你在父级项目的几个分支上工作时,对 git checkout 使用 --recurse-submodules 选项也很有用, 它能让你的子模块处于不同的提交上。确实,如果你在记录了子模块的不同提交的分支上切换, 那么在执行 git status 后子模块会显示为“已修改”并指出“新的提交”。 这是因为子模块的状态默认不会在切换分支时保留。

这点非常让人困惑,因此当你的项目中拥有子模块时,可以总是使用 git checkout --recurse-submodules。 (对于没有 --recurse-submodules 选项的旧版 Git,在检出之后可使用 git submodule update --init --recursive 来让子模块处于正确的状态)。

幸运的是,你可以通过 git config submodule.recurse true 设置 submodule.recurse 选项, 告诉 Git(>=2.14)总是使用 --recurse-submodules。 如上所述,这也会让 Git 为每个拥有 --recurse-submodules 选项的命令(除了 git clone) 总是递归地在子模块中执行。

从子目录切换到子模块

另一个主要的告诫是许多人遇到了将子目录转换为子模块的问题。 如果你在项目中已经跟踪了一些文件,然后想要将它们移动到一个子模块中,那么请务必小心,否则 Git 会对你发脾气。 假设项目内有一些文件在子目录中,你想要将其转换为一个子模块。 如果删除子目录然后运行 submodule add,Git 会朝你大喊:

1
2
3
$ rm -Rf CryptoLibrary/
$ git submodule add https://github.com/chaconinc/CryptoLibrary
'CryptoLibrary' already exists in the index

你必须要先取消暂存 CryptoLibrary 目录。 然后才可以添加子模块:

1
2
3
4
5
6
7
8
$ git rm -r CryptoLibrary
$ git submodule add https://github.com/chaconinc/CryptoLibrary
Cloning into 'CryptoLibrary'...
remote: Counting objects: 11, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 11 (delta 0), reused 11 (delta 0)
Unpacking objects: 100% (11/11), done.
Checking connectivity... done.

现在假设你在一个分支下做了这样的工作。 如果尝试切换回的分支中那些文件还在子目录而非子模块中时——你会得到这个错误:

1
2
3
4
5
6
7
$ git checkout master
error: The following untracked working tree files would be overwritten by checkout:
CryptoLibrary/Makefile
CryptoLibrary/includes/crypto.h
...
Please move or remove them before you can switch branches.
Aborting

你可以通过 checkout -f 来强制切换,但是要小心,如果其中还有未保存的修改,这个命令会把它们覆盖掉。

1
2
3
$ git checkout -f master
warning: unable to rmdir CryptoLibrary: Directory not empty
Switched to branch 'master'

当你切换回来之后,因为某些原因你得到了一个空的 CryptoLibrary 目录,并且 git submodule update 也无法修复它。 你需要进入到子模块目录中运行 git checkout . 来找回所有的文件。 你也可以通过 submodule foreach 脚本来为多个子模块运行它。

要特别注意的是,近来子模块会将它们的所有 Git 数据保存在顶级项目的 .git 目录中,所以不像旧版本的 Git,摧毁一个子模块目录并不会丢失任何提交或分支。

拥有了这些工具,使用子模块会成为可以在几个相关但却分离的项目上同时开发的相当简单有效的方法。

Git Configuration

Git Hooks

Git Internal

Git常用命令共有30多个,可运行git help查看;但Git总共有130多个命令,可以通过git help -a查看,这些命令可以分为高层命令和底层命令,底层命令被设计成unix风格,不常用

img

Git仓库下有一个.git目录,里面存储了git全部的秘密,一般包括下面的内容:

  • config
  • index
  • HEAD
  • hooks/
  • logs/
  • refs/
  • objects/

下面会详细介绍没个部分都是什么

config

config是仓库的配置文件,一个典型的配置文件如下,我们创建的远端,分支都在配置文件里有表现; fetch操作的行为也是在这里配置的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[core]
repositoryformatversion = 0
filemode = false
bare = false
logallrefupdates = true
symlinks = false
ignorecase = true
[remote "origin"]
url = git@github.com:yanhaijing/zepto.fullpage.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
[branch "dev"]
remote = origin
merge = refs/heads/dev

objects

git通过一种算法可以得到任意文件的指纹(40位16进制数字),然后通过文件指纹存取数据,存取的数据都位于objects目录

img

  • Git常用命令共有30多个,可运行git help查看;但Git总共有130多个命令,可以通过git help -a查看,这些命令可以分为高层命令和底层命令,底层命令被设计成unix风格,不常用

    img

    Git仓库下有一个.git目录,里面存储了git全部的秘密,一般包括下面的内容:

    • config
    • index
    • HEAD
    • hooks/
    • logs/
    • refs/
    • objects/

    下面会详细介绍没个部分都是什么

    config

    config是仓库的配置文件,一个典型的配置文件如下,我们创建的远端,分支都在配置文件里有表现; fetch操作的行为也是在这里配置的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    [core]
    repositoryformatversion = 0
    filemode = false
    bare = false
    logallrefupdates = true
    symlinks = false
    ignorecase = true
    [remote "origin"]
    url = git@github.com:yanhaijing/zepto.fullpage.git
    fetch = +refs/heads/*:refs/remotes/origin/*
    [branch "master"]
    remote = origin
    merge = refs/heads/master
    [branch "dev"]
    remote = origin
    merge = refs/heads/dev

    objects

    git通过一种算法可以得到任意文件的指纹(40位16进制数字),然后通过文件指纹存取数据,存取的数据都位于objects目录

    img

    Git 从核心上来看不过是简单地存储键值对(key-value)。它允许插入任意类型的内容,并会返回一个键值,通过该键值可以在任何时候再取出该内容。Git存储的索引内容包含三种对象:

    • commit对象每次提交都会至少产生一个commit对象,它的内容包括:指向parent commit对象,根tree对象。
    • tree对象:类似于目录,tree对象中包含多条记录,每条记录保存了本次快照的所有tree对象和blob对象。
    • blob对象:类似于文件,保存具体的文件内容。

    objects目录下有3种类型的数据:

    • Blob
    • Tree
    • Commit

    文件都被存储为blob类型的文件,可以通过内部命令hash-object写入数据

    1
    2
    echo 'test content' | git hash-object -w --stdin
    d670460b4b4aece5915caf5c68d12f560a9fe3e4

    然后通过cat-file取出数据

    1
    2
    $ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
    test content

    文件夹被存储为tree类型的文件,文件内容如下所示

    1
    2
    git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
    100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b simplegit.rb

    一般我们系统中的目录,在git中会像下面这样存储

    img

    创建的提交节点被存储为commit类型数据,commit文件的内容如下

    1
    2
    3
    4
    5
    6
    $ git cat-file -p fdf4fc3
    tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
    author Scott Chacon <schacon@gmail.com> 1243040974 -0700
    committer Scott Chacon <schacon@gmail.com> 1243040974 -0700

    first commit

    有三个提交的Git仓库可简化为下图所示

    img

    refs

    refs目录存储都是引用文件,如本地分支,远端分支,标签等

    • refs/heads/xxx 本地分支
    • refs/remotes/origin/xxx 远端分支
    • refs/tags/xxx 本地tag

    引用文件的内容都是40位commit

    img

    上面只有提交的图补上分支后,如下所示

    img

    HEAD文件存储的是当前所在的位置,其内容可以使分支名字,40位commit ID

    1
    2
    $ cat HEAD
    refs/heads/master

    上面的图补上HEAD后,如下所示:

    img

文件都被存储为blob类型的文件,可以通过内部命令hash-object写入数据

1
2
echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

然后通过cat-file取出数据

1
2
$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

文件夹被存储为tree类型的文件,文件内容如下所示

1
2
git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b simplegit.rb

一般我们系统中的目录,在git中会像下面这样存储

img

创建的提交节点被存储为commit类型数据,commit文件的内容如下

1
2
3
4
5
6
$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700

first commit

有三个提交的Git仓库可简化为下图所示

img

refs

refs目录存储都是引用文件,如本地分支,远端分支,标签等

  • refs/heads/xxx 本地分支
  • refs/remotes/origin/xxx 远端分支
  • refs/tags/xxx 本地tag

引用文件的内容都是40位commit

img

上面只有提交的图补上分支后,如下所示

img

HEAD

HEAD文件存储的是当前所在的位置,其内容可以使分支名字,40位commit ID

1
2
$ cat HEAD
refs/heads/master

上面的图补上HEAD后,如下所示:

img

参考资料