基于 patch 的版本控制系统……

patch 是版本控制系统中最为渊远流长的概念之一,日常的各种操作中都需要跟它打个照面。比如 git diff 输出格式就是个 patch 文件,git cherry-pick 会把摘取的修改以 patch 的形式应用到目标分支上。此种例子比比皆是。不过需要指出的是,当前广泛使用的版本控制系统,比如 svn/git/hg,都是基于 snapshot 而不是 patch 的。基于 snapshot 的版本控制系统,以 snapshot 的方式存储当前版本。虽然这一类版本控制系统也会用到 patch,不过它们只有在需要时才计算出 patch 文件来。patch 是这一类版本控制系统的产物,而非基石。
(注意:切勿混淆 commit 和 snapshot 的概念,两者并不等价。Git 显然不会在每个 commit 中存储对整个仓库的 snapshot,这么做太占空间。事实上,Git 的 commit 只包含指向 snapshot tree 的指针,参见:Git-内部原理-Git-对象

自然存在基于 patch 的版本控制系统,比如 darcs 和 pijul,只是较为默默无闻。它们会是本文的主角。在基于 patch 的版本控制系统当中,当前版本由历史上一系列 patch 决定。需要在开头澄清的是,尽管本文着力于基于 patch 的版本控制系统的优势,但这并不表示个人认为基于 patch 的版本控制系统是更好的选择。基于 snapshot 的版本控制系统之所以流行,自然有它的优势所在。本文的目的是介绍基于 patch 的版本控制系统,尤其聚焦于这一类系统是如何处理合并的,旨在提供一种新思路。当我们需要借鉴版本控制系统来解决一类问题时,除了参考 git 和 svn,有时候也可以看下 pijul。

……是如何处理合并的

基于 patch 的版本控制系统,在跟 Git 比较时,通常会拿 git cherry-pick 说事。我们知道,git cherry-pick 会拿出给定 commit 的修改,应用到当前版本上。初看上去,Git 提取了给定 commit 到当前版本上。然而仔细观察后会发现,cherry-pick 之后新增的 commit,跟给定的 commit,其 ID 并不相同。事实上,git cherry-pick 只是提取了给定 commit 的修改到当前版本上。换句话说,cherry-pick 的是改动的内容,而非 commit 本身。如果事后又合并了当初 git cherry-pick 的 commit,在 Git 的眼里,它认为同样的修改发生了两次。

假设以下的场景:在开发 feature 分支上,发现 master 分支上有一个 bug,影响到新功能的开发,所以在 feature 分支上修了,然后 cherry-pick 到 master 分支上来。后来由于业务上的变动,master 分支去掉了这个修复。当我们合并 feature 分支后,这个修复又会重新出现在 master 分支。

在基于 patch 的版本控制系统没有这个问题,在它们眼里,无论在哪个分支上,同样的修改都是同一个 patch。在合并时,它们比较的是 patch 的多寡,而非 snapshot 的异同。同样的道理,基于 patch 的版本控制系统,在处理 cherry-pickrevertblame 时,也会更加简单。

基于 snapshot 的版本控制系统,在合并时采用三路合并(three-way merge)。比如 Git 中合并就是采用递归三路合并。所谓的三路合并,就是 theirs(A) 和 ours(B) 两个版本先计算出公共祖先 merge_base(C),接着分别做 theirs-merge_base 和 ours-merge_base 的 diff,然后合并这两个 diff。当两个 diff 修改了同样的地方时,就会产生合并冲突。

如果是基于 patch 的版本控制系统,会把对方分支上多出来的 patch 添加到当前分支上。效果看上去就像 git rebase 一样。如果添加过程中发生了冲突怎么办?

patch 有两个重要的属性:

  1. 假设 patch B 依赖于 patch A,patch C 依赖于 patch B,B 可以在 A 和 C 之间自由移动而不改变最终结果。这种移动操作称之为 commute。
  2. 每个 patch 都有一个对应的 inverse patch,可以把这个 path 引入的修改去掉。

darcs 在处理合并冲突时,会先添加若干个 inverse patch,回退到可以直接添加 patch 的时候。额外添加的 inverse patch 和之前有冲突的 patch 合并在一起,成为一个新的 patch。这其中可能还会有 commute 操作来移动 patch 到适当的位置。

通常对于差别较大的 Git 分支,不建议用 rebase 操作,因为 rebase 过程中,可能会发生因为修复冲突带来的往后更多的冲突 - 冲突的滚雪球效应。darcs 的合并,也会有同样的问题,一个合并操作耗费的时间可能会遇上指数爆炸。

pijul 通过引入名为有向图文件(directed graph file,以下简称为 digle)的数据结构,解决了这个问题。抛开我所不了解的具体细节不谈(对于细节感兴趣的读者,看这篇文章),由 digle 表示的数据结构能够保证不会发生合并冲突。这意味着,我们可以用 digle 作为 patch 的内部实现,这样两个 patch 的合并就是两个 digle 的合并,而 digle 的合并是不会产生冲突的。这么一来,合并过程中就不会有滚雪球效应了,我们可以在最后把 digle 具象成实际的 patch 的时候,才开始解决合并冲突。

pijul 的 merge 有两个优点:

  1. 最终结果跟用了 git rebase 一样,能够保证历史是单线条的。
  2. 由于 merge 过程中能够参考中间各个 patch 的信息,合并的效果理论上应该比简单粗暴的三路合并要好。

感兴趣的读者可以看看 pijul 的代码,深究其内部实现。

参考资料:


spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.