Git篇(6):分支操作的本质

经过前面的学习,我们已初步理解了 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,它同时指向 c2c4

👉 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、c6
  • dev 独有提交:c3、c4
  • 公共祖先:c2

HEAD 放在 main 上来做 rebase
初始历史
lua 复制代码
main: c1 -- c2 -- c5 -- c6
              \
dev:            c3 -- c4
HEAD -> main
  • main 独有提交:c5、c6
  • dev 独有提交:c3、c4
  • 公共祖先:c2
  • 在main上做rebase,实际上就是希望main指向的提交链变成c1 -- c2 -- c3 -- c4 -- c5 -- c6

执行命令
复制代码
git rebase dev

因为当前分支是 main ,目标分支是 dev


Git rebase 的步骤
  1. 找到共同祖先

    • 公共祖先:c2。
    • main 从 c2 之后有 c5、c6(重点关注,会成为悬空提交)
    • dev 从 c2 之后有 c3、c4。
  2. 提取 main 独有提交

    • Git 会把 c5、c6 保存为补丁(相对于 c2 的改动)。
  3. 把 main 移到 dev

    • 把 main 指针先挪到 dev 的最新提交 c4(因为我们的目标是希望main指向的提交链变成c1 -- c2 -- c3 -- c4 -- c5 -- c6)。
    • 这一步相当于:先main 变成 c1 -- c2 -- c3 -- c4,此时devmain都指向c4,HEAD随着main走。
  4. 依次应用第2步提取的 main 的每一个独有提交

    1. c4和c5合并(如果出现冲突,手动处理),成为c5' (成为一个新的commit,哈希变了),但提交备注不变,还叫c5;
    2. c5'(注意变成了c5',用的是上一次合并后的提交) 和c6合并(如果出现冲突,手动处理),成为c6' ,(成为一个新的commit,哈希变了),但提交备注不变,还叫c6;
  5. 得到新的提交链

    1. 原本的c5和c6没有指针指向了,成为悬空提交
    2. 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、c6
  • dev 独有提交:c3、c4
  • 公共祖先:c2

2. 执行 rebase

dev 分支上运行:

arduino 复制代码
git switch dev
git rebase main

Git 的 rebase 流程:

  1. 找到共同祖先

    • 祖先是 c2
    • 从这里分叉出去,dev 有 c3、c4,main 有 c5、c6。
  2. 提取 dev 独有提交

    • Git 会把 c3、c4 临时保存下来(相对于 c2 的差异)。
  3. 把 dev 的 HEAD 移到 main

    • dev 分支指向 main 的最新提交 c6
  4. 重新应用独有提交

    • 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
...
  • 这里的 abc1234def5678 就可能是 c3c4 的哈希。
  • 你可以直接恢复:
bash 复制代码
# 新建一个分支指针,指向某个 commit 哈希
git branch 分支名 哈希值

注意:

  • reflog 过期前的时间
    • 默认 reflog 保留 90 天(可配置)。
    • 超过这个时间,如果没有被引用,c3c4 会被垃圾回收(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 后,旧的 c3c4 会变成悬挂提交。
  • 它们短时间内还在,可以用 git refloggit 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 能做的事:
  1. 切换分支:

    git checkout dev

  2. 切换到某个提交(游离 HEAD):

    git checkout c1

  3. 丢弃工作区的修改(很危险!):

lua 复制代码
git checkout -- file.txt

👉 这三个功能逻辑完全不同,却挤在一个命令里,很多新手被坑过。


switch / restore 的诞生

为了降低学习成本,Git 在 2.23(2019年)引入了两个新命令:

  1. git switch → 专门负责"切换分支"
csharp 复制代码
git switch dev        # 切换到 dev
git switch -c new-branch  # 创建并切换到新分支
  1. git restore → 专门负责"恢复文件"
bash 复制代码
git restore file.txt        # 丢弃工作区修改
git restore --staged file.txt  # 从暂存区撤销

为什么要这样设计?
  • checkout 太万能,用户常常记混,误操作风险大

  • switchrestore 语义清晰,符合直觉:

    • 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 操作,都能还原为"动指针 + 是否生成新提交 + 是否改工作区"这三件事。

相关推荐
银安4 小时前
Git篇(7):Git 检测差异的原理——为什么合并会出现冲突
git
马优晨6 小时前
Git 中的某个分支打标签
git·git 中的某个分支打标签·git分支打标签·git 分支打tag·git 分支打标签tag
Blue桃之夭夭6 小时前
git和VScode
ide·git·vscode
码厂一粒沙14 小时前
【代码管理】git使用指南(新手向)
git
李贺梖梖1 天前
Git初识
git
~央千澈~1 天前
git大文件储存机制是什么-为什么有大文件会出错并且处理大文件非常麻烦-优雅草卓伊凡
git
Komorebi_99991 天前
Git 常用命令完整指南
大数据·git·elasticsearch
stark张宇1 天前
Git 与 GitHub 协同工作流:从0到1搭建版本控制体系
git·gitlab·github
爱吃生蚝的于勒1 天前
【Linux】零基础学会Linux之权限
linux·运维·服务器·数据结构·git·算法·github