继上次学习Git学习-GIt基础后,本章将学习Git分支。使用分支意味着你可以把你的工作从开发主线上分离开来,以免影响开发主线。 在很多版本控制系统中,这是一个略微低效的过程——常常需要完全创建一个源代码目录的副本。对于大项目来说,这样的过程会耗费很多时间。

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

分支简介

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

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

为了形象说明上述的提交对象,假设现在有一个工作目录,有三个文件,并且还没有进行提交过。然后进行暂存和提交操作,暂存操作时会为每一个文件计算校验和,校验和 使用 blob 对象保存,提交操作时会计算每个子目录的校验和,然后在Git仓库中这些校验和保存为树对象。随后,Git会创建一个提交对象,它包含一个指向这个树对象的指针。

实际上,Git中的每个提交都包含一个树对象,该树对象描述了在该提交中所有文件和子目录的布局。这个树对象包含指向对应文件和子目录的树对象或者文件对象的引用。

简而言之,Git的每一次提交都会对应有一个树对象,也只能有一个树对象。

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

image-20231214162142939

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

image-20231214162202144

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

分支创建

GIt 创建分支只是为你创建了一个可以移动的新的指针。 比如,创建一个 testing 分支, 你需要使用 git branch 命令:

1
$ git branch testing

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

image-20231214162603258

那么,Git 又是怎么知道当前在哪一个分支上呢? 也很简单,它有一个名为 HEAD 的特殊指针。 请注意它和许多其它版本控制系统(如 Subversion 或 CVS)里的 HEAD 概念完全不同。 在 Git 中,它是一个指针,指向当前所在的本地分支(译注:将 HEAD 想象为当前分支的别名)。 在本例中,你仍然在 master 分支上。 因为 git branch 命令仅仅 创建 一个新分支,并不会自动切换到新分支中去。

image-20231214162634544

分支切换

使用 git checkout 命令切换分支。例如现在需要切换到 testing 分支:

1
git checkout testing

这样 HEAD 就指向 testing 分支了。

image-20231214162732333

HEAD 指针会随着提交操作自动向前移动,也会在切换分支时移动到对应分支的最新提交上。

在切换分支时,git checkout 命令实际做了两件事,一是使 HEAD 指回目标分支,二是将工作目录恢复成目标分支所指向的快照内容。

在切换分支时,一定要注意你工作目录里的文件会被改变。 如果是切换到一个较旧的分支,你的工作目录会恢复到该分支最后一次提交时的样子。 如果 Git 不能干净利落地完成这个任务,它将禁止切换分支。

一般是在Git中有未提交的更改时,尝试切换分支可能会被拒绝。这是因为Git不希望在未保存更改的情况下让你切换到另一个分支,以防止潜在的冲突和数据丢失。

在出现禁止切换分支时,可以提交更改(git commit -a),也可以储藏更改(git stash)。

分支合并

使用 git merge 合并分支。例如现在有 masteriss53 两个分支,现在需要把 iss53 合并到 master 分支下,运行如下命令:

1
2
3
4
5
6
$ git checkout master
Switched to branch 'master'
$ git merge iss53
Merge made by the 'recursive' strategy.
index.html | 1 +
1 file changed, 1 insertion(+)

有时候合并操作不会如此顺利。 如果你在两个不同的分支中,对同一个文件的同一个部分进行了不同的修改,Git 就没法干净的合并它们。 当出现冲突时,提示内容如下:

1
2
3
4
$ 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 会暂停下来,等待你去解决合并产生的冲突。 你可以在合并冲突后的任意时刻使用 git status 命令来查看那些因包含合并冲突而处于未合并(unmerged)状态的文件:

1
2
3
4
5
6
7
8
9
10
11
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: index.html

no changes added to commit (use "git add" and/or "git commit -a")

任何因包含合并冲突而有待解决的文件,都会以未合并状态标识出来。 Git 会在有冲突的文件中加入标准的冲突解决标记,这样你可以打开这些包含冲突的文件然后手动解决冲突。 出现冲突的文件会包含一些特殊区段,看起来像下面这个样子:

1
2
3
4
5
6
7
<<<<<<< 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 分支所指示的版本在 ======= 的下半部分。 为了解决冲突,你必须选择使用由 ======= 分割的两部分中的一个,或者你也可以自行合并这些内容。 例如,你可以通过把这段内容换成下面的样子来解决冲突:

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

上述的冲突解决方案仅保留了其中一个分支的修改,并且 <<<<<<< , ======= , 和 >>>>>>> 这些行被完全删除了。 在你解决了所有文件里的冲突之后,对每个文件使用 git add 命令来将其标记为冲突已解决。 一旦暂存这些原本有冲突的文件,Git 就会将它们标记为冲突已解决。

分支管理

git branch 命令不只是可以创建与删除分支。 如果不加任何参数运行它,会得到当前所有分支的一个列表:

1
2
3
4
$ git branch
iss53
* master
testing

注意 master 分支前的 * 字符:它代表现在检出的那一个分支(也就是说,当前 HEAD 指针所指向的分支)。 这意味着如果在这时候提交,master 分支将会随着新的工作向前移动。 如果需要查看每一个分支的最后一次提交,可以运行 git branch -v 命令:

1
2
3
4
$ git branch -v
iss53 93b412c fix javascript issue
* master 7a98805 Merge branch 'iss53'
testing 782fd34 add scott to the author list in the readmes

--merged--no-merged 这两个有用的选项可以过滤这个列表中已经合并或尚未合并到当前分支的分支。 如果要查看哪些分支已经合并到当前分支,可以运行 git branch --merged

1
2
3
$ git branch --merged
iss53
* master

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

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

1
2
$ git branch --no-merged
testing

这里显示了其他分支。 因为它包含了还未合并的工作,尝试使用 git branch -d 命令删除它时会失败:

1
2
3
$ git branch -d testing
error: The branch 'testing' is not fully merged.
If you are sure you want to delete it, run 'git branch -D testing'.

如果真的想要删除分支并丢掉那些工作,如同帮助信息里所指出的,可以使用 -D 选项强制删除它。

如果在 git branch --no-merged 不加任何参数,表示默认检查当前分支。

使用 git branch --no-merged master 命令,表示查看未合并到master分支的有哪些。

远程分支

远程分支概念

远程引用是对远程仓库的引用(指针),包括分支、标签等。

使用 git ls-remote <remote> 显式地获取远程引用的完整列表。

image-20231214165623679

使用git remote show <remote> 可以获得远程分支的更多信息。

image-20231214165633071

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

远程分支都是以 <remote>/<branch> 的形式命名。

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

image-20231214165759669

如果你在本地的 master 分支做了一些工作,在同一段时间内有其他人推送提交到 git.ourcompany.com 并且更新了它的 master 分支,这就是说你们的提交历史已走向不同的方向。 即便这样,只要你保持不与 origin 服务器连接(并拉取数据),你的 origin/master 指针就不会移动。

image-20231214165822344

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

image-20231214165844920

在使用了 git fetch 拉取远程仓库的数据后,使用 git merge 命令会将远程分支与本地分支进行合并。但需要注意的是,当前本地分支需要跟踪该远程分支才行。

跟踪分支

使用 git branch -u <remote>/branch 将当前本地分支跟踪到指定远程仓库的远程分支上。-u参数也可以换成 --set-upstream-to 参数。

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

使用 git checkout -b <branch> <remote>/<branch> 命令,会创建一个本地分支并跟踪到指定远程分支,同时切换分支。由于这个命令比较常用,Git提供了快捷方式,使用 git checkout --track <remote>/<branch> 命令可以做到上述命令相同的功能。

由于这个操作太常用了,该捷径本身还有一个捷径。 如果你尝试检出的分支 (a) 不存在且 (b) 刚好只有一个名字与之匹配的远程分支,那么 Git 就会为你创建一个跟踪分支:

1
2
3
$ git checkout serverfix
Branch serverfix set up to track remote branch serverfix from origin.
Switched to a new branch 'serverfix'

如果想要将本地分支与远程分支设置为不同的名字,你可以轻松地使用上一个命令增加一个不同名字的本地分支:

1
2
3
$ git checkout -b sf origin/serverfix
Branch sf set up to track remote branch serverfix from origin.
Switched to a new branch 'sf'

现在,本地分支 sf 会自动从 origin/serverfix 拉取。


使用 git branch -vv 命令,会将所有的本地分支列出来并且包含更多的信息,如每一个分支正在跟踪哪个远程分支与本地分支是否是领先、落后或是都有。

这里可以看到 iss53 分支正在跟踪 origin/iss53 并且 “ahead” 是 2,意味着本地有两个提交还没有推送到服务器上。 也能看到 master 分支正在跟踪 origin/master 分支并且是最新的。 接下来可以看到 serverfix 分支正在跟踪 teamone 服务器上的 server-fix-good 分支并且领先 3 落后 1, 意味着服务器上有一次提交还没有合并入同时本地有三次提交还没有推送。 最后看到 testing 分支并没有跟踪任何远程分支。

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

1
$ git fetch --all; git branch -vv

拉取远程分支

git fetch 命令从服务器上抓取本地没有的数据时,它并不会修改工作目录中的内容。 它只会获取数据然后让你自己合并。 然而,有一个命令叫作 git pull 在大多数情况下它的含义是一个 git fetch 紧接着一个 git merge 命令。 如果有一个像之前章节中演示的设置好的跟踪分支,不管它是显式地设置还是通过 clonecheckout 命令为你创建的,git pull 都会查找当前分支所跟踪的服务器与分支, 从服务器上抓取数据然后尝试合并入那个远程分支。

由于 git pull 的魔法经常令人困惑所以通常单独显式地使用 fetchmerge 命令会更好一些。

推送远程分支

使用 git push <remote> <本地分支名>:<远程分支名> 推送指定的本地分支到指定远程仓库的远程分支下。如果远程分支不存在,Git会自动创建远程分支。

当本地分支已跟踪远程分支,可以直接使用 git push 推送当前本地分支至跟踪的远程分支上。

git push --force-with-lease 是一个用于 Git 的命令,它在强制推送(force push)时提供了一种更安全的方式。

通常情况下,使用 git push --force 命令可以强制推送你的本地分支到远程仓库,即使这样可能会覆盖其他人的提交。这可能会导致数据丢失或合作困难。

git push --force-with-lease 命令可以避免这种情况。它会首先检查远程分支的状态,确保你的本地分支是基于最新的远程分支提交的。如果你的本地分支落后于远程分支,那么 --force-with-lease 选项会拒绝推送,以防止覆盖其他人的提交。

删除远程分支

使用 git push <remote> --delete <branch> 删除远程分支。

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

使用 git reflog show <remote>/<branch> 命令查看已删除的分支。找到删除分支的提交哈希,重新新建本地分支并推送,即可恢复远程分支。

变基

在 Git 中整合来自不同分支的修改主要有两种方法:merge 以及 rebase

变基的基本操作

假设现在Git上的分支情况如下:

image-20231214172915634

现在需要将 experiment 分支合并到 master 分支下。

如果使用合并 merge 命令,它会把两个分支的最新快照(C3C4)以及二者最近的共同祖先(C2)进行三方合并,合并的结果是生成一个新的快照(并提交)。

image-20231214173016814

如果使用变基rebase命令,它将提取在 C4 中引入的补丁和修改,然后在 C3 的基础上应用一次。操作命令如下:

1
2
3
4
$ git checkout experiment
$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: added staged command

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

image-20231214173219126

再将C4中的修改变基到C3上,现在回到 master 分支,进行一次快进合并。

1
2
$ git checkout master
$ git merge experiment

image-20231214173306033

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

变基的风险

要用它得遵守一条准则:

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

变基操作的实质是丢弃一些现有的提交,然后相应地新建一些内容一样但实际上不同的提交。

变基 vs. 合并

有一种观点认为,仓库的提交历史即是 记录实际发生过什么。 它是针对历史的文档,本身就有价值,不能乱改。 从这个角度看来,改变提交历史是一种亵渎,你使用 谎言 掩盖了实际发生过的事情。 如果由合并产生的提交历史是一团糟怎么办? 既然事实就是如此,那么这些痕迹就应该被保留下来,让后人能够查阅。

另一种观点则正好相反,他们认为提交历史是 项目过程中发生的事。 没人会出版一本书的第一版草稿,软件维护手册也是需要反复修订才能方便使用。 持这一观点的人会使用 rebasefilter-branch 等工具来编写故事,怎么方便后来的读者就怎么写。

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

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

总结

本章主要学习了本地分支的创建、切换、合并、删除,远程分支的推送、跟踪、拉取、删除,分支管理的常用命令,分支合并与分支变基的区别与使用。