02、分支(branch)
1、分支是什么
为了理解 Git 分支的实现方式,我们需要回顾一下 Git 是如何储存数据的。或许你还记得上篇博文中的内容,Git 保存的不是文件差异或者变化量,而是文件快照。
在 Git 中提交时,会保存一个提交(commit)对象,该对象包含一个指向暂存内容快照的指针,包含本次提交的作者等相关附属信息,包含零个或多个指向该提交对象的父对象指针:首次提交是没有直接祖先的,普通提交有一个祖先,由两个或多个分支合并产生的提交则有多个祖先。
为直观起见,我们假设在工作目录中有三个文件,准备将它们暂存后提交。暂存操作会对每一个文件计算校验和(即前文提到的 SHA-1 哈希字串),然后把当前版本的文件快照保存到 Git 仓库中(Git 使用 blob 类型的对象存储这些快照),并将校验和加入暂存区域:
1
$ git add .
2
$ git commit -m 'initial commit of my project'
当使用 git commit 新建一个提交对象前,Git 会先计算每一个子目录(本例中就是项目根目录)的校验和,然后在 Git 仓库中将这些目录保存为树(tree)对象。之后 Git 创建的提交对象,除了包含相关提交信息以外,还包含着指向这个树对象(项目根目录)的指针,如此它就可以在将来需要的时候,重现此次快照的内容了。
现在,Git 仓库中有五个对象:三个表示文件快照内容的 blob 对象;一个记录着目录树内容及其中各个文件对应 blob 对象索引的 tree 对象;以及一个包含指向 tree 对象(根目录)的索引和其他提交信息元数据的 commit 对象。概念上来说,仓库中的各个对象保存的数据和相互关系看起来如图所示:

单个提交对象在仓库中的数据结构,作些修改后再次提交,那么这次的提交对象会包含一个指向上次提交对象的指针(译注:即下图中的 parent 对象)。两次提交后,仓库历史会变成下图的样子:

现在来谈分支。Git 中的分支,其实本质上仅仅是个指向 commit 对象的可变指针。Git 会使用 master 作为分支的默认名字。在若干次提交后,你其实已经有了一个指向最后一次提交对象的 master 分支,它在每次提交的时候都会自动向前移动。

分支其实就是从某个提交对象往回看的历史
那么,Git 又是如何创建一个新的分支的呢?答案很简单,创建一个新的分支指针。比如新建一个 testing 分支,可以使用 git branch 命令:
1
$ git branch testing
这会在当前 commit 对象上新建一个分支指针

那么,Git 是如何知道你当前在哪个分支上工作的呢?其实答案也很简单,它保存着一个名为 HEAD 的特别指针。请注意它和你熟知的许多其他版本控制系统(比如 Subversion 或 CVS)里的 HEAD 概念大不相同。在 Git 中,它是一个指向你正在工作中的本地分支的指针(译注:将 HEAD 想象为当前分支的别名。)。运行 git branch 命令,仅仅是建立了一个新的分支,但不会自动切换到这个分支中去,所以在这个例子中,我们依然还在 master 分支里工作。

要切换到其他分支,可以执行 git checkout 命令。我们现在转换到新建的 testing 分支:
1
$ git checkout testing
这样 HEAD 就指向了 testing 分支。

这样的实现方式会给我们带来什么好处呢?好吧,现在不妨再提交一次:

每次提交后 HEAD 随着分支一起向前移动。非常有趣,现在 testing 分支向前移动了一格,而 master 分支仍然指向原先 git checkout 时所在的 commit 对象。现在我们回到 master 分支看看:
1
$ git checkout master

HEAD 在一次 checkout 之后移动到了另一个分支
这条命令做了两件事。它把 HEAD 指针移回到 master 分支,并把工作目录中的文件换成了 master 分支所指向的快照内容。也就是说,现在开始所做的改动,将始于本项目中一个较老的版本。它的主要作用是将 testing 分支里作出的修改暂时取消,这样你就可以向另一个方向进行开发。现在我们在 master 分支上在提交一次,现在的情况就变成了下图。

现在我们的项目提交历史产生了分叉,因为刚才我们创建了一个分支,转换到其中进行了一些工作,然后又回到原来的主分支进行了另外一些工作。这些改变分别孤立在不同的分支里:我们可以在不同分支里反复切换,并在时机成熟时把它们合并到一起。而所有这些工作,仅仅需要 branch 和 checkout 这两条命令就可以完成。
由于 Git 中的分支实际上仅是一个包含所指对象校验和(40 个字符长度 SHA-1 字串)的文件,所以创建和销毁一个分支就变得非常廉价。说白了,新建一个分支就是向一个文件写入 41 个字节(外加一个换行符)那么简单,当然也就很快了。
这和大多数版本控制系统形成了鲜明对比,它们管理分支大多采取备份所有项目文件到特定目录的方式,所以根据项目文件数量和大小不同,可能花费的时间也会有相当大的差别,快则几秒,慢则数分钟。而 Git 的实现与项目复杂度无关,它永远可以在几毫秒的时间内完成分支的创建和切换。同时,因为每次提交时都记录了祖先信息(译注:即 parent 对象),将来要合并分支时,寻找恰当的合并基础(译注:即共同祖先)的工作其实已经自然而然地摆在那里了,所以实现起来非常容易。Git 鼓励开发者频繁使用分支,正是因为有着这些特性作保障。
2、分支的操作
先初始化一个 git 仓库,并且在仓库中添加一个文件。
1
[denggh :04:59 Desktop]$ mkdir tmp && cd tmp
2
3
[denggh :05:08 tmp]$ git init
4
Initialized empty Git repository in C:/Users/denggh/Desktop/tmp/.git/
5
6
[denggh :05:11 tmp] (master)$ echo 'hi git' > hi.txt
7
8
[denggh :05:44 tmp] (master)$ ll
9
total 1
10
-rw-r--r-- 1 denggh 197121 7 5月 4 18:05 hi.txt
11
12
[denggh :05:47 tmp] (master)$ git add .
13
warning: LF will be replaced by CRLF in hi.txt.
14
The file will have its original line endings in your working directory
15
16
[denggh :05:50 tmp] (master)$ git commit -m '第一次提交文件到git仓库'
17
[master (root-commit) def6362] 第一次提交文件到git仓库
18
1 file changed, 1 insertion(+)
19
create mode 100644 hi.txt
20
2.1、分支查看:git branch
1
[denggh :11:21 tmp] (master)$ git branch
2
* master
3
4
[denggh :11:26 tmp] (master)$ git branch -v
5
* master def6362 第一次提交文件到git仓库
6
7
// git branch 是分支操作命令,第一个是查看分支,第二个是查询分支的commit id 及最后一次提交信息
2.2、分支创建:git branch <分支名称>
1
[denggh :11:40 tmp] (master)$ git branch dev
2
3
[denggh :15:28 tmp] (master)$ git branch -v
4
dev def6362 第一次提交文件到git仓库
5
* master def6362 第一次提交文件到git仓库

执行创建分支的命令之后,我们发现 refs/heads 目录下多了一个 dev文件,我们这个时候查看 dev 与 master 文件内容。
1
[denggh :18:07 tmp] (master)$ cat .git/refs/heads/master
2
def6362f790dccbf5d04b68034ef8f7c12d9bf3e
3
4
[denggh :19:00 tmp] (master)$ cat .git/refs/heads/dev
5
def6362f790dccbf5d04b68034ef8f7c12d9bf3e
所以从中我们可以理解,创建分支只是创建了一个指针,想必这个时候读者对上节的内容应该更能理解了:

2.3、分支切换:git checkout <分支名称>
1
[denggh :25:05 tmp] (master)$ git checkout dev
2
Switched to branch 'dev'

分支切换之后,我们发现 HEAD 文件的内容发生了变化,比对前后的内容改变的内容如下:
1
// 切换前 HEAD 内容
2
ref: refs/heads/master
3
4
// 切换后 HEAD 内容
5
ref: refs/heads/dev
我们发现 HEAD 里面存的是一个文件的位置,他指向的是 refs/heads 目录下的某个文件,而 refs/heads 目录下文件的内容又是一个指针,所以这个时候我们更能清楚的理解上节的内容了。

2.4、删除分支:git branch -d <分支名称>
1
[denggh :25:11 tmp] (dev)$ git branch -d dev
2
error: Cannot delete branch 'dev' checked out at 'C:/Users/denggh/Desktop/tmp'
3
// 这里报错了,所以删除分支时不能在被删除的那个分支上
4
5
[denggh :33:27 tmp] (dev)$ git checkout master
6
Switched to branch 'master'
7
8
[denggh :33:44 tmp] (master)$ git branch -d dev
9
Deleted branch dev (was def6362).
10
11
[denggh :33:59 tmp] (master)$ git branch
12
* master
13
14
15
// 有时候删除分支时会报错,因为被删除的分支上有文件变化,且没有合并,如果删除分支就会丢失修改内容
16
// 如果确认修改内容不要了,使用 -D 就可以了。
17
[denggh :36:52 tmp] (master)$ git branch -d dev
18
error: The branch 'dev' is not fully merged.
19
If you are sure you want to delete it, run 'git branch -D dev'.
20
21
[denggh :36:59 tmp] (master)$ git branch -D dev
22
Deleted branch dev (was 165f0e6).
23
24
[denggh :37:07 tmp] (master)$ git branch
25
* master
26
2.5、分支重命名:git branch -m <分支名称> <新名称>
1
[denggh :40:26 tmp] (master)$ git branch de
2
3
[denggh :40:29 tmp] (master)$ git branch
4
de
5
* master
6
7
[denggh :40:32 tmp] (master)$ git branch -m de dev
8
9
[denggh :40:38 tmp] (master)$ git branch
10
dev
11
* master
2.6、分支合并:git merge -m '<message>' <分支名称>
1
[denggh :43:37 tmp] (master)$ git branch
2
dev
3
* master
4
5
[denggh :43:41 tmp] (master)$ git merge -m '合并 dev 分支到 master 上' dev
6
Updating def6362..4ab5312
7
Fast-forward (no commit created; -m option ignored)
8
hi.txt | 2 +-
9
1 file changed, 1 insertion(+), 1 deletion(-)
如上,现在有两个分支 master 和 dev。现在要把 dev 分支上内容合并到 master 上。则切换到 master 上执行名称:
1
$ git merge -m '合并 dev 分支到 master 上' dev
2.7、分支合并冲突解决
1
[denggh :48:18 tmp] (master)$ git merge -m '合并 dev 分支到 master 上' dev
2
Auto-merging hi.txt
3
CONFLICT (content): Merge conflict in hi.txt
4
Automatic merge failed; fix conflicts and then commit the result.
5
6
// 上面的意思是把 dev 分支合并到 master 上时,在自动合并 hi.txt 的时候发生了冲突。需要我们手动解决冲突然后在进行提交。
当两个分支都进行了不同的开发时,尤其是修改了同一个文件之后。在进行合并的时候,就会比较容易导致冲突。现在我们看一看冲突文件 hi.txt 的内容
1
<<<<<<< HEAD
2
hi master!
3
=======
4
hi dev!
5
>>>>>>> dev
冲突地方以 ======= 当做分隔符号
<<<<<<< HEAD 和 ======= 之间的内容也就是 1 和 3 行之间的内容第 2 行,是 HEAD 所指的分支上的内容,也就是 master 分支内容
>>>>>>> dev 和 ======= 之间的内容也就是 3 和 5 行之间的内容第 4 行,是 dev 所指的分支上的内容,也就是 dev 分支内容
根据实际需求,手动修改文件
1
hi dev!
文件修改之后,我们在命令行中查看一下状态
1
[denggh :55:22 tmp] (master|MERGING)$ git status
2
On branch master
3
You have unmerged paths.
4
(fix conflicts and run "git commit")
5
(use "git merge --abort" to abort the merge)
6
7
Unmerged paths:
8
(use "git add <file>..." to mark resolution)
9
both modified: hi.txt
10
11
no changes added to commit (use "git add" and/or "git commit -a")
我们执行 git status 发现提示和之前都不一样了。这里提示的意思是:在 master 分支上你有存在未合并的文件,没有合并的文件的是 hi.txt 。如果你解决了冲突,就使用 git add <file> 命令来标记冲突已经解决了。
1
[denggh :55:49 tmp] (master|MERGING)$ git add hi.txt
2
3
[denggh :57:24 tmp] (master|MERGING)$ git commit -m '合并dev分支上内容'
4
[master 4ff90a1] 合并dev分支上内容
5
6
[denggh :57:30 tmp] (master)$
像上面那样,使用 git add 命令标记冲突解决,然后 git commit 提交即可。