Pro Git研读精选:分支介绍和分支合并

简介: Pro Git研读精选:分支介绍和分支合并

本文摘取自《Pro Git》第三章的第一节和第二节,由本人进行适当修改和删减。


何谓分支


为了理解 Git 分支的实现方式,我们需要回顾一下 Git 是如何储存数据的。Git 保存的不是文件差异或者变化量,而只是一系列文件快照。


 Git 中提交时,会保存一个提交(commit)对象,该对象包含一个指向暂存内容快照的指针,包含本次提交的作者等相关附属信息,包含零个或多个指向该提交对象的父对象指针:首次提交是没有直接祖先的,普通提交有一个祖先,由两个或多个分支合并产生的提交则有多个祖先。


为直观起见,我们假设在工作目录中有三个文件,准备将它们暂存后提交(代码如下)。暂存操作会对每一个文件计算校验和(即第一章中提到的 SHA-1 哈希字串),然后把当前版本的文件快照保存到 Git 仓库中(Git 使用 blob 类型的对象存储这些快照),并将校验和加入暂存区域:


$ git add README test.rb LICENSE

$ git commit -m 'initial commit of my project'

 

当使用 git commit 新建一个提交对象前,Git 会先计算每一个子目录(本例中就是项目根目录)的校验和,然后在 Git 仓库中将这些目录保存为树(tree)对象。之后 Git 创建的提交对象,除了包含相关提交信息以外,还包含着指向这个树对象(项目根目录)的指针,如此它就可以在将来需要的时候,重现此次快照的内容了。


现在,Git 仓库中有五个对象:三个表示文件快照内容的 blob 对象(图3-13个红色方块);一个记录着目录树内容及其中各个文件对应 blob 对象索引的 tree 对象(图3-1的蓝色方块);以及一个包含指向 tree 对象(根目录)的索引和其他提交信息元数据的 commit 对象(图3-1的绿色方块)。概念上来说,仓库中的各个对象保存的数据和相互关系看起来如图 3-1 所示:

image.png

 

 3-1. 单个提交对象在仓库中的数据结构

 

进行一些修改后再次提交,那么这次的提交对象会包含一个指向上次提交对象的指针(即下图中的 parent 对象)。两次提交后,仓库历史会变成图 3-2 的样子:最右边的为最新的提交,包含一个指向上次提交对象的指针。

 image.png

 3-2. 多个提交对象之间的链接关系

 

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

image.png


 3-3. 分支其实就是从某个提交对象往回看的历史

 

那么,Git 又是如何创建一个新的分支的呢?答案很简单,创建一个新的分支指针。比如新建一个 testing 分支,可以使用 git branch 命令:

$ git branch testing

 

这会在当前 commit 对象上新建一个分支指针(见图 3-4)。

image.png


 3-4. 多个分支指向提交数据的历史

 

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

image.png


 3-5. HEAD 指向当前所在的分支

 

要切换到其他分支,可以执行 git checkout 命令。我们现在转换到新建的 testing 分支:

$ git checkout testing

这样 HEAD 就指向了 testing 分支(见图3-6)。

image.png


 3-6. HEAD 在你转换分支时指向新的分支

 

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

$ git commit -a -m 'made a change'

 

 3-7 展示了提交后的结果。

image.png


 3-7. 每次提交后 HEAD 随着分支一起向前移动

 

非常有趣,现在 testing 分支向前移动了一格,而 master 分支仍然指向原先git checkout 时所在的commit 对象。现在我们回到 master 分支看看:

 

$ git checkout master

 

 3-8 显示了结果。

image.png


 3-8. HEAD 在一次checkout 之后移动到了另一个分支

 

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

 

我们作些修改后再次提交:

$ vim test.rb

$ git commit -a -m 'made other changes'

 

现在我们的项目提交历史产生了分叉(如图 3-9 所示),因为刚才我们创建了一个分支,转换到其中进行了一些工作,然后又回到原来的主分支进行了另外一些工作。这些改变分别孤立在不同的分支里:我们可以在不同分支里反复切换,并在时机成熟时把它们合并到一起。而所有这些工作,仅仅需要branch checkout 这两条命令就可以完成。

image.png


 3-9. 不同流向的分支历史

 

由于 Git 中的分支实际上仅是一个包含所指对象校验和(40 个字符长度 SHA-1 字串)的文件,所以创建和销毁一个分支就变得非常廉价。说白了,新建一个分支就是向一个文件写入 41 个字节(外加一个换行符)那么简单,当然也就很快了。

这和大多数版本控制系统形成了鲜明对比,它们管理分支大多采取备份所有项目文件到特定目录的方式,所以根据项目文件数量和大小不同,可能花费的时间也会有相当大的差别,快则几秒,慢则数分钟。而 Git 的实现与项目复杂度无关,它永远可以在几毫秒的时间内完成分支的创建和切换。同时,因为每次提交时都记录了祖先信息(译注:即 parent 对象),将来要合并分支时,寻找恰当的合并基础(译注:即共同祖先)的工作其实已经自然而然地摆在那里了,所以实现起来非常容易。Git 鼓励开发者频繁使用分支,正是因为有着这些特性作保障。

 

 

关于分支的小例子:


在任意一个仓库新建一个test分支,切换到test分支后,在仓库里新建一个test3.txt并进行提交,当你切换回master分支时,test3.txt便消失了,如下图,说明此时master分支正处于一个比较老的版本。

image.png

image.png

 

分支的合并


现在让我们来看一个简单的分支与合并的例子,实际工作中大体也会用到这样的工作流程:


  1. 开发某个网站。
  2. 为实现某个新的需求,创建一个分支。
  3. 在这个分支上开展工作。


假设此时,你突然接到一个电话说有个很严重的问题需要紧急修补,那么可以按照下面的方式处理:


  1. 返回到原先已经发布到生产服务器上的分支。
  2. 为这次紧急修补建立一个新分支,并在其中修复问题。
  3. 通过测试后,回到生产服务器所在的分支,将修补分支合并进来,然后再推送到生产服务器上。
  4. 切换到之前实现新需求的分支,继续工作。

 

首先,我们假设你正在项目中愉快地工作,并且已经提交了几次更新(见图3-10)。

image.png

 3-10. 一个简短的提交历史

 

现在,你决定要修补问题追踪系统上的 #53 问题。顺带说明下,Git 并不同任何特定的问题追踪系统打交道。这里为了说明要解决的问题,才把新建的分支取名为 iss53。要新建并切换到该分支,

image.png

这条命令相当于执行下面这两条命令

$ git branch iss53

$ git checkout iss53

 

 3-11 示意该命令的执行结果。

image.png


 3-11. 创建了一个新分支的指针

 

接着你开始尝试修复问题,在提交了若干次更新后,iss53 分支的指针也会随着向前推进,因为它就是当前分支(换句话说,当前的 HEAD 指针正指向 iss53,见图 3-12):

$ vim index.html

$ git commit -a -m 'added a new footer [issue 53]'

image.png


 3-12. iss53 分支随工作进展向前推进

 

突然,此时你接到了一个电话,需要紧急解决一个问题。于是我们创建一个紧急修补分支 hotfix 来开展工作,直到搞定(见图 3-13):

image.png

下面两行代码是在hotfix分支下进行的操作(此处只用这两行举例)

$ vim index.html

$ git commit -a -m 'fixed the broken email address'

image.png

 


 3-13. hotfix 分支是从 master 分支所在点分化出来的

 

有必要作些测试,确保修补是成功的,然后回到 master 分支并把它合并进来,然后发布到生产服务器。用 git merge 命令来进行合并:

image.png

请注意,合并时出现了“Fast forward”的提示。由于当前 master 分支所在的提交对象是要并入的 hotfix 分支的直接上游,Git 只需把 master 分支指针直接右移。换句话说,如果顺着一个分支走下去可以到达另一个分支的话,那么 Git 在合并两者时,只会简单地把指针右移,因为这种单线的历史分支不存在任何需要解决的分歧,所以这种合并过程可以称为快进(Fast forward)。

现在最新的修改已经在当前 master 分支所指向的提交对象中了,可以部署到生产服务器上去了(见图 3-14)。

image.png


 3-14. 合并之后,master 分支和 hotfix 分支指向同一位置。

 

由于当前 hotfix 分支和 master 都指向相同的提交对象,所以 hotfix 已经完成了历史使命,可以删掉了。使用 git branch -d 选项执行删除操作:

image.png


解决完紧急问题后,我们继续回到iss53分支上继续之前的工作。

image.png

image.png

 


3-15. iss53 分支可以不受影响继续推进。

 

不用担心之前hotfix分支的修改内容尚未包含到iss53 中来。如果确实需要纳入此次修补,可以用 git merge master master 分支合并到iss53;或者等iss53完成之后,再将iss53分支中的更新并入master

 

在问题 #53 相关的工作完成之后,可以合并回 master分支。实际操作同前面合并hotfix分支差不多,只需回到master分支,运行git merge命令指定要合并进来的分支:

image.png

 

请注意,这次合并操作的底层实现,并不同于之前 hotfix的并入方式。因为这次你的开发历史是从更早的地方开始分叉的。由于当前master分支所指向的提交对象(C4)并不是iss53 分支的直接祖先,Git 不得不进行一些额外处理。就此例而言,Git 会用两个分支的末端(C4 C5)以及它们的共同祖先(C2)进行一次简单的三方合并计算。图 3-16 用红框标出了 Git 用于合并的三个提交对象:

image.png


 3-16. Git 为分支合并自动识别出最佳的同源合并点。

 

这次,Git 没有简单地把分支指针右移,而是对三方合并后的结果重新做一个新的快照,并自动创建一个指向它的提交对象(C6)(见图 3-17)。这个提交对象比较特殊,它有两个祖先(C4 C5)。

值得一提的是 Git 可以自己裁决哪个共同祖先才是最佳合并基础;这和 CVS Subversion1.5 以后的版本)不同,它们需要开发者手工指定合并基础。所以此特性让 Git 的合并操作比其他系统都要简单不少。

image.png


 3-17. Git 自动创建了一个包含了合并结果的提交对象。

 

既然之前的工作成果已经合并到master了,那么iss53也就没用了。你可以就此删除它,并在问题追踪系统里关闭该问题。

image.png

 

 

遇到冲突时的分支合并


有时候合并操作并不会如此顺利。如果在不同的分支中都修改了同一个文件的同一部分,Git 就无法干净地把两者合到一起(译注:逻辑上说,这种问题只能由人来裁决。)。如果你在解决问题 #53 的过程中修改了 hotfix 中修改的部分,将得到类似下面的结果:


$ git merge iss53
    Auto-merging index.html
    CONFLICT (content): Merge conflict in index.html
    Automatic merge failed; fix conflicts and then commit the result.

Git 作了合并,但没有提交,它会停下来等你解决冲突。要看看哪些文件在合并时发生冲突,可以用 git status 查阅:

[master*]$ git status
    index.html: needs merge
    # On branch 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)
    #
    # unmerged: index.html
    #


任何包含未解决冲突的文件都会以未合并(unmerged)的状态列出。Git会在有冲突的文件里加入标准的冲突解决标记,可以通过它们来手工定位并解决这些冲突。可以看到此文件包含类似下面这样的部分:


<<<<<<< HEAD:index.html
    <div id="footer">contact : email.support@github.com</div>
    =======
    <div id="footer">
    please contact us at support@github.com
    </div>
    >>>>>>> iss53:index.html


可以看到 ======= 隔开的上半部分,是 HEAD(即 master 分支,在运行 merge 命令时所切换到的分支)中的内容,下半部分是在 iss53 分支中的内容。解决冲突的办法无非是二者选其一或者由你亲自整合到一起。比如你可以通过把这段内容替换为下面这样来解决:

<div id="footer">
    please contact us at email.support@github.com
    </div>

这个解决方案各采纳了两个分支中的一部分内容,而且我还删除了 <<<<<<<=======  >>>>>>> 这些行。在解决了所有文件里的所有冲突后,运行 git add 将把它们标记为已解决状态(译注:实际上就是来一次快照保存到暂存区域。)。因为一旦暂存,就表示冲突已经解决。如果你想用一个有图形界面的工具来解决这些问题,不妨运行 git mergetool,它会调用一个可视化的合并工具并引导你解决所有冲突:


$ git mergetool
    merge tool candidates: kdiff3 tkdiff xxdiff meld gvimdiff opendiff emerge vimdiff
    Merging the files: index.html
    Normal merge conflict for 'index.html':
    {local}: modified
    {remote}: modified
    Hit return to start merge resolution tool (opendiff):

如果不想用默认的合并工具(Git 为我默认选择了 opendiff,因为我在 Mac 上运行了该命令),你可以在上方"merge tool candidates"里找到可用的合并工具列表,输入你想用的工具名。我们将在第七章讨论怎样改变环境中的默认值。

退出合并工具以后,Git 会询问你合并是否成功。如果回答是,它会为你把相关文件暂存起来,以表明状态为已解决。

再运行一次 git status 来确认所有冲突都已解决:

$ git status
    # On branch master
    # Changes to be committed:
    # (use "git reset HEAD <file>..." to unstage)
    #
    # modified: index.html
    #

如果觉得满意了,并且确认所有冲突都已解决,也就是进入了暂存区,就可以用 git commit 来完成这次合并提交。提交的记录差不多是这样:

Merge branch 'iss53'
    Conflicts:
    index.html
    #
    # It looks like you may be committing a MERGE.
    # If this is not correct, please remove the file
    # .git/MERGE_HEAD
    # and try again.
    #

如果想给将来看这次合并的人一些方便,可以修改该信息,提供更多合并细节。比如你都作了哪些改动,以及这么做的原因。有时候裁决冲突的理由并不直接或明显,有必要略加注解。

 


相关文章
|
11天前
|
开发工具 git
git学习四:常用命令总结,包括创建基本命令,分支操作,合并命令,压缩命令,回溯历史命令,拉取命令
这篇文章是关于Git常用命令的总结,包括初始化配置、基本提交、分支操作、合并、压缩历史、推送和拉取远程仓库等操作的详细说明。
47 1
git学习四:常用命令总结,包括创建基本命令,分支操作,合并命令,压缩命令,回溯历史命令,拉取命令
|
17天前
|
开发工具 git 开发者
关于git 解决分支冲突问题(具体操作,包含截图,教你一步一步解决冲突问题)
本文通过具体操作和截图,详细讲解了如何在Git中解决分支冲突问题,包括如何识别冲突、手动解决冲突代码、提交合并后的代码,以及推送到远程分支。
76 3
关于git 解决分支冲突问题(具体操作,包含截图,教你一步一步解决冲突问题)
|
1月前
|
缓存 开发工具 git
Git创建分支以及合并分支
在Git中,创建分支使用`git branch [branch_name]`,切换分支使用`git checkout [branch_name]`。修改文件后,通过`git add [file]`添加到暂存区,然后`git commit`提交到本地仓库。如果是新建分支的第一次推送,使用`git push origin [branch_name]`推送到远程仓库,之后可以简化为`git push`。合并分支时,使用`git merge [branch_name]`将指定分支的更改合并到当前分支。
35 2
Git创建分支以及合并分支
|
12天前
|
开发工具 git
Git分支使用总结
Git分支使用总结
19 1
|
29天前
|
存储 Linux 开发工具
Git基础命令,分支,标签的使用【快速入门Git】
本文详细介绍了Git版本控制系统的基础概念和常用命令,包括工作区、暂存区和版本库的区别,文件状态的变化,以及如何进行文件的添加、提交、查看状态、重命名、删除、查看提交历史、远程仓库操作和分支管理,还涉及了Git标签的创建和删除,旨在帮助读者快速入门Git。
Git基础命令,分支,标签的使用【快速入门Git】
|
1月前
|
测试技术 开发工具 git
掌握 Git 分支策略:提升你的版本控制技能
在现代软件开发中,版本控制至关重要,Git 作为最流行的分布式版本控制系统,其分支管理策略对于高效协作和代码维护尤为重要。本文介绍了几种常用的 Git 分支策略,包括主线开发模型、功能分支模型、Gitflow 工作流和 Forking 工作流,并探讨了如何根据项目需求选择合适的分支模型。通过保持 `master` 分支稳定、及时合并清理分支、使用命名规范、利用 Pull Request 进行代码审查及自动化测试等最佳实践,可以显著提升团队协作效率和软件质量。掌握这些策略将帮助开发者更好地管理代码库,加快开发流程。
|
2月前
|
开发工具 git 开发者
|
2月前
|
项目管理 开发工具 git
|
2月前
|
存储 小程序 安全
【技巧】git stash用的好,切换分支随便搞
本文详细介绍了 Git 中的 `git stash` 命令,帮助你在切换分支时临时保存未提交的更改。通过实际操作示例,展示了如何使用 `git stash` 的各种命令,如 `save`、`list`、`apply` 等。无论你是初学者还是有一定经验的开发者,都能从中受益。
53 0
【技巧】git stash用的好,切换分支随便搞
|
2月前
|
Shell 开发工具 git

相关实验场景

更多