0%

Pro Git(3):Git 分支

几乎所有的版本控制系统都以某种形式支持分支。使用分支意味着你可以把你的工作从开发主线上分离开来,以免影响开发主线。在很多版本控制系统中,这是一个略微低效的过程:常常需要完全创建一个源代码目录的副本。对于大项目来说,这样的过程会耗费很多时间。

Git 的分支模型可以认为是它的 必杀技特性。Git 处理分支的方式可谓是难以置信的轻量,创建新分支这一操作几乎能在瞬间完成,并且在不同分支之间的切换操作也是一样便捷。与许多其它版本控制系统不同,Git 鼓励在工作流程中频繁地使用分支与合并,哪怕一天之内进行许多次。理解和精通这一特性,你便会意识到 Git 是如此的强大而又独特,并且从此真正改变你的开发方式。

分支简介

Git 保存的不是文件的变化或者差异,而是一系列不同时刻的快照。在进行提交操作时,Git 会保存一个提交对象(commit object)。该提交对象会包含一个指向暂存内容快照的指针。不仅如此,该提交对象还包含了作者的姓名和邮箱、提交时输入的信息以及指向它的父对象的指针。首次提交产生的提交对象没有父对象,普通提交操作产生的提交对象有一个父对象, 而由多个分支合并产生的提交对象有多个父对象

假设一个工作目录中里面包含 3 个被暂存的文件。暂存操作会为每一个文件计算校验和,然后会把当前版本的文件快照保存到 Git 仓库中(Git 使用 blob 对象来保存它们)。当使用 git commit 进行提交操作时,Git 会先计算每一个子目录的校验和,然后在 Git 仓库中将这些校验和保存为树对象。随后,Git 便会创建一个提交对象, 它除了包含上面提到的那些信息 外,还包含指向这个树对象(项目根目录)的指针。

现在,Git 仓库中有五个对象:三个 blob 对象(保存着文件快照)、一个树对象(记录着目录结构和 blob 对象索引)以及一个提交对象(包含着指向前述树对象的指针和所有提交信息)。如下所示:

做些修改后再次提交,那么这次产生的提交对象会包含一个指向上次提交对象(父对象)的指针。如下所示:

Git 的分支,其实本质上仅仅是指向提交对象的可变指针。Git 的默认分支名字是 master。在多次提交操作之后,你其实已经有一个指向最后那个提交对象的 master 分支。 master 分支会在每次提交时自动向前移动。

Git 的 master 分支并不是一个特殊分支。它就跟其它分支完全没有区别。之所以几乎每一个仓库都有 master 分支,是因为 git init 命令默认创建它,并且大多数人都懒得去改动它。

如下展示了分支及其提交历史的关系:

分支创建

在 Git 中创建分支只是为你创建了一个可以移动的新的指针。使用 git branch 命令创建分支,如下所示,这会在当前所在的提交对象上创建一个指针:

1
# git branch testing

那么,Git 又是怎么知道当前在哪一个分支上呢?也很简单,它有一个名为 HEAD 的特殊指针。请注意它和许多其它版本控制系统(如 Subversion 或 CVS)里的 HEAD 概念完全不同。在 Git 中,它是一个指针,指向当前所在的本地分支,可以认为它是当前分支的别名

可以使用 git log --decorate 命令查看各个分支当前所指的对象。

分支切换

需要注意,git branch 命令仅仅创建新的分支,并不会自动切换到新分支中去。要切换到一个已存在的分支,你需要使用 git checkout 命令。如果想创建一个新分支后立即切换过去,可以用 git checkout -b <newbranchname> 一条命令搞定。

1
2
# git checkout testing
Switched to branch 'testing'

这样 HEAD 就指向 testing 分支了。假如此时我们做一次简单修改,testing 分支向前移动了,但是 master 分支却没有。它仍然指向运行 git checkout 时所指的对象。

1
2
3
4
# git commit -a -m "made a change"
# cat LICENSE
TEST
TEST

我们重新切回到 master 分支上。此时 HEAD 指回 master 分支,同时将工作目录恢复成 master 分支所指向的快照内容。也就是说,此时做修改的话,项目将始于一个较旧的版本。本质上来讲,这就是忽略 testing 分支所做的修改,以便于向另一个方向进行开发。

1
2
3
4
# git checkout master
Switched to branch 'master'
# cat LICENSE
TEST

在切换分支时,一定要注意你工作目录里的文件会被改变。如果是切换到一个较旧的分支,你的工作目录会恢复到该分支最后一次提交时的样子。如果 Git 不能干净利落地完成这个任务,它将禁止切换分支。如下所示,在 master 分支上进行修改但是没有提交,如果此时尝试切换到 testing 分支,将得到错误:

1
2
3
4
5
6
# echo "another change" >> LICENSE
# git checkout testing
error: Your local changes to the following files would be overwritten by checkout:
LICENSE
Please, commit your changes or stash them before you can switch branches.
Aborting

你可以在不同分支间不断地来回切换和工作,并在时机成熟时将它们合并起来。而所有这些工作,你需要的命令只有 branch、checkout 和 commit。

如果项目的提交历史产生了分叉,可以简单地使用 git log 命令查看分叉历史。运行 git log --oneline --decorate --graph --all 输出你的提交历史、各个分支的指向以及项目的分支分叉情况。

1
2
3
4
5
6
7
# git log --oneline --decorate --graph --all
* bbbdc72 (HEAD, master) another change
| * bb974ab (testing) made a change
|/
* 3352c55 made a change
* e3a0da4 made a change
* fdb9795 initial commit

由于 Git 的分支实质上仅是包含所指对象校验和(长度为 40 的 SHA-1 值字符串)的文件,所以它的创建和销毁都异常高效。同时,由于每次提交都会记录父对象,所以寻找恰当的合并基础(即共同祖先)也是同样的简单和高效。这些高效的特性使得 Git 鼓励开发人员频繁地创建和使用分支。

分支的新建与合并

切换分支之前,要留意你的工作目录和暂存区里那些还没有被提交的修改,它可能会和你即将检出的分支产生冲突从而阻止 Git 切换到该分支。最好的方法是,在你切换分支之前,保持好一个干净的状态。

使用 git merge 命令完成分支之间的合并。当你试图合并两个分支时,如果顺着一个分支走下去能够到达另一个分支,那么 Git 在合并两者的时候,只会简单的将指针向前推进(指针右移),因为这种情况下的合并操作没有需要解决的分歧——这就叫做 快进(fast-forward)

如下所示,创建 hotfix 分支并进行改动,然后将 hotfix 分支 merge 到 master 分支:

1
2
3
4
5
6
# git checkout -b "hotfix"
Switched to a new branch 'hotfix'
# echo "new fix in hotfix branch" >> LICENSE
# git commit -a -m "fix in hotfix"
[hotfix a29f621] fix in hotfix
1 file changed, 1 insertion(+)
1
2
3
4
5
6
7
8
9
10
11
# git checkout master
Switched to branch 'master'
# git merge hotfix
Updating bbbdc72..a29f621
Fast-forward
LICENSE | 1 +
1 file changed, 1 insertion(+)
# cat LICENSE
TEST
another change
new fix in hotfix branch

使用带 -d 选项的 git branch 命令来删除分支:

1
2
# git branch -d hotfix
Deleted branch hotfix (was a29f621).

但是如果要 merge 的两个分支并不是直接的祖先关系,Git 不得不做一些额外的工作。如下所示,出现这种情况的时候,Git 会使用两个分支的末端所指的快照,(C4 和 C5)以及这两个分支的工作祖先(C2),做一个简单的三方合并。

和之前将分支指针向前推进所不同的是,Git 将此次三方合并的结果做了一个新的快照并且自动创建一个新的提交指向它。这个被称作一次合并提交,它的特别之处在于他有不止一个父提交,如下所示:

有时候合并操作不会如此顺利。如果你在两个不同的分支中,对同一个文件的同一个部分进行了不同的修改,Git 就没法干净的合并它们。如果在合并它们的时候产生合并冲突,此时 Git 会暂停下来,等待你去解决合并产生的冲突。你可以在合并冲突后的任意时刻使用 git status 命令来查看那些因包含合并冲突而处于未合并(unmerged)状态的文件:

1
2
3
4
# git merge testing
Auto-merging LICENSE
CONFLICT (content): Merge conflict in LICENSE
Automatic merge failed; fix conflicts and then commit the result.
1
2
3
4
5
6
7
8
9
10
11
12
# git status

# On branch master
# You have unmerged paths.
# (fix conflicts and run "git commit")
#
# Unmerged paths:
# (use "git add <file>..." to mark resolution)
#
# both modified: LICENSE
#
no changes added to commit (use "git add" and/or "git commit -a")

任何因包含合并冲突而有待解决的文件,都会以未合并状态标识出来。Git 会在有冲突的文件中加入标准的冲突解决标记,这样你可以打开这些包含冲突的文件然后手动解决冲突。

如下所示:

1
2
3
4
5
6
7
8
# cat LICENSE
TEST
<<<<<<< HEAD
another change
new fix in hotfix branch
=======
TEST
>>>>>>> testing

在这里例子中,======= 的上半部分为 HEAD 所指示分支(这里为master)在这个区段的内容,======= 的下半部分为 testing 分支在这个区段的内容。为了解决冲突,你必须选择使用由 ======= 分割的两部分中的一个,或者你也可以自行合并这些内容。

在解决完冲突并将 <<<<<<< , ======= , 和 >>>>>>> 这些行完全删除后,对每个文件使用 git add 命令来将其标记为冲突已解决。一旦暂存这些原本有冲突的文件,Git 就会将它们标记为冲突已解决。

如果你想使用图形化工具来解决冲突,你可以运行 git mergetool,该命令会为你启动一个合适的可视化合并工具,并带领你一步一步解决这些冲突。等你退出合并工具之后,Git 会询问刚才的合并是否成功。如果你回答是,Git 会暂存那些文件以表明冲突已解决。你可以再次运行 git status 来确认所有的合并冲突都已被解决。

如果你对结果感到满意,并且确定之前有冲突的的文件都已经暂存了,这时你可以输入 git commit 来完成合并提交。

分支管理

git branch 命令不只是可以创建与删除分支。如果不加任何参数运行它,会得到当前所有分支的一个列表。输出结果中的 * 字符代表现在检出的那一个分支(也就是说,当前 HEAD 指针所指向的分支)。

1
2
3
# git branch
* master
testing

如果需要查看每一个分支的最后一次提交,可以运行 git branch -v 命令。--merged--no-merged 这两个有用的选项可以过滤这个列表中已经合并或尚未合并到当前分支的分支。如果要查看哪些分支已经合并到当前分支,可以运行 git branch --merged

1
2
3
# git branch --merged
* master
testing

在这个列表中分支名字前没有 * 的分支通常可以使用 git branch -d 删除掉。因为你已经将它们的工作整合到了另一个分支,所以并不会失去任何东西。

查看所有包含未合并工作的分支,可以运行 git branch –no-merged:

1
2
3
4
# git checkout testing
Switched to branch 'testing'
# git branch --no-merged
master

如果包含了还未合并的工作,那么尝试使用 git branch -d 命令删除未合并分支时会失败。如果真的想要删除分支并丢掉那些工作,可以使用 -D 选项强制删除它。

上面描述的选项 –merged 和 –no-merged 会在没有给定提交或分支名作为参数时,分别
列出已合并或未合并到 当前分支 的分支。你总是可以提供一个附加的参数来查看其它分支的合并状态而不必检出它们。

分支开发工作流

接下来会介绍一些常见的利用分支进行开发的工作流程,而正是由于分支管理的便捷,才衍生出这些典型的工作模式。

长期分支

在整个项目开发周期的不同阶段,你可以同时拥有多个开放的分支,你可以定期地把某些主题分支合并入其他分支中。

许多使用 Git 的开发者都喜欢使用这种方式来工作,比如只在 master 分支上保留完全稳定的代码(有可能仅仅是已经发布或即将发布的代码)。他们还有一些名为 develop 或者 next 的平行分支,被用来做后续开发或者测试稳定性:这些分支不必保持绝对稳定,但是一旦达到稳定状态,它们就可以被合并入 master 分支了。

事实上我们刚才讨论的,是随着你的提交而不断右移的指针。稳定分支的指针总是在提交历史中落后一大截,而前沿分支的指针往往比较靠前。你可以用这种方法维护不同层次的稳定性。使用多个长期分支的方法并非必要,但是这么做通常很有帮助,尤其是当你在一个非常庞大或者复杂的项目中工作时。

主题分支

主题分支对任何规模的项目都适用。主题分支是一种短期分支,它被用来实现单一特性或其相关工作。

远程分支

请牢记,当你做这么多操作的时候,这些分支全部都存于本地。当你新建和合并分支的时候,所有这一切都只发生在你本地的 Git 版本库中:没有与服务器发生交互。

远程引用是对远程仓库的引用(指针),包括分支、标签等等。你可以通过 git ls-remote <remote> 来显式地获得远程引用的完整列表, 或者通过 git remote show <remote> 获得远程分支的更多信息。

远程跟踪分支是远程分支状态的引用。它们是你无法移动的本地引用。一旦你进行了网络通信, Git 就会为你移动它们以精确反映远程仓库的状态。请将它们看做书签,这样可以提醒你该分支在远程仓库中的位置就是你最后一次连接到它们的位置。

它们以 <remote>/<branch> 的形式命名。举个例子,假设你的网络里有一个在 git.ourcompany.com 的 Git 服务器。如果你从这里克隆,Git 的 clone 命令会为你自动将其命名为 origin,拉取它的所有数据,创建一个指向它的 master 分支的指针,并且在本地将其命名为 origin/master。Git 也会给你一个与 origin 的 master 分支在指向同一个地方的本地 master 分支,这样你就有工作的基础。

如果要与给定的远程仓库同步数据,运行 git fetch <remote> 命令。例如 git fetch origin 查找 origin 是哪一个服务器,从中抓取本地没有的数据,并且更新本地数据库,移动 origin/master 指针到更新之后的位置。

推送

当你想要公开分享一个分支时,需要将其推送到有写入权限的远程仓库上。本地的分支并不会自动与远程仓库同步:你必须显式地推送想要分享的分支。使用命令 git push <remote> <branch> 进行推送。如果本地分支和远程分支的命名不相同,可以使用 git push origin local_branch:remote_branch 的命令格式进行推送。

需要注意的是,是当抓取到新的远程跟踪分支时,本地不会自动生成一份可编辑的副本(拷贝)。也就是说只会在本地生成一个 <remote>/<branch> 远程分支引用。

使用命令 git merge <remote>/<branch> 可以将远程分支的工作合并到当前分支。如果想要在本地生成某个远程分支的副本,并将其建立在远程跟踪分支之上,可以使用如下命令:

1
git checkout -b branch remote/branch

该例子会给你一个用于工作的本地分支,并且起点位于 remote/branch。

跟踪分支

从一个远程跟踪分支检出一个本地分支会自动创建所谓的 跟踪分支,它跟踪的分支叫做 上游分支。跟踪分支是与远程分支有直接关系的本地分支。如果在一个跟踪分支上输入 git pull,Git 能自动地识别去哪个服务器上抓取

当克隆一个仓库时,它通常会自动地创建一个跟踪 origin/master 的 master 分支。运行 git checkout -b <branch> <remote>/<branch> 命令可以创建本地 branch 分支,并跟踪远程分支 <remote>/<branch>。该操作十分常用,所以 Git 提供了 –track 快捷方式:

1
git checkout --track origin/branch

由于这个操作太常用了,因此 Git 还有一种更快捷的方法。如果你尝试检出的分支不存在且刚好只有一个名字与之匹配的远程分支,那么 Git 就会为你创建一个跟踪分支。如果想要将本地分支与远程分支设置为不同的名字,那么可以使用 git checkout -b <branch> <remote>/<branch>

想要修改正在跟踪的上游分支,可以在任意时间使用 -u--set-upstream-to 选项运行 git branch 来显式地设置。

当设置好跟踪分支后,可以通过简写 @{upstream}@{u} 来引用它的上游分支。如果想要查看设置的所有跟踪分支,可以使用 git branch-vv 选项。这会将所有的本地分支列出来并且包含更多的信息,如每一个分支正在跟踪哪个远程分支,本地分支是否是领先、落后或是都有。

需要重点注意的一点是这些信息来自于你从每个服务器上最后一次抓取的数据。这个命令并没有连接服务器,它只会告诉你关于本地缓存的服务器数据。 如果想要统计最新的领先与落后数字,需要在运行此命令前抓取所有的远程仓库:

1
git fetch --all; git branch -vv

拉取

git fetch 命令从服务器上抓取本地没有的数据时,它并不会修改工作目录中的内容。它只会获取数据然后让你自己合并。 而 git pull 在大多数情况下它的含义是一个 git fetch 紧接着一个 git merge 命令。git pull 会查找当前分支所跟踪的服务器与分支,从服务器上抓取数据然后尝试合并那个远程分支。

删除远程分支

可以运行带有 --delete 选项的 git push 命令来删除一个远程分支。基本上这个命令做的只是从服务器上移除指针。Git 服务器通常会保留数据一段时间直到垃圾回收运行,所以如果不小心删除掉了,通常是很容易恢复的。

变基

在 Git 中整合来自不同分支的修改主要有两种方法:merge 以及 rebase。这里我们将介绍变基 rebase。

变基的基本操作

可以使用 rebase 命令将提交到某一分支上的所有修改都移至另一分支上。它的原理是首先找到这两个分支的最近共同祖先,然后对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件, 然后将当前分支指向目标基底,最后将之前另存为临时文件的修改依序应用在目标基底上。

如下所示,将 experiment 分支变基到 master(即当前分支 experiment、变基操作的目标基底分支 master):

1
2
git checkout experiment
git rebase master

也可以使用 git rebase <basebranch> <topicbranch> 命令形式,可以直接将主题分支 topicbranch 变基到目标分支 master 分支上,这样做能省去你先切换到 topicbranch 分支,再对其执行变基命令的多个步骤。

现在回到 master 分支,进行一次快进合并:

1
2
git checkout master
git merge experiment

merge 和 rebase 这两种整合方法的最终结果没有任何区别,但是变基使得提交历史更加整洁。你在查看一个经过变基的分支的历史记录时会发现,尽管实际的开发工作是并行的,但它们看上去就像是串行的一样,提交历史是一条直线没有分叉。

一般我们这样做的目的是为了确保在向远程分支推送时能保持提交历史的整洁。例如向某个其他人维护的项目贡献代码时。在这种情况下,你首先在自己的分支里进行开发,当开发完成时你需要先将你的代码变基到 origin/master 上,然后再向主项目提交修改。这样的话,该项目的维护者就不再需要进行整合工作,只需要 快进合并 便可。

请注意,无论是通过变基,还是通过三方合并,整合的最终结果所指向的快照始终是一样的,只不过提交历史不同罢了。变基是将一系列提交按照原有次序依次应用到另一分支上,而合并是把最终结果合在一起。

在对两个分支进行变基时,所生成的“重放”并不一定要在目标分支上应用,你也可以指定另外的一个分支进行应用。使用 git rebase 命令的 --onto 选项 可以实现这一点。例如:

1
git rebase --onto master server client

以上命令的意思是:取出 client 分支,找出它从 server 分支分歧之后的补丁,然后把这些补丁在 master 分支上重放一遍,让 client 看起来像直接基于 master 修改一样。

变基的风险

使用变基得遵守一条准则:

1
如果提交存在于你的仓库之外,而别人可能基于这些提交进行开发,那么不要执行变基。

变基操作的实质是丢弃一些现有的提交,然后相应地新建一些内容一样但实际上不同的提交。如果你已经将提交推送至某个仓库,而其他人也已经从该仓库拉取提交并进行了后续工作,此时,如果你用 git rebase 命令重新整理了提交并再次推送,你的同伴因此将不得不再次将他们手头的工作与你的提交进行整合,如果接下来你还要拉取并整合他们修改过的提交,事情就会变得一团糟。

如果你只对不会离开你电脑的提交执行变基,那就不会有事。如果你对已经推送过的提交执行变基,但别人没有基于它的提交,那么也不会有事。如果你对已经推送至共用仓库的提交上执行变基命令,并因此丢失了一些别人的开发所基于的提交,那你就有大麻烦了。

变基 vs 合并

总的原则是,只对尚未推送或分享给别人的本地修改执行变基操作清理历史,从不对已推送至别处的提交执行变基操作,这样,你才能享受到两种方式带来的便利