我在一些地方听说过,分布式版本控制系统发光的主要原因之一,是比传统工具(如SVN)更好的合并。 这实际上是由于两个系统工作方式的内在差异,还是特定的DVCS实现(如Git/Mercurial)具有比SVN更聪明的合并算法?


当前回答

为什么在DVCS中合并比在Subversion中更好的说法很大程度上是基于以前在Subversion中分支和合并的工作方式。1.5.0之前的Subversion没有存储任何关于分支何时合并的信息,因此当您想合并时,您必须指定必须合并的修订范围。

那么为什么Subversion合并这么糟糕呢?

想想这个例子:

      1   2   4     6     8
trunk o-->o-->o---->o---->o
       \
        \   3     5     7
b1       +->o---->o---->o

当我们想要将b1的更改合并到trunk中时,我们会发出以下命令,同时站在一个已经签出trunk的文件夹上:

svn merge -r 2:7 {link to branch b1}

它将尝试将b1中的更改合并到您的本地工作目录中。然后在解决任何冲突并测试结果之后提交更改。当你提交的时候,修订树看起来是这样的:

      1   2   4     6     8   9
trunk o-->o-->o---->o---->o-->o      "the merge commit is at r9"
       \
        \   3     5     7
b1       +->o---->o---->o

然而,当版本树增长时,这种指定修订范围的方法很快就会失控,因为subversion没有任何关于何时以及哪些修订被合并在一起的元数据。想想接下来会发生什么:

           12        14
trunk  …-->o-------->o
                                     "Okay, so when did we merge last time?"
              13        15
b1     …----->o-------->o

This is largely an issue by the repository design that Subversion has, in order to create a branch you need to create a new virtual directory in the repository which will house a copy of the trunk but it doesn't store any information regarding when and what things got merged back in. That will lead to nasty merge conflicts at times. What was even worse is that Subversion used two-way merging by default, which has some crippling limitations in automatic merging when two branch heads are not compared with their common ancestor.

为了缓解这种情况,Subversion现在为分支和合并存储元数据。这样就能解决所有问题了,对吧?

哦,顺便说一下,Subversion仍然很糟糕……

在像subversion这样的集中式系统上,虚拟目录很糟糕。为什么?因为每个人都可以看到它们,即使是垃圾实验。如果你想要尝试,但你不想看到每个人都在尝试,那么分支是很好的选择。这是严重的认知噪音。你添加的分支越多,你看到的垃圾就越多。

存储库中的公共分支越多,跟踪所有不同的分支就越困难。因此,您将面临的问题是,分支是否仍在开发中,或者它是否真的已经死亡,这在任何集中式版本控制系统中都很难判断。

据我所见,大多数情况下,组织都会默认使用一个大分支。这是一种遗憾,因为这反过来将很难跟踪测试和发布版本,以及来自分支的任何其他好处。

那么,为什么DVCS(比如Git、Mercurial和Bazaar)在分支和合并方面比Subversion更好呢?

原因很简单:分支是一级概念。DVCS中没有设计虚拟目录,分支是硬对象,它需要这样才能简单地处理存储库的同步(即推和拉)。

使用DVCS时要做的第一件事是克隆存储库(git的克隆、hg的克隆和bzr的分支)。克隆在概念上等同于在版本控制中创建一个分支。有些人称之为分叉或分支(尽管后者通常也用于指位于同一位置的分支),但这是同一件事。每个用户都运行自己的存储库,这意味着每个用户都有分支。

版本结构不是树,而是图。更具体地说,是有向无环图(DAG,意思是没有任何循环的图)。除了每个提交都有一个或多个父引用(提交所基于的父引用)之外,您真的不需要详细讨论DAG的细节。因此,下面的图表将反向显示两次修订之间的箭头。

一个非常简单的合并例子是这样的;假设有一个名为origin的中央存储库,用户Alice将存储库克隆到她的机器上。

         a…   b…   c…
origin   o<---o<---o
                   ^master
         |
         | clone
         v

         a…   b…   c…
alice    o<---o<---o
                   ^master
                   ^origin/master

在克隆过程中发生的事情是,每个修订都被原样复制到Alice(这是由唯一可识别的哈希id验证的),并标记了起源分支的位置。

然后Alice开始执行她的repo,在她自己的存储库中提交,并决定推动她的更改:

         a…   b…   c…
origin   o<---o<---o
                   ^ master

              "what'll happen after a push?"


         a…   b…   c…   d…   e…
alice    o<---o<---o<---o<---o
                             ^master
                   ^origin/master

解决方案相当简单,原始存储库需要做的唯一一件事就是接受所有的新修订,并将其分支移动到最新的修订(git称之为“快进”):

         a…   b…   c…   d…   e…
origin   o<---o<---o<---o<---o
                             ^ master

         a…   b…   c…   d…   e…
alice    o<---o<---o<---o<---o
                             ^master
                             ^origin/master

我在上面描述的用例甚至不需要合并任何东西。所以问题不在于合并算法,因为三向合并算法在所有版本控制系统中几乎是一样的。这个问题更多的是结构问题。

你能给我举个合并的例子吗?

无可否认,上面的例子是一个非常简单的用例,所以让我们做一个更复杂的,但更常见的用例。还记得起源有三次修订吗?好吧,做这些的人,我们叫他Bob,他一直在自己工作,并在自己的存储库上做了一个提交:

         a…   b…   c…   f…
bob      o<---o<---o<---o
                        ^ master
                   ^ origin/master

                   "can Bob push his changes?" 

         a…   b…   c…   d…   e…
origin   o<---o<---o<---o<---o
                             ^ master

现在Bob不能将他的更改直接推到原始存储库。系统检测的方法是检查Bob的修改是否直接从原点修改,在这种情况下不是。任何尝试推送都会导致系统显示类似于“呃…我恐怕不能让你这么做,鲍勃。”

因此Bob必须拉入,然后合并更改(与git的拉;或者hg的拉并合并;或者bzr的归并)。这是一个两步的过程。首先Bob必须获取新的修订,这将从原始存储库复制它们。我们现在可以看到图形发散:

                        v master
         a…   b…   c…   f…
bob      o<---o<---o<---o
                   ^
                   |    d…   e…
                   +----o<---o
                             ^ origin/master

         a…   b…   c…   d…   e…
origin   o<---o<---o<---o<---o
                             ^ master

pull过程的第二步是合并分叉的尖端,并提交结果:

                                 v master
         a…   b…   c…   f…       1…
bob      o<---o<---o<---o<-------o
                   ^             |
                   |    d…   e…  |
                   +----o<---o<--+
                             ^ origin/master

希望合并不会遇到冲突(如果你预料到它们,你可以在git中手动执行fetch和merge这两个步骤)。之后需要做的是将这些更改再次推入到原点,这将导致一个快进合并,因为合并提交是原始存储库中最新提交的直接后代:

                                 v origin/master
                                 v master
         a…   b…   c…   f…       1…
bob      o<---o<---o<---o<-------o
                   ^             |
                   |    d…   e…  |
                   +----o<---o<--+

                                 v master
         a…   b…   c…   f…       1…
origin   o<---o<---o<---o<-------o
                   ^             |
                   |    d…   e…  |
                   +----o<---o<--+

还有另一个选项可以合并git和hg,称为rebase,它将把Bob的更改移动到最新更改之后。因为我不想让这个答案变得更啰嗦,所以我会让你阅读git, mercurial或bazaar文档。

作为对读者的练习,试着画出当其他用户参与时它将如何工作。这与上面Bob的示例类似。存储库之间的合并比您想象的要容易,因为所有的修订/提交都是唯一可识别的。

还有在每个开发人员之间发送补丁的问题,这在Subversion中是一个巨大的问题,在git、hg和bzr中通过唯一可识别的修订得到了缓解。一旦有人合并了他的更改(即进行了合并提交),并通过推送到中央存储库或发送补丁将其发送给团队中的其他人来使用,那么他们就不必担心合并,因为它已经发生了。Martin Fowler称这种工作方式为混杂集成。

因为它的结构不同于Subversion,所以通过使用DAG,它使得分支和合并以一种更容易的方式完成,不仅对系统,而且对用户也是如此。

其他回答

One thing that hasn't been mentioned in the other answers, and that really is a big advantage of a DVCS, is that you can commit locally before you push your changes. In SVN, when I had some change I wanted to check in, and someone had already done a commit on the same branch in the meantime, this meant that I had to do an svn update before I could commit. This means that my changes, and the changes from the other person are now mixed together, and there is no way to abort the merge (like with git reset or hg update -C), because there is no commit to go back to. If the merge is non-trivial,this means that you can't continue to work on your feature before you have cleaned up the merge result.

但是,也许这只是那些太笨而不能使用单独分支的人的优势(如果我没记错的话,在我使用SVN的公司中,我们只有一个用于开发的分支)。

SVN跟踪文件,Git跟踪内容变化。跟踪从一个类/文件重构到另一个类/文件的代码块是足够聪明的。他们使用两种完全不同的方法来追踪你的来源。

我仍然大量使用SVN,但我对我使用Git的几次非常满意。

如果你有时间,这是一本不错的书:为什么我选择Git

为什么在DVCS中合并比在Subversion中更好的说法很大程度上是基于以前在Subversion中分支和合并的工作方式。1.5.0之前的Subversion没有存储任何关于分支何时合并的信息,因此当您想合并时,您必须指定必须合并的修订范围。

那么为什么Subversion合并这么糟糕呢?

想想这个例子:

      1   2   4     6     8
trunk o-->o-->o---->o---->o
       \
        \   3     5     7
b1       +->o---->o---->o

当我们想要将b1的更改合并到trunk中时,我们会发出以下命令,同时站在一个已经签出trunk的文件夹上:

svn merge -r 2:7 {link to branch b1}

它将尝试将b1中的更改合并到您的本地工作目录中。然后在解决任何冲突并测试结果之后提交更改。当你提交的时候,修订树看起来是这样的:

      1   2   4     6     8   9
trunk o-->o-->o---->o---->o-->o      "the merge commit is at r9"
       \
        \   3     5     7
b1       +->o---->o---->o

然而,当版本树增长时,这种指定修订范围的方法很快就会失控,因为subversion没有任何关于何时以及哪些修订被合并在一起的元数据。想想接下来会发生什么:

           12        14
trunk  …-->o-------->o
                                     "Okay, so when did we merge last time?"
              13        15
b1     …----->o-------->o

This is largely an issue by the repository design that Subversion has, in order to create a branch you need to create a new virtual directory in the repository which will house a copy of the trunk but it doesn't store any information regarding when and what things got merged back in. That will lead to nasty merge conflicts at times. What was even worse is that Subversion used two-way merging by default, which has some crippling limitations in automatic merging when two branch heads are not compared with their common ancestor.

为了缓解这种情况,Subversion现在为分支和合并存储元数据。这样就能解决所有问题了,对吧?

哦,顺便说一下,Subversion仍然很糟糕……

在像subversion这样的集中式系统上,虚拟目录很糟糕。为什么?因为每个人都可以看到它们,即使是垃圾实验。如果你想要尝试,但你不想看到每个人都在尝试,那么分支是很好的选择。这是严重的认知噪音。你添加的分支越多,你看到的垃圾就越多。

存储库中的公共分支越多,跟踪所有不同的分支就越困难。因此,您将面临的问题是,分支是否仍在开发中,或者它是否真的已经死亡,这在任何集中式版本控制系统中都很难判断。

据我所见,大多数情况下,组织都会默认使用一个大分支。这是一种遗憾,因为这反过来将很难跟踪测试和发布版本,以及来自分支的任何其他好处。

那么,为什么DVCS(比如Git、Mercurial和Bazaar)在分支和合并方面比Subversion更好呢?

原因很简单:分支是一级概念。DVCS中没有设计虚拟目录,分支是硬对象,它需要这样才能简单地处理存储库的同步(即推和拉)。

使用DVCS时要做的第一件事是克隆存储库(git的克隆、hg的克隆和bzr的分支)。克隆在概念上等同于在版本控制中创建一个分支。有些人称之为分叉或分支(尽管后者通常也用于指位于同一位置的分支),但这是同一件事。每个用户都运行自己的存储库,这意味着每个用户都有分支。

版本结构不是树,而是图。更具体地说,是有向无环图(DAG,意思是没有任何循环的图)。除了每个提交都有一个或多个父引用(提交所基于的父引用)之外,您真的不需要详细讨论DAG的细节。因此,下面的图表将反向显示两次修订之间的箭头。

一个非常简单的合并例子是这样的;假设有一个名为origin的中央存储库,用户Alice将存储库克隆到她的机器上。

         a…   b…   c…
origin   o<---o<---o
                   ^master
         |
         | clone
         v

         a…   b…   c…
alice    o<---o<---o
                   ^master
                   ^origin/master

在克隆过程中发生的事情是,每个修订都被原样复制到Alice(这是由唯一可识别的哈希id验证的),并标记了起源分支的位置。

然后Alice开始执行她的repo,在她自己的存储库中提交,并决定推动她的更改:

         a…   b…   c…
origin   o<---o<---o
                   ^ master

              "what'll happen after a push?"


         a…   b…   c…   d…   e…
alice    o<---o<---o<---o<---o
                             ^master
                   ^origin/master

解决方案相当简单,原始存储库需要做的唯一一件事就是接受所有的新修订,并将其分支移动到最新的修订(git称之为“快进”):

         a…   b…   c…   d…   e…
origin   o<---o<---o<---o<---o
                             ^ master

         a…   b…   c…   d…   e…
alice    o<---o<---o<---o<---o
                             ^master
                             ^origin/master

我在上面描述的用例甚至不需要合并任何东西。所以问题不在于合并算法,因为三向合并算法在所有版本控制系统中几乎是一样的。这个问题更多的是结构问题。

你能给我举个合并的例子吗?

无可否认,上面的例子是一个非常简单的用例,所以让我们做一个更复杂的,但更常见的用例。还记得起源有三次修订吗?好吧,做这些的人,我们叫他Bob,他一直在自己工作,并在自己的存储库上做了一个提交:

         a…   b…   c…   f…
bob      o<---o<---o<---o
                        ^ master
                   ^ origin/master

                   "can Bob push his changes?" 

         a…   b…   c…   d…   e…
origin   o<---o<---o<---o<---o
                             ^ master

现在Bob不能将他的更改直接推到原始存储库。系统检测的方法是检查Bob的修改是否直接从原点修改,在这种情况下不是。任何尝试推送都会导致系统显示类似于“呃…我恐怕不能让你这么做,鲍勃。”

因此Bob必须拉入,然后合并更改(与git的拉;或者hg的拉并合并;或者bzr的归并)。这是一个两步的过程。首先Bob必须获取新的修订,这将从原始存储库复制它们。我们现在可以看到图形发散:

                        v master
         a…   b…   c…   f…
bob      o<---o<---o<---o
                   ^
                   |    d…   e…
                   +----o<---o
                             ^ origin/master

         a…   b…   c…   d…   e…
origin   o<---o<---o<---o<---o
                             ^ master

pull过程的第二步是合并分叉的尖端,并提交结果:

                                 v master
         a…   b…   c…   f…       1…
bob      o<---o<---o<---o<-------o
                   ^             |
                   |    d…   e…  |
                   +----o<---o<--+
                             ^ origin/master

希望合并不会遇到冲突(如果你预料到它们,你可以在git中手动执行fetch和merge这两个步骤)。之后需要做的是将这些更改再次推入到原点,这将导致一个快进合并,因为合并提交是原始存储库中最新提交的直接后代:

                                 v origin/master
                                 v master
         a…   b…   c…   f…       1…
bob      o<---o<---o<---o<-------o
                   ^             |
                   |    d…   e…  |
                   +----o<---o<--+

                                 v master
         a…   b…   c…   f…       1…
origin   o<---o<---o<---o<-------o
                   ^             |
                   |    d…   e…  |
                   +----o<---o<--+

还有另一个选项可以合并git和hg,称为rebase,它将把Bob的更改移动到最新更改之后。因为我不想让这个答案变得更啰嗦,所以我会让你阅读git, mercurial或bazaar文档。

作为对读者的练习,试着画出当其他用户参与时它将如何工作。这与上面Bob的示例类似。存储库之间的合并比您想象的要容易,因为所有的修订/提交都是唯一可识别的。

还有在每个开发人员之间发送补丁的问题,这在Subversion中是一个巨大的问题,在git、hg和bzr中通过唯一可识别的修订得到了缓解。一旦有人合并了他的更改(即进行了合并提交),并通过推送到中央存储库或发送补丁将其发送给团队中的其他人来使用,那么他们就不必担心合并,因为它已经发生了。Martin Fowler称这种工作方式为混杂集成。

因为它的结构不同于Subversion,所以通过使用DAG,它使得分支和合并以一种更容易的方式完成,不仅对系统,而且对用户也是如此。

从历史上看,Subversion只能执行直接的双向合并,因为它没有存储任何合并信息。这涉及到获取一组更改并将它们应用到树中。即使使用合并信息,这仍然是最常用的合并策略。

默认情况下,Git使用3-way合并算法,其中包括为合并的头部找到一个共同的祖先,并利用合并两侧存在的知识。这使得Git在避免冲突方面更加智能。

Git还有一些复杂的重命名查找代码,这也有帮助。它不存储更改集或存储任何跟踪信息——它只存储每次提交时的文件状态,并根据需要使用启发式方法定位重命名和代码移动(磁盘上的存储比这更复杂,但它呈现给逻辑层的接口没有暴露跟踪)。

刚读了Joel博客上的一篇文章(很遗憾是他的最后一篇)。这篇文章是关于Mercurial的,但实际上它谈论的是分布式VC系统(如Git)的优势。

使用分布式版本控制, 分布式部分实际上不是 最有趣的部分。有趣的是,这些系统从变化的角度考虑问题,而不是从版本的角度。

点击这里阅读文章。