基于 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-pick
,revert
和 blame
时,也会更加简单。
基于 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 有两个重要的属性:
- 假设 patch B 依赖于 patch A,patch C 依赖于 patch B,B 可以在 A 和 C 之间自由移动而不改变最终结果。这种移动操作称之为 commute。
- 每个 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 有两个优点:
- 最终结果跟用了
git rebase
一样,能够保证历史是单线条的。 - 由于 merge 过程中能够参考中间各个 patch 的信息,合并的效果理论上应该比简单粗暴的三路合并要好。
感兴趣的读者可以看看 pijul 的代码,深究其内部实现。
参考资料:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。