在分支間複製修改

現在你與Sally在同一個項目的並行分支上工作:你在私有分支上,而Sally在主幹(trunk)或者叫做開發主線上。

由於有眾多的人參與項目,大多數人擁有主幹拷貝是很正常的,任何人如果進行一個長週期的修改會使得主幹陷入混亂,所以通常的做法是建立一個私有分支,提交修改到自己的分支,直到這階段工作結束。

所以,好消息就是你和Sally不會互相打擾,壞消息是有時候分離會遠。記住「閉門造車」策略的問題,當你完成你的分支後,可能因為太多衝突,已經無法輕易合併你的分支和主幹的修改。

相反,在你工作的時候你和Sally仍然可以繼續分享修改,這依賴於你決定什麼值得分享,Subversion給你在分支間選擇性「拷貝」修改的能力,當你完成了分支上的所有工作,所有的分支修改可以被拷貝回到主幹。

複製特定的修改

在上一章節,我們提到你和Sally對integer.c在不同的分支上做過修改,如果你看了Sally的344版本的日誌訊息,你會知道她修正了一些拼寫錯誤,毋庸置疑,你的拷貝的文件也一定存在這些拼寫錯誤,所以你以後的對這個文件修改也會保留這些拼寫錯誤,所以你會在將來合併時得到許多衝突。最好是現在接收Sally的修改,而不是作了許多工作之後才來做。

是時間使用svn merge命令,這個命令的結果非常類似svn diff命令(在第 2 章 基本使用的內容),兩個命令都可以比較版本庫中的任何兩個對象並且描述其區別,舉個例子,你可以使用svn diff來查看Sally在版本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文件已經作了補綴(Patch)(patched),現在已經保留了Sally修改—修改從主幹「拷貝」到你的私有分支的工作副本,現在作為一個本地修改,在這種情況下,要靠你審查本地的修改來確定它們工作正常。

在另一種情境下,事情並不會運行得這樣正常,也許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的行為。

迷惑的主要原因是這個命令的名稱,術語「合併」不知什麼原因被用來表明分支的組合,或者是其他什麼神奇的資料混合,這不是事實,一個更好的名稱應該是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的不同版本的簡略寫法,最後一種語法表示工作副本是可選的,如果省略,默認是當前目錄。

合併的最佳實踐

手工追蹤合併

合併修改聽起來很簡單,但是實踐起來會是很頭痛的事,如果你重複合併兩個分支,你也許會合併兩次同樣的修改。當這種事情發生時,有時候事情會依然正常,當對文件打補綴(Patch)時,Subversion如果注意到這個文件已經有了相應的修改,而不會作任何操作,但是如果已經應用的修改又被修改了,你會得到衝突。

理想情況下,你的版本控制系統應該會阻止對一個分支做兩次改變操作,必須自動的記住那一個分支的修改已經接收了,並且可以顯示出來,用來盡可能幫助自動化的合併。

不幸的是,Subversion不是這樣一個系統,類似於CVS,Subversion並不記錄任何合併操作,[21]當你提交本地修改,版本庫並不能判斷出你是通過svn merge還是手工修改得到這些文件。

這對你這樣的用戶意味著什麼?這意味著除非Subversion以後發展這個特性,你必須手工的記錄這些訊息。最佳的方式是使用提交日誌訊息,像前面的例子提到的,推薦你在日誌訊息中說明合併的特定版本號(或是版本號的範圍),之後,你可以運行svn log來查看你的分支包含哪些修改。這可以幫助你小心的依序運行svn merge命令而不會進行多餘的合併。

在下一小節,我們要展示一些這種技巧的例子。

預覽合併

首先,一定要記住合併的工作副本沒有本地更改,並且最近已更新過。如果你的工作副本用這樣的方法「清理」,你會發現一些頭痛的事情。

因為合併只是導致本地修改,它不是一個高風險的操作,如果你在第一次操作錯誤,你可以運行svn revert來再試一次。

有時候你的工作副本很可能已經改變了,合併會針對存在的那一個文件,這時運行svn revert不會恢復你在本地作的修改,兩部分的修改無法識別出來。

在這個情況下,人們很樂意能夠在合併之前預測一下,一個簡單的方法是使用運行svn merge同樣的參數運行svn diff,另一種方式是傳遞--dry-run選項給merge命令來預覽:

$ 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命令有時候會報告「failed hunks」錯誤,svn merge會報告「skipped targets」:

$ 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也許會存在於比較的兩個分支快照裡,但工作副本裡不存在,比較的增量資料要應用到這個文件,這種情況下會發生什麼?「skipped」訊息意味著用戶可能是在比較錯誤的兩棵樹,這是經典的用戶錯誤,當發生這種情況,可以使用迭代恢復(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開發者交談時你一定會聽到提及術語祖先,這個詞是用來描述兩個對象的關係:如果他們互相關聯,一個對象就是另一個的祖先,或者相反。

舉個例子,假設你提交版本100,包括對foo.c的修改,則foo.c@99是foo.c@100的一個「祖先」,另一方面,假設你在版本101刪除這個文件,而在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 diffmerge命令一樣工作。)

合併和移動

一個普遍的願望是重構原始碼程式,特別是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複製品。最終的結果是將你的「rename」合併到分支,並且從最新修訂版本刪除了Sally最近的修改。

這不是真的資料丟失;Sally的修改還在版本庫的歷史中,但是。在Subversion改進之前,最好小心對分支進行合併和改名。



[21] 然而,寫這些的時候,這些特性正在實現中!