Git 分支:直觉与现实

2023年 12月 1日 127.1k 0

你好!我一直在投入写作一本关于 Git 的小册,因此我对 Git 分支投入了许多思考。我不断从他人那里听说他们觉得 Git 分支的操作方式违反直觉。这使我开始思考:直觉上的分支概念可能是什么样,以及它如何与 Git 的实际操作方式区别开来?

在这篇文章中,我想简洁地讨论以下几点内容:

  • 我认为许多人可能有的一个直觉性的思维模型
  • Git 如何在内部实现分支的表示(例如,“分支是对提交的指针”)
  • 这种“直觉模型”与实际操作方式之间的紧密关联
  • 直觉模型的某些局限性,以及为何它可能引发问题

本文无任何突破性内容,我会尽量保持简洁。

分支的直观模型

当然,人们对分支有许多不同的直觉。我自己认为最符合“苹果树的一个分支”这一物理比喻的可能是下面这个。

我猜想许多人可能会这样理解 Git 分支:在下图中,两个红色的提交就代表一个“分支”。

我认为在这个示意图中有两点很重要:

  • 分支上有两个提交
  • 分支有一个“父级”(main),它是这个“父级”的分支
  • 虽然这个观点看似合理,但实际上它并不符合 Git 对于分支的定义 — 最重要的是,Git 并没有一个分支的“父级”的概念。那么,Git 又是如何定义分支的呢?

    在 Git 里,分支是完整的历史

    在 Git 中,一个分支是每个过去提交的完整历史记录,而不仅仅是那个“分支”提交。因此,在我们上述的示意图中,所有的分支(main 和 branch)都包含了 4 次提交。

    我创建了一个示例仓库,地址为:https://github.com/jvns/branch-example。它设置的分支方式与前图一样。现在,我们来看看这两个分支:

    main 分支包含了 4 次提交:

    $ git log --oneline main
    70f727a d
    f654888 c
    3997a46 b
    a74606f a

    mybranch 分支也有 4 次提交。最后两次提交在这两个分支里都存在。

    $ git log --oneline mybranch
    13cb960 y
    9554dab x
    3997a46 b
    a74606f a

    因此,mybranch 中的提交次数为 4,而不仅仅是 2 次“分支”提交,即 13cb960 和 9554dab

    你可以用以下方式让 Git 绘制出这两个分支的所有提交:

    $ git log --all --oneline --graph
    * 70f727a (HEAD -> main, origin/main) d
    * f654888 c
    | * 13cb960 (origin/mybranch, mybranch) y
    | * 9554dab x
    |/
    * 3997a46 b
    * a74606f a

    分支以提交 ID 的形式存储

    在 Git 的内部,分支会以一种微小的文本文件的形式存储下来,其中包含了一个提交 ID。这就是我一开始提及到的“技术上正确”的定义。这个提交就是分支上最新的提交。

    我们来看一下示例仓库中 main 和 mybranch 的文本文件:

    $ cat .git/refs/heads/main
    70f727acbe9ea3e3ed3092605721d2eda8ebb3f4
    $ cat .git/refs/heads/mybranch
    13cb960ad86c78bfa2a85de21cd54818105692bc

    这很好理解:70f727 是 main 上的最新提交,而 13cb96 是 mybranch 上的最新提交。

    这样做的原因是,每个提交都包含一种指向其父级的指针,所以 Git 可以通过追踪这些指针链来找到分支上所有的提交。

    正如我前文所述,这里遗漏的一个重要因素是这两个分支间的任何关联关系。从这里能看出,mybranch 是 main 的一个分支——这一点并没有被表明出来。

    既然我们已经探讨了直观理解的分支概念是如何不成立的,我接下来想讨论的是,为何它在某些重要的方面又是如何成立的。

    人们的直观感觉通常并非全然错误

    我发现,告诉人们他们对 Git 的直觉理解是“错误的”的说法颇为流行。我觉得这样的说法有些可笑——总的来说,即使人们关于某个题目的直觉在某些方面在技术上不精确,但他们通常会有完全合理的理由来支持他们的直觉!即使是“不正确的”模型也可能极其有用。

    现在,我们来讨论三种情况,其中直觉上的“分支”概念与我们实际在操作中如何使用 Git 非常相符。

    变基操作使用的是“直观”的分支概念

    现在,让我们回到最初的图片。

    当你在 main 上对 mybranch 执行 变基rebase 操作时,它将取出“直观”分支上的提交(只有两个红色的提交)然后将它们应用到 main 上。

    执行结果就是,只有两次提交(x 和 y)被复制。以下是相关操作的样子:

    $ git switch mybranch
    $ git rebase main
    $ git log --oneline mybranch
    952fa64 (HEAD -> mybranch) y
    7d50681 x
    70f727a (origin/main, main) d
    f654888 c
    3997a46 b
    a74606f a

    在此,git rebase 创建了两个新的提交(952fa64 和 7d50681),这两个提交的信息来自之前的两个 x 和 y 提交。

    所以直觉上的模型并不完全错误!它很精确地告诉你在变基中发生了什么。

    但因为 Git 不知道 mybranch 是 main 的一个分叉,你需要显式地告诉它在何处进行变基。

    合并操作也使用了“直观”的分支概念

    合并操作并不复制提交,但它们确实需要一个“基础base”提交:合并的工作原理是查看两组更改(从共享基础开始),然后将它们合并。

    我们撤销刚才完成的变基操作,然后看看合并基础是什么。

    $ git switch mybranch
    $ git reset --hard 13cb960  # 撤销 rebase
    $ git merge-base main mybranch
    3997a466c50d2618f10d435d36ef12d5c6f62f57

    这里我们获得了分支分离出来的“基础”提交,也就是 3997a4。这正是你可能会基于我们的直观图片想到的提交。

    GitHub 的拉取请求也使用了直观的概念

    如果我们在 GitHub 上创建一个拉取请求,打算将 mybranch 合并到 main,这个请求会展示出两次提交:也就是 x 和 y。这完全符合我们的预期,也和我们对分支的直观认识相符。

    我想,如果你在 GitLab 上发起一个合并请求,那显示的内容应该会与此类似。

    直观理解颇为精准,但它有一定局限性

    这使我们的对分支直观定义看起来相当准确!这个“直观”的概念和合并、变基操作以及 GitHub 拉取请求的工作方式完全吻合。

    当你在进行合并、变基或创建拉取请求时,你需要明确指定另一个分支(如 git rebase main),因为 Git 不知道你的分支是基于哪个分支的。

    然而,关于分支的直观理解有一个比较严重的问题:你直觉上认为 main 分支和某个分离的分支有很大的区别,但 Git 并不清楚这点。

    所以,现在我们要来讨论一下 Git 分支的不同种类。

    主干和派生分支

    对于人类来说,main 和 mybranch 有着显著的区别,你可能针对如何使用它们,有着截然不同的意图。

    通常,我们会将某些分支视为“主干trunk”分支,同时将其他一些分支看作是“派生”。你甚至可能有派生的派生分支。

    当然,Git 自身并没有这样的区分(“派生”是我刚刚构造的术语!),但是分支的种类确实会影响你如何处理它。

    例如:

    • 你可能会想将 mybranch 变基到 main,但你大概不会想将 main 变基到 mybranch —— 那就太奇怪了!
    • 一般来说,人们在重写“主干”分支的历史时比短期存在的派生分支更为谨慎。

    Git 允许你进行“反向”的变基

    我认为人们经常对 Git 感到困惑的一点是 —— 由于 Git 并没有分支是否是另一个分支的“派生”的概念,它不会给你任何关于何时合适将分支 X 变基到分支 Y 的指引。这一切需要你自己去判断。

    例如,你可以执行以下命令:

    $ git checkout main
    $ git rebase mybranch

    或者

    $ git checkout mybranch
    $ git rebase main

    Git 将会欣然允许你进行任一操作,尽管在这个案例中 git rebase main 是极其正常的,而 git rebase mybranch 则显得格外奇怪。许多人表示他们对此感到困惑,所以我提供了一个展示两种变基类型的图片以供参考:

    相似地,你可以进行“反向”的合并,尽管这相较于反向变基要正常得多——将 mybranch 合并到 main 和将 main 合并到 mybranch 都有各自的益处。

    下面是一个展示你可以进行的两种合并方式的示意图:

    Git 对于分支之间缺乏层次结构感觉有些奇怪

    我经常听到 “main 分支没什么特别的” 的表述,而这令我感到困惑——对于我来说,我处理的大部分仓库里,main 无疑是非常特别的!那么人们为何会称其为不特别呢?

    我觉得,重点在于:尽管分支确实存在彼此间的关系(main 通常是非常特别的!),但 Git 并不知情这些关系。

    每当你执行如 git rebase 或 git merge 这样的 git 命令时,你都必须明确地告诉 Git 分支间的关系,如果你出错,结果可能会相当混乱。

    我不知道 Git 在此方面的设计究竟“对”还是“错”(无疑它有利有弊,而我已对无休止的争论感到厌倦),但我认为,这对于许多人来说,原因在于它有些出人意料。

    Git 关于分支的用户界面也同样怪异

    假设你只想查看某个分支上的“派生”提交,正如我们之前讨论的,这是完全正常的需求。

    下面是用 git log 查看我们分支上的两次派生提交的方法:

    $ git switch mybranch
    $ git log main..mybranch --oneline
    13cb960 (HEAD -> mybranch, origin/mybranch) y
    9554dab x

    你可以用 git diff 这样查看同样两次提交的合并差异:

    $ git diff main...mybranch

    因此,如果你想使用 git log 查看 x 和 y 这两次提交,你需要用到两个点(..),但查看同样的提交使用 git diff,你却需要用到三个点(...)。

    我个人从来都记不住 .. 和 ... 的具体用意,所以我通常虽然它们在原则上可能很有用,但我选择尽量避免使用它们。

    在 GitHub 上,默认分支具有特殊性

    同样值得一提的是,在 GitHub 上存在一种“特殊的分支”:每一个 GitHub 仓库都有一个“默认分支”(在 Git 术语中,就是 HEAD 所指向的地方),具有以下的特别之处:

    • 初次克隆仓库时,默认会检出这个分支
    • 它作为拉取请求的默认接收分支
    • GitHub 建议应该保护这个默认分支,防止被强制推送,等等。

    很可能还有许多我未曾想到的场景。

    总结

    这些说法在回顾时看似是显而易见的,但实际上我花费了大量时间去搞清楚一个更“直观”的分支概念,这是因为我已经习惯了技术性的定义,“分支是对某次提交的引用”。

    同样,我也没有真正去思索过如何在每次执行 git rebase 或 git merge 命令时,让 Git 明确理解你分支之间的层次关系——对我而言,这已经成为第二天性,并没有觉得有何困扰。但当我反思这个问题时,可以明显看出,这很容易导致某些人混淆。

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论