Git由浅入深之分支管理

1,129 阅读14分钟

几乎所有的版本控制系统都以分支的方式进行操作,分支是独立于项目主线的一条支线,我们可以在不影响主线代码的情况下,在分支下进行工作。对于传统的一些版本控制工具来说,我们通常需要花费比较多的时间拷贝主线代码,创建一个分支,并且对分支的管理效率也越来越不令人满意,而如今备受推崇的Git确实名副其实,Git中的分支非常轻量,我们可以随时随意创建任意数量的新分支,几乎感觉不到什么延时,而且对分支的操作也很高效,如,切换分支,暂存内容,分支合并,分支提交等。

Git分支的与众不同

上一节我们提到相对于其他大多数版本控制系统,Git分支是轻量且高效的,为什么呢?答案在前几篇已经有提到:传统的版本控制系统存储的数据是文件的变更,而Git则是存储一系列的文件快照(snapshot)。

Git分支的这些特性,使得分支对我们几乎没有什么限制,一般针对每一个功能或需求都可以随意创建分支,而在传统的版本控制系统,这样几乎是不现实的。

当我们向服务器提交数据时,Git会存储一个提交对象(commit object),这个存储对象包括一系列有用信息,详见上一篇中提交对象

Git主干分支(master)

master,有主人,大师的意思,在Git是通常作为主干分支,Git初始化仓库时,默认创建的分支名就是master,就像默认的远端主机别名是origin一样,大多数人不会修改它,这并不说明它与别的分支有什么区别,你可以随意修改名称。

分支类型

在Git中,除了默认的master主干分支,我们创建的每一个分支,一般可分为两种:

  • 长运行分支(Long-Running branch):与master并行,长期存在使用的分支,如用以测试项目稳定性或作为主分支;
  • 主题分支(topic branch):针对每一个需求或功能或bug而暂时创建的分支,一旦任务完成,即可能回收。

分支指针(HEAD)

Git中有一个HEAD指针,始终指向当前分支,如图可见,项目当前处在master分支,之前一共有三次提交:

提交历史记录

上图可见,第一行显示了当前项目所有分支,HEAD -> master表明当前所处分支为master,我们可以总结如下图:

branch

我们可以在项目根目录.git文件下找到一个HEAD文件:vi .git/HEAD,其内保存了指向当前分支最新提交的指针:

HEAD

该指针指向refs/heads/分支名文件,我们进入.git/refs/heads/目录,其下以分支名为文件名列出了所有分支:

当前分支head

我们查看当前分支文件,执行vi master:

当前分支ref

可以看到,其内存储的就是当前分支的最新一次提交对象ID。

创建分支(git branch, git checkout -b)

接下来,假设有一个需求A,我们创建一个分支work-a:

git checkout -b 分支名

-b参数声明为创建新分支

创建新分支

新分支HEAD

等价于以下两条指令:

git branch 分支名
git checkout 分支名

切换分支(git checkout)

git checkout 分支名

表示切换到该分支,上文提到指定-b配置即说明创建新分支。

注:在切换分支前,一定确保当前分支的修改已经提交或者缓存。

多分支并行

我们经常会遇到同时需要开发多个功能和需求,或者突然发现线上bug需要紧急处理,我们只需要提交当前分支修改,然后切换到主干分支,从其基础上再切出一个新分支fix-bug1:

提交修改

可以看到,在work-a分支上我们新增了一次提交:b287b8e22470b20cc98e6224a8023708b4cc6989

创建多分支

多分支结构图

现在我们在fix-bug1分支上修复bug后,进行提交:

提交及历史

可以看到,在fix-bug1分支上多了一个提交:ca270e6,现在整个结构就变成如下图:

结构图

合并分支(git merge)

我们已经修复了某bug或完成了功能开发,这时要做的是把代码并入主干,,当然一般公司或团队都需要经过代码审查,才能并入主干,在此略过不谈,分支合并相关指令:

git merge 分支名

该指令告诉Git将指定分支合并到当前分支,当然是可能出现冲突的,我们按照指示解决冲突,即可。

现在我们先切换到master分支,然后把fix-bug1分支并入主干:

合并分支

可以看到执行git merge指令后,状态信息显示:

  • 第一行Updating,告诉我们提交记录更新至ca270e6;
  • 第二行Fast-forward,即快速推进,说明Git直接将当前分支推进到指向新提交对象;
  • 后面是merge的内容信息

合并分之后结构

非快速推进合并(no fast-forward)

现在,我们再次创建一个分支fix-bug2,并进行几次修改提交:

多次提交

多次提交后,状态如下:

多次提交后结构

我们通过非快速推进方式合并分支进主干分支:

非快速推进方式合并

如上图,指定--no-ff即声明进行非快速推进合并,第二行的Merge made by the 'recursive' strategy表明通过非快速推进方式合并,我们发现除了分支上进行的提交记录外,Git创建了一个新的提交对象:7a657a,使用
git log --graph指令查看其信息:

提交记录图

如图,快速推进方式合并入主干的fix-bug1分支的提交记录直接并入主线,且不会创建新的提交对象;而对于非快速推进方式合并的fix-bug2分支,其提交历史也都保存,但是并未进入主线,而是保存了一条支线,同时,在主线上创建一个新的提交对象。

最后描述其结构如图:

非快速推进合并后结构

非快速推进与快速推进合并(fast-forward & no fast-forward)

从上例,对比一下两种方式合并分支的异同:

  • 提交对象都会保存;
  • 报存提交对象方式不同:快速推进方式是直接在主线(合并主分支)上,添加这些提交对象,即直接移动HEAD指针;而非快速推进方式是将提交对象保存在支线,然后在主线新建一个提交对象,修改HEAD指针及新建提交对象的指针,而且此新建提交对象有两个父提交对象(即有两个parent指针)。
  • 合并后分支指向不同:快速推进合并后,两个分支将同时指向最新提交对象,而非快速推进合并后,合并主分支指向新建的提交对象,另一分支指向不变。

我们查看一下新创建提交对象:

新创建提交对象

可以看到该提交对象中有两个指针指向父提交对象,一个指向主线中的父提交对象,一个指向fix-bug2分支合并而来的支线父提交对象。

三路合并(three-way merge)

除了之前提到的两种合并的情况,其实还存在这样一种情况,就是现在假如我完成了work-a分支的开发,需要将其并入主干,我们能看到当前master主干分支已经推进到7a6576了,而work-a分支指向b287b8,两者有共同祖先提交对象6d50f6,我们将其合并:

三路合并

上图第二行表明此次是通过非快速推进方式合并,我们查看提交对象记录图:

三路合并提交对象记录图

结构如图:

三路合并结构图

我们发现,三路合并结构是在需要合并的两个分支的最新提交对象的基础上,创建一个新提交对象(4ae14b),将合并主分支(即执行合并指令时,当前所处分支)的HEAD指针前移指向该提交对象,该提交对象有两个父提交对象,分别为合并前待合并分支的最新提交对象(即b287b8和7a657a)。

关于三路合并需要明确:

  • 三路合并其实是一种非快速推进合并方式;
  • 三路合并的前提是两个分支有共同祖先提交对象;

分支冲突(conflict)

在合并分支,不可避免会发生冲突,当我们在两个分支对同一文件同一部分进行不同修改后,发起合并时就会提示有冲突,假设我们有work-b分支,在其基础上切出新分支work-b-1,然后在两分支上分别对README.md文件同一部分进行不同修改并提交,然后将work-b-1分支合并到work-b分支:

合并时提示冲突

发现README.md文件有冲突,查看该文件:

查看冲突

如上图,列出了两个分支的不同修改,HEAD表明当前分支的修改内容,下面是work-b-1分支的修改,我们选择需要保留的内容,删除其他无关信息和内容,然后保存该文件,查看当前状态:

查看状态

根据提示,解决冲突后提交:

解决冲突

查看分支

对于创建过但并未删除的分支,我们可以查看分支列表,依然使用git branch指令,不传入任何参数:

分支列表

图中列出了所有分支,前面带星号的表示当前分支,当然我们还可以查看指明最新提交信息的分支列表,可以添加-v参数:

分支详情列表

筛选分支

除了可以查看所有分支列表,Git还支持筛选已合并或未合并至当前分支的所有分支:

  • --merged参数指明筛选已合并分支;
  • --no-merged参数指明筛选未合并分支。

筛选分支

删除分支(git branch -d)

当分支合并入主干后,也许我们不再需要那个分支了,我们需要将其删除,使用指令:

git branch -d 分支名

之前介绍到使用git branch是创建新分支,而指定-d参数,说明需要删除该分支:

删除分支

远程分支(remote branch)

我们注意到,前文所讲述的分支都是存在本地的,即本地分支,还需要了解远程分支,如[remote]/[branch]这种形式,表示是远端主机的某分支,关于远端主机详情请查看,其实远程分支和本地分支基本理论概念还是相同的,区别是有些指令不同而已:

git checkout -b test origin/develop

以上指令即从远程分支(远端主机origin上的develop分支)切出新的本地分支test分支。

跟踪分支(tracking branch)

前文已经介绍了本地分支和远程分支的概念及操作,那么这两类分支之间应该有某种关系将他们关联起来,本地项目都需要与远端主机仓库同步(pull & push),当我们从一个远程分支切出(创建)一个本地分支时,这个分支就叫跟踪分支(tracking branch),而远程分支叫上游分支(upstream branch)。

当我们克隆一个远端仓库时,会默认创建一个跟踪分支master,其上游分支就是远端主机别名/master

创建跟踪分支

创建跟踪分支指令如下:


    git checkout -b 本地分支名 远端主机别名/远程分支名

当然也可以不指定分支名,使用远程分支同名:


    git checkout --track 远端主机别名/远程分支名
修改跟踪关系

有时候,可能需要为本地分支设置其上游分支,添加-u参数:


    git branch -u 远端主机别名/远程分支名

以上指令就指明当前分支跟踪某远端主机的远程分支。

查看跟踪分支(git branch -vv)

使用以下指令查看分支的上游分支:


    git branch -vv

跟踪分支列表

上图输出信息第二行表明master分支跟踪远程origin/master分支,ahead 7表明本地有7个提交未推到服务器,其他分支不是跟踪分支,没有上游分支。

删除远程分支

对于不再需要的远程分支,是可以删除的:

git push origin --delete test

以上指令删除远端主机origin的test分支,但是在垃圾回收之前,Git服务器仍然会保留分支数据,我们可以很方便的恢复数据,之后会详细介绍。

变基(rebase)

Git中有两种方式整合不同分支的修改:第一种是前文介绍的合并(merge),另一种就是本节的主题变基(rebase)。

变基其实与前文提到的三路合并(three-way merge)颇有渊源:

三路合并结构图

如图work-a分支与主干master分支合并后,创建一个新提交对象,我们还可以通过变基完成两个分支的修改整合,由于work-a分支已合并到master分支,我们在work-a分支再提交一次修改e0ae7dc,然后我们将work-a分支对master分支进行变基:

分支变基

执行变基时,由于两个分支对同一文件同一部分进行了不同修改,会提示冲突,需要解决冲突,我们修改文件解决冲突,然后查看状态:

解决冲突后变基状态

上图,第一行rebase in progress; onto 4ae14b3说明当前分支针对4ae14b3快照进行变基,第三到第五行分别说明:

  • 第三行:解决冲突然后执行git rebase --continue指令继续变基;
  • 第四行:执行git rebase --skip指令,跳过解决冲突;
  • 第五行:执行git rebase --abort指令,终止变基,回到分支变基前状态。

下面第6到第八行说明:

  • 第七行:使用git reset HEAD <file>指令撤销某文件变更;
  • 第八行:使用git add <file>指令标记冲突为已解决状态。

最后一行no changes added to commit (use "git add" and/or "git commit -a"),说明尚未标记冲突,需要使用指令标记变更,在继续执行变基:

解决冲突继续变基

变基后历史记录图

如上图,变基后,在主线上创建新提交对象640b83,并修改work-a分支指针指向该提交对象:

变基后结构图

之后我们可以正常的合并:

变基后合并

如图,主线分支更新提交对象到640b83a,第二行Fast-forward说明此次合并属于快速推进合并方式,结构如下:

变基合并后结构图

三路合并与变基

基于上例,三路合并,整合修改变更后会保留分支的原始提交记录,新创建提交对象有两个父提交对象,一个在主线上,一个在待合并分支上;而变基则不能保留待合并分支的原始提交记录,主线上新建的提交对象只有一个位于主线上的父提交对象。更多变基相关内容计划单独出文介绍。

至于到底选用哪种方式整合变更,变基还是合并,这个一直有争论,没有哪一种方式绝对合理,我们只需要把握一个原则:无论变基还是合并,你应该只操作本地历史记录,任何已经推到服务器并入主干的内容和提交历史不应该更改。

更多相关阅读请移步