经过前面的学习,我们已初步理解了 Git 的"底层三层模型(blob/tree/commit + HEAD/branch/ref)",接下来看分支操作才会觉得 "哦,原来都只是指针在动" 。
下面我们就来逐一拆解 merge / rebase / reset / checkout 的本质:
merge(合并)
问题背景 :
你在 dev
分支做了一堆提交,同时 main
分支也在推进。
现在你想把两条线的工作合在一起。 本质:
merge
会新建一个 合并提交(merge commit) ,它有两个父提交。- 这个合并提交就是"把两个历史快照揉到一起"。
示意:
sql
main: c1 -- c2 -------- c5
\ ↑
dev: c3 -- c4
git merge dev(main分支上运行)
结果是 c5
,它同时指向 c2
和 c4
。
👉 merge 是历史保留型,它不会改已有提交,只是生成一个新提交。
补充:
这里先对merge在提交链上的作用效果上有个直观理解,如果深入到每个文件合并的具体内容上,可能会出现冲突问题;
很多人直觉上会觉得 Git 是靠"行号"来判断冲突,但这是错误的,且这让很多人对git的差异检测产生深深地误解与困惑;
实际上 Git 并不是按行号比较,而是按 diff(文本差异块,hunk)来比较的,而这涉及到Git的差异检测原理,光看描述好像很唬人,实际上原理异常简单!下一篇文章,会简单直白地展示原理。
merge与rebase
背景问题:分支并行开发导致历史分叉
当两个分支从同一个基点出发,分别提交了不同的修改,就会出现这样的历史:
lua
main: c1 -- c2 -------- c5 -- c6
\
dev: c3 -- c4
这就是分叉历史。
问题来了:
- 如果我们合并,历史会长得像"Y"字形,有多个分支点,提交图越来越复杂。
- 如果我们希望历史看起来是一条"直线",就需要 变基(rebase) 。
Merge 的思路
- 保留分叉的历史。
- 通过一个新提交(merge commit)把两个分支合到一起。
结果:
lua
c1 -- c2 ---- c5 -- c6
\ /
c3 -- c4 -- M
优点:真实还原了开发过程。
缺点:历史复杂,特别是多人协作时,会产生大量合并提交。
Rebase 的思路
- 让分支看起来像是从最新的主干"顺延"出来的。
- 把当前分支的独有提交,搬到目标分支的末尾,重新应用。
结果:
bash
c1 -- c2 -- c5 -- c6 -- c3' -- c4'
优点:历史干净,像一条直线,看起来好像 dev 一直基于 main 开发。
缺点:修改了提交 ID(因为重新应用),可能丢失"真实的分叉历史"。
本质问题:历史的可读性 vs 历史的真实性
- merge :保持真实性,提交树像一棵"分叉的树"。
- rebase:保持可读性,提交树像一条"干净的直线"。
所以,变基要解决的本质问题就是:如何把并行开发的分叉历史变成一条线性的历史,让提交记录更直观、简洁。
总结
👉 Merge 解决的是"怎么把代码合到一起"。
👉 Rebase 解决的是"怎么让历史更好看"。
rebase(变基)
问题背景
你不喜欢 merge
产生那种"分叉+合并"的历史,想要一条干净的直线。
什么是rebase?
rebase
= 把当前分支的 "独有提交" 重新应用在目标分支的末尾,并应用于当前分支;- Git 的 rebase 操作不会改目标分支,只会改当前分支;
- 所以它不是保留原提交,而是生成一堆新提交(哈希全变) 。
csharp
git rebase <base>
"以 <base>
为新的基底,把 HEAD 分支里 <base>
不包含的提交,按顺序搬过去",更新当前分支。
举例:
css
git rebase dev
HEAD->main
简单说就是:👉 "让当前分支main
改成以 dev
为新的基底"。
案例演示
初始历史
lua
main: c1 -- c2 -- c5 -- c6
\
dev: c3 -- c4
main
独有提交:c5、c6dev
独有提交:c3、c4- 公共祖先:c2
HEAD 放在 main 上来做 rebase
初始历史
lua
main: c1 -- c2 -- c5 -- c6
\
dev: c3 -- c4
HEAD -> main
main
独有提交:c5、c6dev
独有提交:c3、c4- 公共祖先:c2
- 在main上做rebase,实际上就是希望main指向的提交链变成c1 -- c2 -- c3 -- c4 -- c5 -- c6
执行命令
git rebase dev
因为当前分支是 main ,目标分支是 dev。
Git rebase 的步骤
-
找到共同祖先
- 公共祖先:c2。
main
从 c2 之后有 c5、c6(重点关注,会成为悬空提交)dev
从 c2 之后有 c3、c4。
-
提取 main 独有提交
- Git 会把 c5、c6 保存为补丁(相对于 c2 的改动)。
-
把 main 移到 dev
- 把 main 指针先挪到 dev 的最新提交 c4(因为我们的目标是希望
main
指向的提交链变成c1 -- c2 -- c3 -- c4 -- c5 -- c6)。 - 这一步相当于:先
main
变成c1 -- c2 -- c3 -- c4
,此时dev
和main
都指向c4,HEAD
随着main
走。
- 把 main 指针先挪到 dev 的最新提交 c4(因为我们的目标是希望
-
依次应用第2步提取的 main 的每一个独有提交
- c4和c5合并(如果出现冲突,手动处理),成为c5' (成为一个新的commit,哈希变了),但提交备注不变,还叫c5;
- c5'(注意变成了c5',用的是上一次合并后的提交) 和c6合并(如果出现冲突,手动处理),成为c6' ,(成为一个新的commit,哈希变了),但提交备注不变,还叫c6;
-
得到新的提交链
- 原本的c5和c6没有指针指向了,成为悬空提交
dev
还是指向c4没变,main
指向了最新的c6'
rebase 后的历史
scss
c1 -- c2 -- c3 -- c4(dev) -- c5(c5') -- c6(c6')(main)
注意:c5和c6的备注不变,还叫c5和c6,但是哈希值变了,不是之前的c5和c6
HEAD -> main
main -> c6'
dev -> c4
思考:变基(rebase)的本质是不是两个链表的拼接?
回答: 仅是看起来像,但不是单纯的拼接,因为后面拼的提交节点是全新的,所以这个猜想是错的!
下面来分层解释一下:
从直观效果来看:像链表拼接
Git 的提交历史,本质就是一个 有向无环图(DAG) ,但通常可以看成是 单链表:
- 每个提交(commit)有一个指针指向它的父提交。
- 分支就是一个指针,指向链表的某个节点。
所以当你执行 rebase 时:
- 你会把某条链上的"独有提交"拆出来(保存为 patch)。
- 然后把它们重新接到另一条链的末尾。
就像操作链表一样,把 c3 -> c4
这一小段"拿起来",接到 c2 -> c5 -> c6
的末尾。
从 效果 上看,确实像是"链表拼接"。
但本质上不是直接拼接,而是 重放提交
如果单纯拼接链表,结果会是"把旧节点直接挂到新链末尾"。
但 Git 的 rebase 并不是直接移动提交节点,而是:
- 找到分叉点(公共祖先)。
- 导出独有提交的变更(diff/patch) 。
- 在新基底上重新应用这些变更,生成全新的提交对象。
所以得到的不是原来的 c3、c4
,而是新的 c3'、c4'
。
这也是为什么 rebase 后会产生 悬挂提交(旧 c3、c4 还在,但没人引用)。
换句话说:
- merge 更像是链表的"合并节点",保留分叉结构。
- rebase 更像是"复制节点 + 拼接链表",把历史变成直线。
总结
- 效果上:rebase 看起来像是链表拼接。
- 实现上:它不是直接拼接节点,而是"复制变更,生成新节点,再拼接"。
HEAD 放在 dev上来做 rebase
1. 初始历史
lua
main: c1 -- c2 -- c5 -- c6
\
dev: c3 -- c4
HEAD -> dev
说明:
main
独有提交:c5、c6dev
独有提交:c3、c4- 公共祖先:c2
2. 执行 rebase
在 dev
分支上运行:
arduino
git switch dev
git rebase main
Git 的 rebase 流程:
-
找到共同祖先
- 祖先是
c2
。 - 从这里分叉出去,
dev
有 c3、c4,main
有 c5、c6。
- 祖先是
-
提取 dev 独有提交
- Git 会把
c3、c4
临时保存下来(相对于c2
的差异)。
- Git 会把
-
把 dev 的 HEAD 移到 main
- 把
dev
分支指向main
的最新提交c6
。
- 把
-
重新应用独有提交
- 从
c6
开始,依次应用 c3、c4 的改动,每一次都会进行合并,下一次应用的是上一次合并的结果,生成新的提交:c3'、c4'。
- 从
3. rebase 后的历史
scss
c1 -- c2 -- c5 -- c6(main) -- c3(c3') -- c4(c4')(dev)
HEAD -> dev
dev -> c4'
main -> c6
同时,原来的 c3 和 c4 还在 Git 数据库里,但不再被任何分支引用:
悬挂提交:c3, c4
- 它们不会立即消失 ,只是变成了 悬挂提交(dangling commits) ,相关概念可回顾前面我们在讲HEAD指针两种状态时,提到的"游离HEAD"与"悬挂提交"概念;
- 因为现在没有任何分支/标签引用它们了;
- 它们依然存在于
.git/objects
,暂时安全,但随着时间的推移,可能会被垃圾回收,因为没有持久引用。
4. 怎么找回原来的c3 和c4呢?
方式 1: git reflog
- Git 会记录 HEAD 和分支指针的移动历史。
- 你可以这样找:
bash
git reflog # 找到 c3 或 c4 的哈希
会显示:
kotlin
abc1234 HEAD@{1}: rebase: checkout c3
def5678 HEAD@{2}: commit: add feature
...
- 这里的
abc1234
、def5678
就可能是c3
、c4
的哈希。 - 你可以直接恢复:
bash
# 新建一个分支指针,指向某个 commit 哈希
git branch 分支名 哈希值
注意:
- reflog 过期前的时间
-
- 默认
reflog
保留 90 天(可配置)。 - 超过这个时间,如果没有被引用,
c3
和c4
会被垃圾回收(git gc
)。
- 默认
方式 2: git fsck --lost-found
- 扫描所有"孤儿对象",输出它们的哈希。
- Git 会把这些悬挂提交放在
.git/lost-found/commit
里。
5. 常见的"保险操作"
很多团队在 rebase 前会:
-
打标签:
git tag backup-dev dev
这样即使 rebase,旧历史也能追溯。
-
分支备份:
git branch dev-backup dev
这样能防止"啊我 rebase 把提交玩丢了"的情况。
✅ 结论:
- rebase 后,旧的
c3
、c4
会变成悬挂提交。 - 它们短时间内还在,可以用
git reflog
或git fsck
找回。 - 长期不引用会被
git gc
清掉。 - 如果要保险,rebase 前最好先打个 tag/branch(保证提交有持久引用)。
reset(重置)
问题背景 :
你提交错了,想"回到过去",但还没 push。
本质:
reset
移动 分支指针(比如 dev) 到某个提交。- 然后根据参数(--soft / --mixed / --hard),决定是否改动暂存区和工作区。
模式对比:
--soft
:只移动分支,保留暂存区和工作区。--mixed
(默认):移动分支 + 清理暂存区,工作区保留。--hard
:三者全部回滚(分支、暂存区、工作区)。
示意:
scss
c1 -- c2 -- c3 (dev, HEAD)
git reset --hard c1
c1 (dev, HEAD)
👉 reset 是指针回溯 + 可选级别的文件回溯。
checkout / switch(切换)
问题背景
你想切换到别的分支,或者到某个提交看看。
本质
checkout
/switch
就是改变 HEAD 所指的分支或提交。- 如果指向分支 → 正常 HEAD。
- 如果指向提交 → 游离 HEAD。
示意
css
c1 -- c2 -- c3
↑
main, HEAD
git switch dev
c1 -- c2 -- c3
↑
main
↑
HEAD → dev
👉 checkout/switch 就是"移动 HEAD 游标" 。
checkout 、 switch、 restore 的区别
Git 一开始只有 checkout
,但是它太强大 → 功能太多、语义不清、容易出错。
checkout 能做的事:
-
切换分支:
git checkout dev
-
切换到某个提交(游离 HEAD):
git checkout c1
-
丢弃工作区的修改(很危险!):
lua
git checkout -- file.txt
👉 这三个功能逻辑完全不同,却挤在一个命令里,很多新手被坑过。
switch / restore 的诞生
为了降低学习成本,Git 在 2.23(2019年)引入了两个新命令:
- git switch → 专门负责"切换分支"
csharp
git switch dev # 切换到 dev
git switch -c new-branch # 创建并切换到新分支
- git restore → 专门负责"恢复文件"
bash
git restore file.txt # 丢弃工作区修改
git restore --staged file.txt # 从暂存区撤销
为什么要这样设计?
-
checkout
太万能,用户常常记混,误操作风险大。 -
switch
和restore
语义清晰,符合直觉:- switch = 切换分支(branch switcher)。
- restore = 恢复文件(file restorer)。
-
这也是 Git 人机交互改进的一部分(但老用户仍然习惯用 checkout)。
✅ 小总结:
-
变基命令 :
git rebase main
(前提:你在 dev 分支上)。 -
checkout vs switch:
checkout
→ 老命令,功能大杂烩(切分支、切提交、丢文件改动)。switch
→ 新命令,只切分支,让语义更明确。restore
→ 新命令,专管文件恢复,替代checkout -- file
。
总结(分支操作的本质)
- merge:保留历史,创造新快照。
- rebase:重放提交,生成全新历史。
- reset:移动分支指针,可能抹掉提交。
- checkout/switch:移动 HEAD,切换工作状态。
这样看,你会发现:
所有复杂的 Git 操作,都能还原为"动指针 + 是否生成新提交 + 是否改工作区"这三件事。