这份文档是为了描述 Subversion 1.4 而编写的。如果您运行的是更新版本的 Subversion,我们强烈建议您访问 https://svnbook.subversion.org.cn/ 并查阅适合您 Subversion 版本的书籍。

在分支之间复制更改

现在您和莎莉正在项目并行分支上工作:您正在私有分支上工作,莎莉正在 主干 或主开发线上工作。

对于拥有大量贡献者的项目,大多数人拥有主干的工作副本是很常见的。每当有人需要进行可能中断主干的长时间运行的更改时,标准程序是创建一个私有分支并在那里提交更改,直到所有工作完成。

所以,好消息是您和莎莉并没有互相干扰。坏消息是,很容易 过于 分离。请记住,使用“钻进洞里”策略的弊端之一是,当您完成分支时,将更改合并回主干可能变得几乎不可能,因为会产生大量冲突。

相反,您和莎莉可能会在工作时继续共享更改。由您决定哪些更改值得共享;Subversion 使您能够选择性地“复制”分支之间的更改。当您完全完成分支时,您的所有分支更改都可以复制回主干。

复制特定更改

在上一节中,我们提到您和莎莉都在不同分支上对 integer.c 做了更改。如果您查看莎莉对修订版 344 的日志消息,您会看到她修复了一些拼写错误。毫无疑问,您对同一文件的副本仍然存在相同的拼写错误。您对该文件的未来更改很可能会影响包含拼写错误的相同区域,因此当您将来合并分支时,您可能会遇到一些潜在的冲突。因此,最好是在您开始在同一位置进行大量工作 之前 接收莎莉的更改。

现在是使用 svn merge 命令的时候了。事实证明,这个命令与 svn diff 命令(您在 第 2 章,基本使用 中阅读过)非常相似。这两个命令都能比较存储库中的任何两个对象并描述差异。例如,您可以要求 svn diff 向您展示莎莉在修订版 344 中所做的确切更改

$ svn diff -c 344 http://svn.example.com/repos/calc/trunk

Index: integer.c
===================================================================
--- integer.c	(revision 343)
+++ integer.c	(revision 344)
@@ -147,7 +147,7 @@
     case 6:  sprintf(info->operating_system, "HPFS (OS/2 or NT)"); break;
     case 7:  sprintf(info->operating_system, "Macintosh"); break;
     case 8:  sprintf(info->operating_system, "Z-System"); break;
-    case 9:  sprintf(info->operating_system, "CPM"); break;
+    case 9:  sprintf(info->operating_system, "CP/M"); break;
     case 10:  sprintf(info->operating_system, "TOPS-20"); break;
     case 11:  sprintf(info->operating_system, "NTFS (Windows NT)"); break;
     case 12:  sprintf(info->operating_system, "QDOS"); break;
@@ -164,7 +164,7 @@
     low = (unsigned short) read_byte(gzfile);  /* read LSB */
     high = (unsigned short) read_byte(gzfile); /* read MSB */
     high = high << 8;  /* interpret MSB correctly */
-    total = low + high; /* add them togethe for correct total */
+    total = low + high; /* add them together for correct total */

     info->extra_header = (unsigned char *) my_malloc(total);
     fread(info->extra_header, total, 1, gzfile);
@@ -241,7 +241,7 @@
      Store the offset with ftell() ! */

   if ((info->data_offset = ftell(gzfile))== -1) {
-    printf("error: ftell() retturned -1.\n");
+    printf("error: ftell() returned -1.\n");
     exit(1);
   }

@@ -249,7 +249,7 @@
   printf("I believe start of compressed data is %u\n", info->data_offset);
   #endif

-  /* Set postion eight bytes from the end of the file. */
+  /* Set position eight bytes from the end of the file. */

   if (fseek(gzfile, -8, SEEK_END)) {
     printf("error: fseek() returned non-zero\n");

svn merge 命令几乎完全相同。但是,它不会将差异打印到您的终端,而是将它们直接应用到您的工作副本中作为 本地修改

$ svn merge -c 344 http://svn.example.com/repos/calc/trunk
U  integer.c

$ svn status
M  integer.c

svn merge 的输出显示您的 integer.c 副本已被修补。它现在包含莎莉的更改——更改已从主干“复制”到您的私有分支工作副本中,现在作为本地修改存在。此时,您可以查看本地修改并确保它正常工作。

在另一种情况下,事情可能没有那么顺利,integer.c 可能进入了冲突状态。您可能需要使用标准程序解决冲突(参见 第 2 章,基本使用),或者如果您决定合并是一个错误的决定,只需放弃并 svn revert 本地更改。

但是假设您已经查看了合并的更改,您可以照常 svn commit 该更改。此时,更改已合并到您的存储库分支中。在版本控制术语中,这种在分支之间复制更改的行为通常被称为 移植 更改。

当您提交本地修改时,请确保您的日志消息提到您正在将特定更改从一个分支移植到另一个分支。例如

$ svn commit -m "integer.c: ported r344 (spelling fixes) from trunk."
Sending        integer.c
Transmitting file data .
Committed revision 360.

正如您将在下一节中看到,这是一个非常重要的“最佳实践”。

注意:虽然 svn diffsvn merge 在概念上非常相似,但在许多情况下它们确实具有不同的语法。请务必在 第 9 章,Subversion 全面参考 中阅读有关它们的详细信息,或询问 svn help。例如, svn merge 需要一个工作副本路径作为目标,即应用树更改的位置。如果没有指定目标,它会假设您要执行以下常见操作之一

  1. 您想将目录更改合并到当前工作目录中。

  2. 您想将特定文件中的更改合并到当前工作目录中同名文件中。

如果您要合并目录并且没有指定目标路径, svn merge 会假设上面的第一种情况,并尝试将更改应用到您的当前目录中。如果您要合并文件,并且该文件(或同名文件)存在于您的当前工作目录中, svn merge 会假设第二种情况,并尝试将更改应用到同名本地文件中。

如果您想将更改应用到其他地方,您需要说明。例如,如果您位于工作副本的父目录中,您需要指定接收更改的目标目录

$ svn merge -c 344 http://svn.example.com/repos/calc/trunk my-calc-branch
U   my-calc-branch/integer.c

合并背后的关键概念

您现在已经看到一个 svn merge 命令的示例,您还将看到更多示例。如果您对合并的确切工作原理感到困惑,您并不孤单。许多用户(尤其是那些刚接触版本控制的用户)最初对命令的正确语法以及何时以及如何使用该功能感到困惑。但不要害怕,这个命令实际上比您想象的要简单得多!有一个非常简单的技巧可以帮助您理解 svn merge 的确切行为。

主要的困惑来源是命令的 名称。“合并”这个词 somehow 表示分支被合并在一起,或者有一些神秘的数据混合正在发生。情况并非如此。这个命令可能有一个更好的名称,比如 svn diff-and-apply,因为这就是所有发生的事情:比较两个存储库树,并将差异应用到工作副本。

该命令接受三个参数

  1. 一个初始存储库树(通常称为比较的 左侧),

  2. 一个最终存储库树(通常称为比较的 右侧),

  3. 一个工作副本,用于接受差异作为本地更改(通常称为合并的 目标)。

一旦指定了这三个参数,就会比较这两个树,并将生成的差异作为本地修改应用到目标工作副本中。命令完成后,结果与您手动编辑文件或自己运行各种 svn addsvn delete 命令相同。如果您喜欢结果,您可以提交它们。如果您不喜欢结果,您只需 svn revert 所有更改。

svn merge 的语法允许您以相当灵活的方式指定三个必要的参数。以下是一些示例

$ svn merge http://svn.example.com/repos/branch1@150 \
            http://svn.example.com/repos/branch2@212 \
            my-working-copy

$ svn merge -r 100:200 http://svn.example.com/repos/trunk my-working-copy

$ svn merge -r 100:200 http://svn.example.com/repos/trunk

第一个语法明确列出了所有三个参数,以 URL@REV 的形式命名每个树,并命名工作副本目标。第二个语法可以作为一种简写,用于比较同一 URL 的两个不同修订版的情况。最后一个语法显示了工作副本参数是可选的;如果省略,它默认为当前目录。

合并的最佳实践

手动跟踪合并

合并更改听起来很简单,但在实践中可能成为一个难题。问题是,如果您反复将更改从一个分支合并到另一个分支,您可能会不小心 两次 合并相同的更改。当这种情况发生时,有时一切都会正常。在修补文件时,Subversion 通常会注意到文件是否已经存在更改,并且不会执行任何操作。但是,如果已经存在的更改以任何方式被修改,您将获得冲突。

理想情况下,您的版本控制系统应该防止将更改重复应用到分支中。它应该自动记住一个分支已经接收了哪些更改,并能够列出它们。它应该利用此信息尽可能地帮助自动执行合并。

不幸的是,Subversion 并不是这样的系统;它还没有记录有关合并操作的任何信息。 [22] 当您提交本地修改时,存储库不知道这些更改是来自运行 svn merge 还是仅仅手动编辑文件。

这对您,用户来说意味着什么?这意味着在 Subversion 增长此功能之前,您将不得不自己跟踪合并信息。执行此操作的最佳位置是提交日志消息。如之前的示例所示,建议您的日志消息提及正在合并到您的分支的特定修订版本号(或修订版本范围)。稍后,您可以运行 svn log 来查看您的分支中已经包含了哪些更改。这将使您能够仔细构建后续的 svn merge 命令,该命令不会与之前移植的更改冗余。

在下一节中,我们将展示此技术的实际示例。

预览合并

首先,请务必将合并操作执行到 没有本地编辑且最近已更新的工作副本中。如果您的工作副本在这两种情况下都不“干净”,您可能会遇到一些麻烦。

假设您的工作副本井井有条,合并并不是一项风险很高的操作。如果您第一次合并错误,只需 svn revert 更改并重试。

如果您已合并到已经存在本地修改的工作副本中,则合并应用的更改将与您现有的更改混合在一起,并且运行 svn revert 将不再是选项。这两组更改可能无法分离。

在这种情况下,人们会很高兴能够在合并发生之前预测或检查合并。一种简单的方法是在运行 svn diff 时使用与计划传递给 svn merge 的参数相同,就像我们在合并的第一个示例中已经展示的那样。另一种预览方法是将 --dry-run 选项传递给合并命令

$ svn merge --dry-run -c 344 http://svn.example.com/repos/calc/trunk
U  integer.c

$ svn status
#  nothing printed, working copy is still unchanged.

--dry-run 选项实际上不会将任何本地更改应用于工作副本。它只显示 在实际合并中打印的状态代码。对于那些运行 svn diff 提供太多细节的情况,它有助于获得潜在合并的“高级”预览。

合并冲突

就像 svn update 命令一样,svn merge 将更改应用于您的工作副本。因此,它也能够创建冲突。但是,svn merge 生成的冲突有时会有所不同,本节将解释这些差异。

首先,假设您的工作副本没有本地编辑。当您 svn update 到特定修订版本时,服务器发送的更改将始终“干净地”应用于您的工作副本。服务器通过比较两棵树来生成增量:工作副本的虚拟快照和您感兴趣的修订版本树。因为比较的左侧与您已经拥有的内容完全相同,所以增量保证能够正确地将您的工作副本转换为右侧树。

但是,svn merge 没有这样的保证,并且可能更加混乱:用户可以要求服务器比较 任何 两棵树,即使它们与工作副本无关!这意味着存在巨大的潜在人为错误。用户有时会比较错误的两棵树,从而创建无法干净应用的增量。 svn merge 会尽力应用尽可能多的增量,但有些部分可能无法应用。就像 Unix patch 命令有时会抱怨“失败的块”一样,svn merge 会抱怨“跳过的目标”。

$ svn merge -r 1288:1351 http://svn.example.com/repos/branch
U  foo.c
U  bar.c
Skipped missing target: 'baz.c'
U  glub.c
C  glorb.h

$

在前面的示例中,可能是 baz.c 存在于正在比较的两个分支的快照中,并且生成的增量想要更改文件的內容,但文件不存在于工作副本中。无论如何,“跳过”消息意味着用户很可能正在比较错误的两棵树;它们是用户错误的典型标志。发生这种情况时,可以轻松地递归地还原合并创建的所有更改(svn revert --recursive),删除还原后留下的任何未版本化文件或目录,然后使用不同的参数重新运行 svn merge

还要注意,前面的示例显示了 glorb.h 上发生的冲突。我们已经说过工作副本没有本地编辑:冲突怎么可能发生?同样,由于用户可以使用 svn merge 将任何旧增量定义并应用于工作副本,因此该增量可能包含无法干净应用于工作文件的文本更改,即使该文件没有本地修改。

svn updatesvn merge 之间的另一个细微区别是发生冲突时创建的全文本文件的名称。在 名为“解决冲突(合并他人的更改)”的部分 中,我们看到更新会生成名为 filename.minefilename.rOLDREVfilename.rNEWREV 的文件。但是,当 svn merge 产生冲突时,它会创建三个名为 filename.workingfilename.leftfilename.right 的文件。在这种情况下,术语“left”和“right”描述了文件来自双树比较的哪一边。无论如何,这些不同的名称将帮助您区分由于更新而发生的冲突与由于合并而发生的冲突。

注意或忽略祖先

与 Subversion 开发人员交谈时,您很可能会听到对术语 ancestry 的引用。这个词用于描述存储库中两个对象之间的关系:如果它们彼此相关,则一个对象被称为另一个对象的祖先。

例如,假设您提交了修订版本 100,其中包括对文件 foo.c 的更改。那么 foo.c@99foo.c@100 的“祖先”。另一方面,假设您在修订版本 101 中提交了 foo.c 的删除操作,然后在修订版本 102 中添加了同名的新文件。在这种情况下,foo.c@99foo.c@102 似乎是相关的(它们具有相同的路径),但实际上它们是存储库中完全不同的对象。它们没有共享历史或“祖先”。

提出这一点的原因是指出 svn diffsvn merge 之间的重要区别。前者忽略祖先关系,而后者对此很敏感。例如,如果您要求 svn diff 比较 foo.c 的修订版本 99 和 102,您将看到基于行的差异;diff 命令只是盲目地比较两个路径。但是,如果您要求 svn merge 比较相同的两个对象,它会注意到它们不相关,首先尝试删除旧文件,然后添加新文件;输出将显示一个删除操作,然后是一个添加操作。

D  foo.c
A  foo.c

大多数合并都涉及比较彼此之间具有祖先关系的树,因此 svn merge 默认使用此行为。但是,您可能偶尔希望 merge 命令比较两棵不相关的树。例如,您可能导入了两个源代码树,它们代表软件项目的不同供应商版本(请参阅 名为“供应商分支”的部分)。如果您要求 svn merge 比较这两棵树,您将看到第一棵树被完全删除,然后添加第二棵树的全部内容!在这种情况下,您将希望 svn merge 只进行基于路径的比较,忽略文件和目录之间的任何关系。在您的合并命令中添加 --ignore-ancestry 选项,它将表现得像 svn diff。(反之,--notice-ancestry 选项将导致 svn diff 表现得像 merge 命令。)

合并和移动

人们通常希望重构源代码,尤其是在基于 Java 的软件项目中。文件和目录被四处移动和重命名,这通常会给项目的所有成员带来极大的混乱。这听起来像是使用分支的完美案例,不是吗?只需创建一个分支,移动内容,然后将分支合并回主干,对吧?

唉,这种情况现在效果并不理想,被认为是 Subversion 目前的一个弱点。问题是 Subversion 的 update 命令不够健壮,尤其是在处理复制和移动操作时。

当您使用 svn copy 复制文件时,存储库会记住新文件来自哪里,但它没有将该信息传输到正在运行 svn updatesvn merge 的客户端。它不是告诉客户端“将您已经拥有的文件复制到此新位置”,而是发送了一个全新的文件。这可能会导致问题,尤其是因为重命名文件时也会发生同样的事情。Subversion 的一个鲜为人知的事实是它缺乏“真正的重命名”—svn move 命令只不过是 svn copysvn delete 的聚合。

例如,假设在您自己的私人分支上工作时,您将 integer.c 重命名为 whole.c。实际上,您在分支中创建了一个新文件,它是原始文件的副本,并删除了原始文件。同时,在 trunk 上,Sally 提交了对 integer.c 的一些改进。现在您决定将您的分支合并到主干

$ cd calc/trunk

$ svn merge -r 341:405 http://svn.example.com/repos/calc/branches/my-calc-branch
D   integer.c
A   whole.c

乍一看,这似乎没有问题,但它可能也不是您或 Sally 预期的结果。合并操作删除了 integer.c 文件的最新版本(包含 Sally 的最新更改),并盲目添加了您的新 whole.c 文件——它是 integer.c 版本的副本。最终效果是,将您的“重命名”合并到分支中删除了 Sally 从最新修订版本中进行的最近更改!

这不是真正的數據丢失;Sally 的更改仍然存在于存储库的历史记录中,但可能不会立即清楚地意识到发生了这种情况。这个故事的寓意是,在 Subversion 改进之前,请务必小心地将副本和重命名从一个分支合并到另一个分支。



[22] 但是,在撰写本文时,此功能正在开发中!