欢迎光临
我们一直在努力

git分支

上一篇我们彻底搞懂了 Git 的底层核心:它是一个基于 SHA-1 的内容寻址数据库,所有文件和目录最终都转化为 Blob、Tree、Commit 对象存储,而版本历史本质上是由 Commit 对象通过父指针串联起来的链表。

这一篇我们将完全聚焦于 Git 最核心的能力 —— 分支,不涉及任何远程仓库和多人协作内容。很多人用 Git 多年,依然对分支一知半解,本质是没有穿透到 “指针” 这个底层本质。我们依然延续 “操作 + 底层验证” 的风格,从分支的本质出发,深入讲解所有分支操作的底层原理、高级技巧和常见问题解决方案。

1:分支本质

1:分支不是代码副本

这是所有 Git 学习者必须打破的第一个误区。如果分支是代码的副本,那么创建一个包含 10 万个文件的项目分支,应该需要复制几十 GB 的数据,耗时几分钟。但实际上,Git 创建分支只需要 不到 1 毫秒。

真相是:Git 的分支本质上只是一个纯文本文件,里面只存储了一个 40 位的 SHA-1 哈希值,指向某个 Commit 对象。它就像一本书里的书签,标记了你当前在历史中的位置。创建分支的过程,就是在 .git/refs/heads/ 目录下新建一个 40 字节的文本文件,仅此而已。

2:动手验证:创建分支的底层变化

我们用一个简单的项目来验证。假设当前我们有 3 次提交,master 分支指向最新的提交:

# 查看当前分支
git branch
# 输出:* master

# 查看 master 分支指向的提交哈希
cat .git/refs/heads/master
# 输出:7c3d8f9a2b4e6c8d0f1a3b5c7e9f1a3b5c7e9f1

现在我们创建一个dev分支

git branch dev

立即查看 .git 目录的变化

# 查看 refs/heads 目录,多了一个 dev 文件!
ls -l .git/refs/heads/
# 输出:
# -rw-r–r– 1 user group 41 May 22 10:00 dev
# -rw-r–r– 1 user group 41 May 22 09:50 master

# 查看 dev 文件的内容,和 master 完全一样!
cat .git/refs/heads/dev
# 输出:7c3d8f9a2b4e6c8d0f1a3b5c7e9f1a3b5c7e9f1

就是这么简单!Git 没有复制任何代码文件,只是新建了一个 41 字节的文本文件(包含换行符)。这就是 Git 分支轻量高效的根本原因。

3:分支的核心特性:可移动指针

分支不是固定不变的,它会随着你的提交自动向前移动。当你在某个分支上执行 git commit 时,Git 会做两件事:

  • 创建新的 Commit、Tree、Blob 对象
  • 将当前分支的指针移动到这个新的 Commit 对象上
  • 我们在 dev 分支上做一次提交来验证:

    # 切换到 dev 分支
    git checkout dev

    # 修改文件并提交
    echo "dev branch change" >> README.md
    git add README.md
    git commit -m "dev: first commit"

    现在查看两个分支的指针变化:

    # dev 分支已经指向了新的提交
    cat .git/refs/heads/dev
    # 输出:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

    # master 分支仍然停留在原来的提交
    cat .git/refs/heads/master
    # 输出:7c3d8f9a2b4e6c8d0f1a3b5c7e9f1a3b5c7e9f1

    两个分支现在指向了不同的提交,这就是分支 “分叉” 的本质。

    2:HEAD指针

    理解了分支是指针之后,我们必须深入理解另一个更重要的指针 ——HEAD。它是 Git 中最核心的指针,没有之一。

    1:HEAD的本质:指向指针的指针

    HEAD 存储在 .git/HEAD 文件中,它的作用是告诉 Git:“你现在在哪个分支上”。

    正常情况下,HEAD 不是直接指向 Commit 对象,而是指向一个分支引用。这种状态叫做符号引用(Symbolic Reference):

    cat .git/HEAD
    # 输出:ref: refs/heads/dev

    这行内容的意思是:“当前 HEAD 指向 refs/heads/dev 这个分支引用”。当你执行 git commit 时,Git 会先通过 HEAD 找到当前分支的引用文件,然后更新这个文件里的哈希值。

    2:切换分支的本质:只是修改 HEAD 的指向

    很多人以为切换分支是 “替换工作区的所有文件”,但这只是结果,不是本质。切换分支的本质只有一件事:修改 .git/HEAD 文件的内容,让它指向目标分支的引用。

    # 切换到 master 分支
    git checkout master

    # 查看 HEAD 的内容,已经变成了指向 master
    cat .git/HEAD
    # 输出:ref: refs/heads/master

    当然,切换分支不仅仅是修改 HEAD,Git 还会做两件重要的事:

  • 更新暂存区:用目标分支指向的 Commit 对应的 Tree 对象重置暂存区
  • 更新工作区:用暂存区的内容更新工作区的文件
  • 这就是为什么切换分支后,你工作区的文件会变成目标分支的样子。但 Git 非常高效,它只会更新那些在两个分支之间有差异的文件,而不是全部替换。

    3:分离头指针状态:HEAD直接指向Commit

    正常情况下 HEAD 指向分支,但有时候我们会不小心进入 ** 分离头指针(Detached HEAD)** 状态:

    # 直接切换到某个历史提交,而不是分支
    git checkout 7c3d8f9 # 第一次提交的哈希值
    # 输出:
    # Note: switching to '7c3d8f9'.
    # You are in 'detached HEAD' state…

    此时 HEAD 的内容发生了根本变化

    cat .git/HEAD
    # 输出:7c3d8f9a2b4e6c8d0f1a3b5c7e9f1a3b5c7e9f1

    现在 HEAD 不再指向任何分支,而是直接指向一个 Commit 对象。这就是 “分离头指针” 这个名字的由来。

    分离头指针状态的风险

    在分离头指针状态下,如果你做了新的提交,会发生什么?

    # 在分离头指针状态下修改并提交
    echo "detached HEAD change" >> test.txt
    git add test.txt
    git commit -m "commit in detached HEAD"

    Git 会正常创建新的提交,但这个提交不属于任何分支!它是一个 “悬空提交”。

    # 查看 HEAD 指向的新提交
    cat .git/HEAD
    # 输出:x9y8z7w6v5u4t3s2r1q0p9o8n7m6l5k4j3h2g1f

    # 查看所有分支,没有任何分支指向这个新提交
    git branch
    # 输出:
    # * (HEAD detached at x9y8z7w)
    # dev
    # master

    现在如果你切换回其他分支:

    git checkout master

    那个新提交就变成了 “孤儿”,没有任何指针指向它。Git 的垃圾回收机制会在一段时间后(默认 30 天)自动删除这个提交,你写的代码就会丢失。

    分离头指针的正确用途和补救方法

    分离头指针状态不是错误,它有合法的用途:

    • 临时查看某个历史版本的代码
    • 基于某个历史版本做实验性修改,不需要保留

    如果你不小心在分离头指针状态下做了重要的提交,想要保留这些修改,只需要基于当前 HEAD 创建一个新分支即可:

    # 在分离头指针状态下创建新分支,会保留所有提交
    git checkout -b temp-branch

    现在 temp-branch 分支指向了那个新提交,它就不会被垃圾回收了。

    3:分支合并

    分支的价值在于并行开发,而开发完成后最终都需要合并。Git 有两种完全不同的合并模式,理解它们的区别是掌握分支的关键。 

    1:快进合并

    当两个分支没有分叉,也就是待合并分支是当前分支的直接祖先时,Git 会采用快进合并模式。

    我们来构造这种场景:

    # 当前在 master 分支
    git checkout master

    # 创建并切换到 feature 分支
    git checkout -b feature

    # 在 feature 分支做两次提交
    echo "feature 1" >> feature.txt
    git add feature.txt
    git commit -m "feature: add feature 1"

    echo "feature 2" >> feature.txt
    git add feature.txt
    git commit -m "feature: add feature 2"

    现在分支结构是这样的:

    master: A — B
    \\
    feature: C — D

    master 停留在提交 B,feature 在 B 的基础上前进了两步到 D。现在我们切换回 master 合并 feature:

    git checkout master
    git merge feature
    # 输出:
    # Updating 7c3d8f9..a1b2c3d
    # Fast-forward
    # feature.txt | 2 ++
    # 1 file changed, 2 insertions(+)
    # create mode 100644 feature.txt

    注意输出里的 Fast-forward 字样,这就是快进合并。

    快进合并的底层原理

    快进合并是最简单的合并方式,Git 不需要做任何复杂的计算,只需要做一件事:将当前分支的指针直接移动到待合并分支的最新提交上。

    验证一下:

    # master 分支现在和 feature 分支指向同一个提交
    cat .git/refs/heads/master
    # 输出:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

    cat .git/refs/heads/feature
    # 输出:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

    快进合并的优点是速度极快,不会产生额外的提交,历史线是完全线性的。但它有一个致命缺点:合并完成后,原来的分支信息就丢失了。你无法从历史中看出这些提交是来自一个独立的功能分支。

    2:三方合并

    当两个分支都有新的提交,也就是分支分叉时,Git 无法进行快进合并,只能采用三方合并模式。

    我们来构造分叉场景:

    # 先回到合并前的状态
    git reset –hard HEAD^
    git branch -D feature

    # 重新创建 feature 分支并做一次提交
    git checkout -b feature
    echo "feature change" >> common.txt
    git add common.txt
    git commit -m "feature: modify common.txt"

    # 切换回 master 分支,也做一次提交
    git checkout master
    echo "master change" >> common.txt
    git add common.txt
    git commit -m "master: modify common.txt"

    现在分支结构变成了这样:

    C (master)
    /
    A — B —
    \\
    D (feature)

    两个分支从 B 点分叉,各自有了新的提交 C 和 D。现在我们合并:

    git merge feature
    # 输出:
    # Auto-merging common.txt
    # Merge made by the 'recursive' strategy.
    # common.txt | 1 +
    # 1 file changed, 1 insertion(+)

    这次没有 Fast-forward,Git 采用了 “递归合并策略”(也就是三方合并),并且自动创建了一个新的提交。

    三方合并的底层逻辑

    Git 会找到三个关键的提交来进行合并:

  • Ours:当前分支的最新提交(C)
  • Theirs:待合并分支的最新提交(D)
  • Base:两个分支的共同祖先提交(B)
  • 然后 Git 会做两次差异比较:

    • 比较 Base 和 Ours,得到差异 Δ1
    • 比较 Base 和 Theirs,得到差异 Δ2

    最后 Git 会将 Δ1 和 Δ2 合并在一起,生成一个新的合并提交(Merge Commit)。

    合并提交的特殊结构

    合并提交和普通提交有一个本质区别:它有两个父提交。

    # 查看最新的合并提交
    git log -1
    # 输出:
    # commit m7n8o9p0q1r2s3t4u5v6w7x8y9z0a1b2c3d4e5f (HEAD -> master)
    # Merge: c3d4e5f x9y8z7w # 注意这一行!两个父提交
    # Author: Your Name <your@email.com>
    # Date: Wed May 22 11:00:00 2024 +0800
    #
    # Merge branch 'feature'

    第一个父提交是合并前当前分支的最新提交(C),第二个父提交是待合并分支的最新提交(D)。Git 通过这种方式记录了合并的历史。

    现在分支结构变成了这样

    C — E (master, 合并提交)
    / /
    A — B — /
    \\ /
    D (feature)

    3:禁用快进合并

    如果你希望即使在可以快进合并的情况下,也保留分支的开发轨迹,可以使用 –no-ff 参数禁用快进合并:

    git merge –no-ff feature

    这样 Git 即使可以快进,也会强制生成一个合并提交。这是企业开发中的最佳实践,因为它完整保留了功能分支的历史,方便后续的代码审查、问题追溯和版本回退。

    4:合并策略

    Git 提供了多种合并策略,默认会根据场景自动选择最合适的策略:

    策略适用场景说明
    recursive(递归) 两个分支的合并 Git 的默认策略,会递归处理子树合并
    resolve 两个分支的合并 比 recursive 更简单的三方合并算法,速度更快,但处理复杂合并的能力稍弱
    octopus(章鱼) 三个及以上分支的合并 专门用于一次性合并多个分支,不支持处理冲突
    ours 任何场景 完全忽略待合并分支的所有修改,合并结果就是当前分支的内容
    theirs 任何场景 和 ours 相反,完全保留待合并分支的内容,丢弃当前分支的修改

    最常用的是 recursive 策略,它也是 Git 的默认策略。ours 和 theirs 策略在某些特殊场景下非常有用,比如你想要完全覆盖某个分支的修改。

    4:变基

    变基(Rebase)是 Git 中另一个合并分支的方式,也是最容易被误解和误用的功能。很多人谈 Rebase 色变,但只要理解了它的本质,就能用好这个强大的工具。

    1:Rebase的本质:“搬运的提交”

    我们还是用之前分叉的分支结构来演示 Rebase:

    C (master)
    /
    A — B —
    \\
    D (feature)

    之前用 Merge 的方式合并,会生成一个新的合并提交 E,历史线变成网状。现在我们用 Rebase 的方式来合并:

    # 切换到 feature 分支
    git checkout feature

    # 执行 rebase 命令,将 feature 分支变基到 master 分支上
    git rebase master

    Rebase 的执行步骤

    Rebase 的执行过程可以拆解为 4 步:

  • 找到共同祖先:Git 找到两个分支的共同祖先 B
  • 提取提交:提取当前分支(feature)从 B 之后的所有提交(也就是 D),并将它们保存为临时补丁文件
  • 切换到目标分支:将当前分支的指针移动到目标分支(master)的最新提交 C 上
  • 应用补丁:将之前提取的补丁文件一个个应用到 C 上,生成新的提交 D'
  • 变基完成后,分支结构变成了这样:

    A — B — C — D' (feature)
    (master)

    现在 feature 分支是 master 分支的直接后继,我们可以切换回 master,进行快进合并:

    git checkout master
    git merge feature

    合并完成后,历史线是完全线性的,非常干净,没有任何额外的合并提交。

    2:Rebase VS Merge

    现在我们可以清晰地对比 Merge 和 Rebase 的本质区别:

    特性MergeRebase
    历史线 网状,保留所有分支历史和合并轨迹 线性,重写提交历史
    提交哈希 所有原提交的哈希保持不变 变基后的提交是全新的,哈希值会改变
    额外提交 生成合并提交 不生成额外提交
    冲突处理 一次性解决所有冲突 逐个提交解决冲突
    适用场景 公共分支合并、需要保留完整历史 个人开发分支整理、让历史更清晰

    3:Rebase的黄金法则

    永远不要在公共分支上使用 Rebase!

    这是 Git 世界中最重要的法则,没有之一。原因很简单:Rebase 会重写提交历史,改变提交的哈希值。如果你的分支已经推送到了远程仓库,并且有其他人正在基于这个分支开发,你把重写后的历史推上去,会导致其他人的本地仓库和远程仓库不一致,引发灾难性的冲突和代码丢失。

    可以安全使用 Rebase 的场景:

    • 你在自己的本地分支上开发,还没有推送到远程
    • 你想要整理自己的提交历史,让它更清晰易读
    • 你需要将一个分支的修改应用到另一个分支的最新版本上

    绝对不能使用 Rebase 的场景:

    • 分支已经推送到远程仓库
    • 有其他人正在基于这个分支开发
    • 公共分支(如 master、develop)之间的合并

    4:Rebase过程的冲突处理

    在 Rebase 过程中,如果某个提交和目标分支有冲突,Git 会暂停 Rebase,让你解决冲突:

    git rebase master
    # 输出:
    # Auto-merging common.txt
    # CONFLICT (content): Merge conflict in common.txt
    # error: could not apply a1b2c3d… feature: modify common.txt
    # hint: Resolve all conflicts manually, mark them as resolved with
    # hint: "git add/rm <conflicted_files>", then run "git rebase –continue".
    # hint: You can instead skip this commit: run "git rebase –skip".
    # hint: To abort and get back to the state before "git rebase", run "git rebase –abort".

    解决 Rebase 冲突的步骤:

  • 打开冲突文件,手动解决冲突
  • 执行 git add 冲突文件,标记冲突已解决
  • 执行 git rebase –continue,继续应用下一个提交
  • 如果有多个提交有冲突,你需要逐个解决,直到所有提交都应用完成。

    如果你不想解决冲突,想要放弃这次 Rebase,可以执行:

    git rebase –abort

    这个命令会将仓库恢复到 Rebase 开始前的状态,就像什么都没发生过一样。

    5:交互式Rebase

    pick 7c3d8f9 first commit
    pick a1b2c3d second commit
    pick x9y8z7w third commit

    # 命令:
    # p, pick <提交> = 使用提交
    # r, reword <提交> = 使用提交,但修改提交信息
    # e, edit <提交> = 使用提交,但停下来修改内容
    # s, squash <提交> = 使用提交,但合并到上一个提交
    # f, fixup <提交> = 类似于 squash,但丢弃提交信息
    # d, drop <提交> = 删除提交
    # x, exec <命令> = 执行 shell 命令
    # b, break = 在此处停止(用 –continue 继续 rebase)

    交互式 Rebase 是 Git 最强大的功能之一,它允许你对已经提交的历史进行任意修改:压缩提交、修改提交信息、删除提交、拆分提交、重新排列提交顺序等。它就像一个提交历史的编辑器,让你可以把零散、混乱的开发历史整理成清晰、逻辑化的提交历史。

    1:基本用法

    交互式 Rebase 的命令格式是:

    git rebase -i <目标提交>

    其中 <目标提交> 是你想要整理的提交范围的前一个提交。例如,想要整理最近 3 次提交,可以执行:

    git rebase -i HEAD~3

    执行后会进入一个编辑界面,显示最近 3 次提交,每个提交前面有一个命令,默认是 pick:

    pick 7c3d8f9 first commit
    pick a1b2c3d second commit
    pick x9y8z7w third commit

    # 命令:
    # p, pick <提交> = 使用提交
    # r, reword <提交> = 使用提交,但修改提交信息
    # e, edit <提交> = 使用提交,但停下来修改内容
    # s, squash <提交> = 使用提交,但合并到上一个提交
    # f, fixup <提交> = 类似于 squash,但丢弃提交信息
    # d, drop <提交> = 删除提交
    # x, exec <命令> = 执行 shell 命令
    # b, break = 在此处停止(用 –continue 继续 rebase)

    2:5种常用操作

    1:修改提交信息

    如果你发现之前的提交信息写错了,或者写得不够清晰,可以用 reword 命令修改:

    reword 7c3d8f9 first commit # 修改这个提交的信息
    pick a1b2c3d second commit
    pick x9y8z7w third commit

    保存退出后,Git 会停下来,让你编辑这个提交的信息,编辑完成后保存即可。

    2:压缩提交

    开发过程中我们经常会产生很多零散的提交,比如 “修复 bug”、“再修复 bug”、“第三次修复 bug”。这些提交对项目历史没有价值,可以用 squash 命令把它们合并成一个提交。

    pick 7c3d8f9 first commit
    squash a1b2c3d second commit # 合并到上一个提交
    squash x9y8z7w third commit # 合并到上一个提交

    保存退出后,Git 会让你编辑新的合并提交的信息,编辑完成后保存即可。

    fixup 和 squash 类似,区别是 fixup 会直接丢弃被合并提交的信息,只保留内容,不需要编辑新的提交信息。

    3:删除提交

    如果你想删除某个提交,只需要把 pick 改成 drop,或者直接删除那一行即可:

    pick 7c3d8f9 first commit
    drop a1b2c3d second commit # 删除这个提交
    pick x9y8z7w third commit

    注意:删除提交会丢弃该提交的所有修改,如果这个提交和其他提交有依赖,可能会导致后续冲突。

    4:重新排列提交顺序

    你可以直接在编辑界面中拖动行,改变提交的顺序:

    pick x9y8z7w third commit # 原来的第三个提交放到第一个
    pick 7c3d8f9 first commit
    pick a1b2c3d second commit

    保存退出后,Git 会按照新的顺序重新应用这些提交。

    5:修改提交内容

    如果你发现某个提交的内容有错误,想要修改,可以用 edit 命令:

    pick 7c3d8f9 first commit
    edit a1b2c3d second commit # 停下来修改这个提交
    pick x9y8z7w third commit

    保存退出后,Git 会应用到这个提交时停下来,你可以修改工作区的文件,然后执行:

    git add .
    git commit –amend # 修改这个提交
    git rebase –continue # 继续 Rebase

    3:拆分提交

    交互式 Rebase 最神奇的操作之一是拆分提交。如果你把多个不相关的修改写到了一个提交里,可以用 edit 命令把它拆分成多个提交:

  • 用 edit 标记想要拆分的提交
  • 当 Git 停下来时,执行 git reset HEAD^,将提交的修改撤回到工作区
  • 然后分多次 git add 和 git commit,把修改拆分成多个提交
  • 最后执行 git rebase –continue 完成 Rebase
  • 6:分支的高级操作

    1:分支的重命名

    重命名本地分支非常简单:

    # 将 old-name 分支重命名为 new-name
    git branch -m old-name new-name

    底层原理:Git 只是将 .git/refs/heads/ 目录下的 old-name 文件重命名为 new-name,文件内容保持不变。

    2:比较两个分支的差异

    Git 提供了多种方式来比较分支之间的差异:

    # 比较两个分支的整体差异
    git diff branch1 branch2

    # 只比较某个文件在两个分支的差异
    git diff branch1 branch2 — path/to/file

    # 查看哪些提交在 branch2 有,但在 branch1 没有
    git log branch1..branch2

    # 查看哪些文件在两个分支之间有差异
    git diff –name-only branch1 branch2

    3:查看分支的演化历史

    用 git log 的 –graph 参数可以直观地查看分支的演化历史:

    git log –graph –oneline –decorate –all

    这个命令会输出一个 ASCII 图形化的分支历史,非常清晰。推荐把它设置为一个别名,方便日常使用:

    git config –global alias.lg "log –graph –oneline –decorate –all"

    以后只需要执行 git lg 就可以查看漂亮的分支历史了。

    4:恢复被删除的分支

    如果你不小心删除了一个重要的分支,不要慌,Git 几乎可以恢复所有丢失的提交和分支。

    恢复被删除分支的步骤:

  • 用 git reflog 找到被删除分支的最后一个提交的哈希值
  • 基于这个哈希值创建一个新分支:git checkout -b 新分支名 哈希值
  • # 查看所有操作历史,找到被删除分支的最后一个提交
    git reflog
    # 输出:
    # a1b2c3d (HEAD -> master) HEAD@{0}: commit: last commit on feature
    # 7c3d8f9 HEAD@{1}: checkout: moving from feature to master
    # x9y8z7w HEAD@{2}: branch: deleted feature
    # …

    # 基于这个哈希值恢复分支
    git checkout -b feature-recovered a1b2c3d

    7:常见分支问题

    1:不小心在错误分支上提交代码

    这是最常见的错误之一。如果你在 master 分支上提交了代码,但这些代码应该在 feature 分支上,可以用下面的方法修复:

    # 1. 在当前提交的基础上创建 feature 分支
    git checkout -b feature

    # 2. 切换回 master 分支,回退到上一个提交
    git checkout master
    git reset –hard HEAD^

    这样所有的修改都被转移到了 feature 分支,master 分支恢复到了原来的状态。

    2:撤销一个合并提交

    如果你合并了一个错误的分支,想要撤销这个合并,可以用 git revert 命令:

    # 撤销合并提交,-m 1 表示保留第一个父提交的内容
    git revert -m 1 <合并提交的哈希值>

    注意:git revert 不会删除原来的合并提交,而是会创建一个新的提交,撤销合并提交带来的所有修改。这是因为合并提交已经存在于历史中,如果删除它会导致其他基于这个合并的提交出现问题。

    3:只合并某个分支的部分提交

    有时候你不想合并整个分支,只想合并其中的某几个提交。这时候可以用 git cherry-pick 命令:

    # 合并单个提交
    git cherry-pick <提交哈希值>

    # 合并多个提交
    git cherry-pick <提交1> <提交2> <提交3>

    # 合并一个范围的提交(从 commitA 到 commitB,不包含 commitA)
    git cherry-pick commitA..commitB

    # 合并单个提交 git cherry-pick <提交哈希值> # 合并多个提交 git cherry-pick <提交1> <提交2> <提交3> # 合并一个范围的提交(从 commitA 到 commitB,不包含 commitA) git cherry-pick commitA..commitB

    赞(0)
    未经允许不得转载:171主机测评 » git分支
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址