Git 的分支模型被称为它的**「必杀技特性」**,也正因为这一特性,使得 Git 从众多版本控制系统中脱颖而出。

分支操作

因为 Git 保存的不是文件的变化或者差异,而是一系列不同时刻的 快照 。

所以在进行提交操作时,Git 会保存一个提交对象(commit object)。该提交对象会包含一个指向暂存内容快照的指针,且还包含了作者的姓名和邮箱提交时输入的信息以及指向它的父对象的指针。 首次提交产生的提交对象没有父对象,普通提交操作产生的提交对象有一个父对象, 而由多个分支合并产生的提交对象有多个父对象。

Git 的分支,其实本质上仅仅是指向提交对象的可变指针。

创建分支

// 创建分支
git branch <branch-name>
// 创建并切换到分支
git branch -b <branch-name>

这会在当前所在的提交对象上创建一个指针。

切换分支

要切换到一个已存在的分支,你需要使用 git checkout 命令。

// 切换到一个已存在的分支
git switch <branch-name>
 
// 在当前所在的提交对象上创建新分支,并切换到新分支。
git checkout -b <branch-name>

查看分支

// 查看已创建的分支
git branch 

合并分支

// 合并分支到当前分支。
git merge <branch-name>

指定分支

// 强制指定分支到某个提交记录
git branch -f <branch> [<start-point>]

删除分支

git branch -d <branch-name>

分支实例

你将经历如下步骤:

  1. 开发某个网站。

  2. 为实现某个新的用户需求,创建一个分支。

  3. 在这个分支上开展工作。

正在此时,你突然接到一个电话说有个很严重的问题需要紧急修补。 你将按照如下方式来处理:

  1. 切换到你的线上分支(production branch)。
  2. 为这个紧急任务新建一个分支,并在其中修复它。
  3. 在测试通过之后,切换回线上分支,然后合并这个修补分支,最后将改动推送到线上分支。
  4. 切换回你最初工作的分支上,继续工作。

新建分支

首先,我们假设你正在你的项目上工作,并且在 master 分支上已经有了一些提交。

现在,你为实现某个新的需求,创建一个分支 iss53。

git checkout -b iss53

当我们做了一些提交的时候,iss53 分支在向前推进。

现在你接到那个电话,有个紧急问题等待你来解决,现在要做的就是切换回主分支。

git switch master

接下来,你要修复这个紧急问题。新建一个 hotfix 分支,直至问题解决。

git checkout -b hotfix

合并分支

然后将 hotfix 分支合并回你的 master 分支来部署线上。

git switch master
git merge hotfix

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

此时,hotfix 分支已经不需要了,可以将它删除。

git branch -d hotfix

现在你切回你正在工作的分支,继续你的工作,并且拥有了新的提交。

假设这时你已经完成了 iss53 的需求,需要将 iss53 分支合并入 master 分支。

git switch master
git merge iss53

这和之前合并 hotfix 分支的时候看起来有点不一样。

在这种情况下,你的开发历史从一个更早的地方开始分叉开来(diverged)。 因为,master 分支所在提交并不是 iss53 分支所在提交的直接祖先,Git 不得不做一些额外的工作。 出现这种情况的时候,Git 会使用两个分支的末端所指的快照以及这两个分支的公共祖先,做一个简单的三方合并。

冲突时的分支合并

有时候合并操作并不会如此顺利,因为如果涉及到同一个文件的同一处,就会在合并时产生冲突。此时 Git 做了合并,但是没有自动地创建一个新的合并提交。 Git 会暂停下来,等待你去解决合并产生的冲突。

任何因包含合并冲突而有待解决的文件,都会以未合并状态标识出来。 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 就会将它们标记为冲突已解决。

远程分支

远程引用是对远程仓库的引用(指针),包括分支、标签等等。请将它们看做书签, 这样可以提醒你该分支在远程仓库中的位置就是你最后一次连接到它们的位置。

推送分支

当你想要公开分享一个分支时,需要将其推送到有写入权限的远程仓库上。 本地的分支并不会自动与远程仓库同步——你必须显式地推送想要分享的分支。

// 推送本地分支到正在跟踪远程分支
git push <remote> <branch>
 
// 推送本地分支到远程分支(自定义远程分支命名)
git push <remote> <branch>:<origin-branch-name>

跟踪分支

从一个远程跟踪分支检出一个本地分支会自动创建所谓的“跟踪分支”(它跟踪的分支叫做“上游分支”)。跟踪分支是与远程分支有直接关系的本地分支。

// 从远程分支新建本地跟踪分支
git checkout --track <remote>/<branch>
 
// 从远程分支新建本地跟踪分支(自定义本地分支命名)
git checkout -b <branch> <remote>/<branch>
 
// 修改跟踪的远程分支
git branch -u <remote>/<branch>
 
// 查看设置的所有跟踪分支
git branch -vv

拉取分支

当从服务器上抓取本地没有的数据时,可以运行 git fetch 或 git pull。推荐单独显式地使用 fetch 与 merge 命令。

// 仅拉取默认远程分支数据,不进行自动合并。
git fetch <remote>
 
// 拉取远程分支,并尝试自动合并。
git pull <remote>

删除远程分支

git push <remote> --delete <branch>

变基

你可以使用 rebase 命令将提交到某一分支上的所有修改都移至另一分支上,就好像“重新播放”一样。在 Git 中,这种操作就叫做 变基(rebase)。

// 重放式变基
git rebase <base-branch> <topic-branch>
// 交互式变基
git rebase -i <base-branch> <topic-branch>

它的原理是首先找到这两个分支(假设当前分支 experiment、变基操作的目标基底分支 master) 的最近共同祖先,然后对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件, 然后将当前分支指向目标基底, 最后以此将之前另存为临时文件的修改依序应用。

// 1.切换 experiment 分支。
git switch experiment
 
// 2.变基至 master 分支。
git rebase master
 
// 3.切换 master 分支。
git switch master
 
// 4.合并 experiment 分支。
git merge experiment

更有趣的变基例子

在对两个分支进行变基时,所生成的“重放”并不一定要在目标分支上应用,你也可以指定另外的一个分支进行

应用。

你从 master 分支创建了一个 server 分支,添加功能并提交后。又基于提交后的 server 分支创建了 client 分支,同样添加功能并提交后。你回到了 server 分支,继续添加功能并提交。假设你希望将 client 中的修改合并到主分支并发布,但暂时并不想合并 server 中的修改,因为它们还需要经过更全面的测试。

这时,你就可以使用 git rebase 命令的 —onto 选项, 选中在 client 分支里但不在 server 分支里的修改,将它们在 master 分支上重放。

// 1. 取出 client 分支,找出它从 server 分支分歧之后的补丁。然后把这些补丁在master 分支上重放一遍,让 client 看起来像直接基于 master 修改一样。
git rebase --onto master server client
 
// 2. 切换 master 分支
git switch master
 
// 3. 合并 client 分支
git merge client

接下来你决定将 server 分支中的修改也整合进来。

// 1.变基至 master分支
git rebase master server
 
// 2.切换 master 分支
git switch master
 
// 3.合并分支
git merge server
 
// 4.删除分支

变基的风险

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

如果你遵循这条金科玉律,就不会出差错。 否则,人民群众会仇恨你,你的朋友和家人也会嘲笑你,唾你。

变基 Vs 合并

至此,你已在实战中学习了变基和合并的用法,你一定会想问,到底哪种方式更好。 在回答这个问题之前,让我们退后一步,想讨论一下提交历史到底意味着什么。

有一种观点认为,仓库的提交历史即是 记录实际发生过什么。 它是针对历史的文档,本身就有价值,不能乱改。 从这个角度看来,改变提交历史是一种亵渎,你使用 谎言 掩盖了实际发生过的事情。 如果由合并产生的提

交历史是一团糟怎么办? 既然事实就是如此,那么这些痕迹就应该被保留下来,让后人能够查阅。另一种观点则正好相反,他们认为提交历史是 项目过程中发生的事。 没人会出版一本书的第一版草稿,软件维护手册也是需要反复修订才能方便使用。 持这一观点的人会使用 rebase 及 filter-branch 等工具来编写故事,怎么方便后来的读者就怎么写。

现在,让我们回到之前的问题上来,到底合并还是变基好?希望你能明白,这并没有一个简单的答案。 Git 是一个非常强大的工具,它允许你对提交历史做许多事情,但每个团队、每个项目对此的需求并不相同。 既然你已经分别学习了两者的用法,相信你能够根据实际情况作出明智的选择。

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