☰
6篇文章讲清楚Git:Git 分支与合并:一次讲清 HEAD、merge 和 rebase (2/6)
2026/9/27 22:57:46 网站建设 项目流程

上一篇我们把提交讲成了一串用parent指针串起来的快照链,但那只是一条线,实际开发中我们不可能大家都在一条线上改代码,所以这就引出了分支。

分支就是一个指针

理解分支最重要的一句话是:分支什么都没多存,它就是一个指向某个提交的可变指针。

在.git/refs/heads/下面,一个分支就是一个普普通通的文本文件,里面存着 40 个字符的哈希:

cat.git/refs/heads/main
5cccce7a1f3b2c8d9e0f1a2b3c4d5e6f7a8b9c0d

就这么简单。所以 Git 建一个分支,本质上只是写了一个 41 字节的文件,瞬间完成,这也是它比 SVN 建分支快几个数量级的根本原因,SVN 的分支是真的去复制一份目录出来。

A <--- B <--- C ↑ main main 这个分支指向 C

HEAD 是什么

HEAD 是"我们现在在哪儿"的标记,唯一需要注意的是它通常不直接指向提交,而是指向一个分支:

cat.git/HEAD
ref: refs/heads/main

这叫符号引用,意思是 HEAD 指向main这个分支,然后main再指向提交 C,所以 HEAD 其实是个两层的指向。

搞清这一层之后,一个新提交产生的过程就很好理解了:Git 做的事情是把 HEAD 指向的那个分支往前移一格,HEAD 本身纹丝不动,它还是指着main。所以切换分支本质上只是改了这个 HEAD 文件的内容,不是去搬动什么代码。

游离 HEAD

如果 HEAD 不指向分支,而是直接指向某个提交,这种状态就叫游离 HEAD(detached HEAD):

gitcheckout HEAD~1
HEAD detached at 3520544 nothing to commit, working tree clean

这时候我们就是在看一个历史提交,HEAD指向3520544这个具体提交,而不指向任何分支。可以拿来做实验,但如果在这个状态下提交了,这些提交不属于任何分支,等我们切走之后就没有任何引用指向它们了,很容易找不回来(除非用reflog,这个到第 4 篇讲):

gitcheckout-qHEAD~1echox>x.txt&&gitadd.&&gitcommit-m"游离状态下的提交"gitcheckout main
Warning: you are leaving 1 commit behind, not connected to any of your branches: 52393a9 游离状态下的提交

Git 会好心地提醒我们"你丢下了一个提交"。想保留的话,在切走之前先建个分支就行了:git checkout -b new-branch。

分支的增删改查

查看分支

gitbranch# 本地分支,当前分支前面有 *gitbranch-v# 带上每个分支的最后一个提交gitbranch-vv# 再带上上游分支,第 3 篇会用到gitbranch-a# 包括远程分支gitbranch--merged# 哪些分支已经合并到当前分支了

创建和切换

gitbranch feature# 只创建,不切换gitcheckout feature# 切换gitcheckout-bfeature# 创建并切换(最常用)gitswitch-cfeature# 同上,新命令

这里需要注意的是git checkout -b feature是从当前 HEAD 的位置分出去的,如果想从别的地方分,就在后面跟上起点:

gitcheckout-bhotfix main# 从 main 分出一个 hotfixgitcheckout-bhotfix a1b2c3d# 从某个提交分

删除分支

gitbranch-dfeature# 删除,如果还没合并会拒绝,防止误删gitbranch-Dfeature# 强制删除,不管有没有合并

-d拒绝删除一个还没合并的分支,这个设计是个保护机制。比如一个做到一半的feature分支,直接删掉那些提交就真找不回来了(其实reflog还能救,见第 4 篇)。确定不要了再用-D。

checkout 和 switch

git checkout是个身兼数职的命令,它既能切分支,也能恢复文件:

gitcheckout main# 切分支gitcheckout -- a.txt# 把 a.txt 恢复成暂存区的样子(恢复文件)gitcheckout HEAD~1# 游离到某个提交

同一个命令名承担了语义完全不同的操作,其中git checkout -- a.txt这种还是会丢改动的危险操作,很容易手滑敲错。所以 Git 2.23 把这两个职责拆成了两个专用命令:

老写法新写法干什么
git checkout <branch>git switch <branch>切分支
git checkout -b <branch>git switch -c <branch>建并切分支
git checkout -- <file>git restore <file>恢复文件,丢弃工作区改动
git checkout <commit>git switch --detach <commit>游离到某个提交

checkout现在还能用,但新写的东西建议用switch和restore,语义清楚,也不容易误操作。

合并

现在假设有两个分支各自往前走了,要把feature合进main:

gitswitch maingitmerge feature

合并的结果有两种,取决于两个分支的位置关系。

快进合并

如果main从分出去之后一次提交都没有,那它的 HEAD 只是停在一个老位置上,而feature是在它前面一路走下来的:

合并前: A <--- B(main) <--- C <--- D(feature) 合并后: A <--- B <--- C <--- D ↑ main, feature

这种情况下 Git 不需要真的"合并"什么,只要把main指针挪到D就行了,这就叫快进(fast-forward),没有产生新的提交,历史是一条直线。

合并提交

如果两个分支都往前走了,那就分叉了,没法靠移动指针解决:

C <--- D (feature) / A <--- B \ E <--- F (main)

这时候git merge feature会创建一个合并提交(merge commit),它有两个父提交:

gitmerge --no-ff feature-m"合并 feature"gitlog--oneline--graph

看这个图,|/是分叉点A,两条线分别是feature的提交C和main的提交D,最后|\汇合到合并提交5cccce7。

--no-ff的意思是"就算能快进,也强制生成一个合并提交"。为什么要这样?因为快进之后历史里看不出"这里曾经有个 feature 分支",以后想整个回退这个功能就很麻烦,得一个个提交去翻。有了合并提交,回退功能只要 revert 这一个提交就行。


merge 和 rebase 的区别

这是分支里最需要搞清的一对,也是面试和实际工作中都绕不开的。

merge:保留真实的分叉

上面看到的那个图就是 merge 的结果。分叉在开发过程中是真实存在过的,所以 merge 也如实地把它留在了历史里。

好处是历史如实反映了当时发生了什么,能看出来"这些提交是并行开发的"。坏处是分支一多,图上全是分叉和合并,git log会变得很难看。

rebase:把提交搬过去

同样是把feature合进main,rebase 的做法完全不一样:

gitswitch featuregitrebase maingitswitch maingitmerge --ff-only feature
* f3af094 C: feature 的提交 * 3520544 D: main 的提交 * 113a797 A: 初始提交

完全没有分叉,是一条直线。rebase 做的事情是:把feature上的提交一个个"拿下来",先让feature指到main的最新提交,再把刚才拿下来的提交在main最新提交的基础上重新应用一遍。效果看起来就像"我本来就是在main最新的基础上开发的"。

代价是提交被改写了

注意上面那个C提交的哈希:merge 版本里是d41640d,rebase 之后变成了f3af094。

改动内容完全一样,但 commit 的哈希变了。原因就在第 1 篇讲的对象模型里:commit 对象存着parent指针,父提交变了,对象内容就变了,哈希自然跟着变。所以 rebase 本质上是产生了一个全新的提交,原来那个在历史里消失了。

这就是那条黄金法则的由来:不要 rebase 已经推送到公共分支的提交。因为别人手里拿着的还是旧的那个提交(旧的 SHA),我们这边变成了新的,两边一比对就是两条不同的历史,别人拉取的时候要么报冲突要么被迫跟着改,团队里会乱成一团。

判断标准很简单:这个分支除了我还有别人在用吗。只有自己在用的分支(本地分支、自己的 PR 分支)可以随便 rebase,公共分支(main、develop)永远不要动。

那到底该用哪个

场景用哪个
把功能分支合进 mainmerge --no-ff,保留功能的边界
功能分支开发期间同步 main 的最新代码rebase main,让自己这边跟上,避免以后攒出一次大冲突
本地整理乱糟糟的提交rebase -i,第 5 篇讲
公共分支之间merge,绝不要 rebase

实践中一个比较常用的组合是:开发期间用自己的分支rebase main保持同步(这时候分支只有自己用,安全),功能开发完了合进main的时候用merge --no-ff留下一个合并提交,作为这个功能的边界。

冲突怎么处理

冲突发生在同一个文件的同一段内容,两个分支各改了一份的时候。Git 不知道该听谁的,就交给我们来决定:

gitmerge feature
Auto-merging a.txt CONFLICT (content): Merge conflict in a.txt Automatic merge failed; fix conflicts and then commit the result.

打开a.txt会看到这样的标记:

<<<<<<< HEAD main 这边改成这样 ======= feature 这边改成这样 >>>>>>> feature

三段的分工是:

  • <<<<<<< HEAD到=======之间:当前分支(合并时我们所在的分支,也就是 HEAD)的内容
  • =======到>>>>>>> feature之间:被合并分支的内容

处理方式就是手工把整个冲突块编辑成我们想要的样子,然后把那三行标记删掉。比如最终想两边都保留,就改成:

main 这边改成这样 feature 这边改成这样

改完之后:

gitadda.txt# 告诉 Git "这个文件的冲突我解决了"gitcommit# 完成合并(merge 会自动生成好提交说明)

git status会一路提示还有哪些文件没解决、下一步该干什么,跟着走就行。

想反悔怎么办

合并和 rebase 中途都可以中止,回到操作之前的状态:

gitmerge--abort# 放弃这次合并,回到 merge 之前gitrebase--abort# 放弃这次 rebasegitcherry-pick--abort

rebase 过程中还有另外两个参数:

gitrebase--continue# 解决完冲突,继续应用下一个提交gitrebase--skip# 跳过当前这个提交,继续下一个

需要注意的是 rebase 的冲突处理和 merge 不太一样:merge 只解决一次冲突,提交完就结束了;而rebase 是逐个提交重放,理论上每一个提交都可能要解一次冲突,所以 rebase 一个提交很多的分支会比较烦。这也是大分支同步主干时,定期小步 rebase 比攒到最后一次性 rebase 舒服得多的原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询