章節 ▾ 第二版

7.8 Git工具 - 高階合併

高階合併

在 Git 中合併通常相當容易。由於 Git 使得多次合併其他分支變得容易,這意味著您可以擁有一個非常長期的分支,但可以隨之保持更新,經常解決小衝突,而不是在最後被一個巨大的衝突所震驚。

然而,有時確實會發生棘手的衝突。與其他一些版本控制系統不同,Git 在合併衝突解決方面並不會過於聰明。Git 的理念是聰明地判斷何時合併解決是明確的,但如果存在衝突,它不會試圖巧妙地自動解決它。因此,如果你等待太久才合併兩個快速分歧的分支,你可能會遇到一些問題。

在本節中,我們將探討這些問題可能是什麼,以及 Git 提供了哪些工具來幫助你處理這些更棘手的情況。我們還將介紹一些不同、非標準的合併型別,並瞭解如何撤銷已完成的合併。

合併衝突

雖然我們在 基本合併衝突 中介紹了一些解決合併衝突的基礎知識,但對於更復雜的衝突,Git 提供了幾個工具來幫助你弄清楚發生了什麼以及如何更好地處理衝突。

首先,如果可能的話,在執行可能存在衝突的合併操作之前,請確保你的工作目錄是乾淨的。如果你有正在進行的工作,要麼提交到臨時分支,要麼暫存起來。這使你可以撤銷在這裡嘗試的任何操作。如果你在嘗試合併時工作目錄中有未儲存的更改,其中一些技巧可能會幫助你保留這些工作。

讓我們來看一個非常簡單的例子。我們有一個非常簡單的 Ruby 檔案,它列印 'hello world'。

#! /usr/bin/env ruby

def hello
  puts 'hello world'
end

hello()

在我們的倉庫中,我們建立一個名為 whitespace 的新分支,然後將所有 Unix 行尾符更改為 DOS 行尾符,這實際上更改了檔案的每一行,但只是關於空格。然後我們將“hello world”這一行更改為“hello mundo”。

$ git checkout -b whitespace
Switched to a new branch 'whitespace'

$ unix2dos hello.rb
unix2dos: converting file hello.rb to DOS format ...
$ git commit -am 'Convert hello.rb to DOS'
[whitespace 3270f76] Convert hello.rb to DOS
 1 file changed, 7 insertions(+), 7 deletions(-)

$ vim hello.rb
$ git diff -b
diff --git a/hello.rb b/hello.rb
index ac51efd..e85207e 100755
--- a/hello.rb
+++ b/hello.rb
@@ -1,7 +1,7 @@
 #! /usr/bin/env ruby

 def hello
-  puts 'hello world'
+  puts 'hello mundo'^M
 end

 hello()

$ git commit -am 'Use Spanish instead of English'
[whitespace 6d338d2] Use Spanish instead of English
 1 file changed, 1 insertion(+), 1 deletion(-)

現在我們切換回 master 分支併為該函式新增一些文件。

$ git checkout master
Switched to branch 'master'

$ vim hello.rb
$ git diff
diff --git a/hello.rb b/hello.rb
index ac51efd..36c06c8 100755
--- a/hello.rb
+++ b/hello.rb
@@ -1,5 +1,6 @@
 #! /usr/bin/env ruby

+# prints out a greeting
 def hello
   puts 'hello world'
 end

$ git commit -am 'Add comment documenting the function'
[master bec6336] Add comment documenting the function
 1 file changed, 1 insertion(+)

現在我們嘗試合併 whitespace 分支,由於空格更改,我們將遇到衝突。

$ git merge whitespace
Auto-merging hello.rb
CONFLICT (content): Merge conflict in hello.rb
Automatic merge failed; fix conflicts and then commit the result.

中止合併

我們現在有幾個選項。首先,讓我們來了解如何擺脫這種情況。如果你可能沒有預料到衝突,並且暫時不想處理這種情況,你可以簡單地使用 git merge --abort 退出合併。

$ git status -sb
## master
UU hello.rb

$ git merge --abort

$ git status -sb
## master

git merge --abort 選項嘗試恢復到你執行合併之前的狀態。它無法完美執行的唯一情況是,如果你在執行它時工作目錄中存在未暫存、未提交的更改,否則它應該執行良好。

如果由於某種原因你只想重新開始,你也可以執行 git reset --hard HEAD,你的倉庫將回到上次提交的狀態。請記住,任何未提交的工作都將丟失,因此請確保你不希望保留任何更改。

忽略空格

在這個特定案例中,衝突與空格相關。我們知道這一點是因為情況很簡單,但在實際案例中檢視衝突時也很容易判斷,因為每一行都在一側被刪除並在另一側重新新增。預設情況下,Git 將所有這些行都視為已更改,因此它無法合併檔案。

然而,預設的合併策略可以接受引數,其中一些引數是關於正確忽略空格更改的。如果你發現合併中有很多空格問題,你可以簡單地中止它並再次執行,這次使用 -Xignore-all-space-Xignore-space-change。第一個選項在比較行時完全忽略空格,第二個選項將一個或多個空格字元序列視為等效。

$ git merge -Xignore-space-change whitespace
Auto-merging hello.rb
Merge made by the 'recursive' strategy.
 hello.rb | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

由於在本例中,實際的檔案更改沒有衝突,一旦我們忽略了空格更改,所有內容都能很好地合併。

如果你的團隊中有人喜歡偶爾將所有內容從空格重新格式化為製表符,反之亦然,這會是一個救星。

手動檔案重新合併

儘管 Git 處理空格預處理相當好,但還有其他型別的更改可能是 Git 無法自動處理的,但可以透過指令碼修復。例如,讓我們假設 Git 無法處理空格更改,我們需要手動完成。

我們真正需要做的是,在嘗試實際的檔案合併之前,將我們要合併的檔案透過 dos2unix 程式執行一次。那麼我們該如何操作呢?

首先,我們進入合併衝突狀態。然後,我們想要獲取我們版本的檔案副本、他們的版本(來自我們正在合併的分支)以及共同版本(來自雙方分支的起點)。然後我們想修復他們的一方或我們的一方,並針對這一個檔案再次嘗試合併。

獲取這三個檔案版本實際上非常容易。Git 將所有這些版本儲存在索引中,稱為“階段”(stages),每個階段都有相應的數字。階段 1 是共同祖先,階段 2 是你的版本,階段 3 來自 MERGE_HEAD,是你正在合併的版本(“他們的”)。

你可以使用 git show 命令和一種特殊語法來提取衝突檔案每個版本的副本。

$ git show :1:hello.rb > hello.common.rb
$ git show :2:hello.rb > hello.ours.rb
$ git show :3:hello.rb > hello.theirs.rb

如果你想更深入一點,你也可以使用 ls-files -u 底層命令來獲取這些檔案每個 Git blob 的實際 SHA-1 值。

$ git ls-files -u
100755 ac51efdc3df4f4fd328d1a02ad05331d8e2c9111 1	hello.rb
100755 36c06c8752c78d2aff89571132f3bf7841a7b5c3 2	hello.rb
100755 e85207e04dfdd5eb0a1e9febbc67fd837c44a1cd 3	hello.rb

:1:hello.rb 只是查詢該 blob SHA-1 的簡寫形式。

現在我們工作目錄中有了所有三個階段的內容,我們可以手動修復他們的檔案以解決空格問題,並使用鮮為人知的 git merge-file 命令重新合併檔案,該命令正是為此而生。

$ dos2unix hello.theirs.rb
dos2unix: converting file hello.theirs.rb to Unix format ...

$ git merge-file -p \
    hello.ours.rb hello.common.rb hello.theirs.rb > hello.rb

$ git diff -b
diff --cc hello.rb
index 36c06c8,e85207e..0000000
--- a/hello.rb
+++ b/hello.rb
@@@ -1,8 -1,7 +1,8 @@@
  #! /usr/bin/env ruby

 +# prints out a greeting
  def hello
-   puts 'hello world'
+   puts 'hello mundo'
  end

  hello()

此時我們已經很好地合併了檔案。事實上,這比 ignore-space-change 選項效果更好,因為它在合併之前實際修復了空格更改,而不是簡單地忽略它們。在 ignore-space-change 合併中,我們最終得到了一些帶有 DOS 行尾符的行,使得內容混雜。

如果你想在最終確定這次提交之前瞭解雙方之間實際更改了什麼,你可以讓 git diff 將你工作目錄中即將作為合併結果提交的內容與這些階段中的任何一個進行比較。讓我們逐一檢視。

要將你的結果與合併前分支中的內容進行比較,換句話說,要檢視合併引入了什麼,你可以執行 git diff --ours

$ git diff --ours
* Unmerged path hello.rb
diff --git a/hello.rb b/hello.rb
index 36c06c8..44d0a25 100755
--- a/hello.rb
+++ b/hello.rb
@@ -2,7 +2,7 @@

 # prints out a greeting
 def hello
-  puts 'hello world'
+  puts 'hello mundo'
 end

 hello()

所以在這裡我們可以很容易地看到,我們的分支中發生的事情,即我們透過這次合併實際引入到這個檔案中的內容,是更改了那一行。

如果我們想檢視合併結果與他們一側的內容有何不同,你可以執行 git diff --theirs。在此示例及後續示例中,我們必須使用 -b 來去除空格,因為我們將其與 Git 中的內容進行比較,而不是我們清理過的 hello.theirs.rb 檔案。

$ git diff --theirs -b
* Unmerged path hello.rb
diff --git a/hello.rb b/hello.rb
index e85207e..44d0a25 100755
--- a/hello.rb
+++ b/hello.rb
@@ -1,5 +1,6 @@
 #! /usr/bin/env ruby

+# prints out a greeting
 def hello
   puts 'hello mundo'
 end

最後,你可以使用 git diff --base 檢視檔案在雙方是如何變化的。

$ git diff --base -b
* Unmerged path hello.rb
diff --git a/hello.rb b/hello.rb
index ac51efd..44d0a25 100755
--- a/hello.rb
+++ b/hello.rb
@@ -1,7 +1,8 @@
 #! /usr/bin/env ruby

+# prints out a greeting
 def hello
-  puts 'hello world'
+  puts 'hello mundo'
 end

 hello()

此時我們可以使用 git clean 命令清除我們為手動合併而建立但不再需要的額外檔案。

$ git clean -f
Removing hello.common.rb
Removing hello.ours.rb
Removing hello.theirs.rb

檢出衝突

也許由於某種原因,我們對當前的解決方案不滿意,或者手動編輯一方或雙方仍然效果不佳,我們需要更多上下文。

讓我們稍微改變一下例子。在這個例子中,我們有兩個存在時間較長的分支,每個分支都有一些提交,但在合併時會產生合法的內容衝突。

$ git log --graph --oneline --decorate --all
* f1270f7 (HEAD, master) Update README
* 9af9d3b Create README
* 694971d Update phrase to 'hola world'
| * e3eb223 (mundo) Add more tests
| * 7cff591 Create initial testing script
| * c3ffff1 Change text to 'hello mundo'
|/
* b7dcc89 Initial hello world code

我們現在有三個獨特的提交,它們只存在於 master 分支上,另有三個存在於 mundo 分支上。如果我嘗試合併 mundo 分支,我們會得到一個衝突。

$ git merge mundo
Auto-merging hello.rb
CONFLICT (content): Merge conflict in hello.rb
Automatic merge failed; fix conflicts and then commit the result.

我們想看看合併衝突是什麼。如果我們開啟檔案,會看到類似這樣的內容:

#! /usr/bin/env ruby

def hello
<<<<<<< HEAD
  puts 'hola world'
=======
  puts 'hello mundo'
>>>>>>> mundo
end

hello()

合併的雙方都向此檔案添加了內容,但有些提交在相同位置修改了檔案,從而導致了此衝突。

讓我們探討幾個你現在可以使用的工具,以確定這個衝突是如何產生的。或許不清楚你到底該如何修復這個衝突。你需要更多的上下文。

一個有用的工具是帶有 --conflict 選項的 git checkout。這將再次重新檢出檔案並替換合併衝突標記。如果你想重置標記並再次嘗試解決它們,這會很有用。

你可以給 --conflict 傳遞 diff3merge(這是預設值)。如果你傳遞 diff3,Git 將使用略有不同的衝突標記版本,它不僅會提供“ours”和“theirs”版本,還會將“base”版本內聯顯示,以提供更多上下文。

$ git checkout --conflict=diff3 hello.rb

一旦我們執行它,檔案將變成這樣:

#! /usr/bin/env ruby

def hello
<<<<<<< ours
  puts 'hola world'
||||||| base
  puts 'hello world'
=======
  puts 'hello mundo'
>>>>>>> theirs
end

hello()

如果你喜歡這種格式,可以透過將 merge.conflictstyle 設定為 diff3 來將其設定為未來合併衝突的預設值。

$ git config --global merge.conflictstyle diff3

git checkout 命令還可以接受 --ours--theirs 選項,這是一種無需合併即可快速選擇某一方的便捷方式。

這對於二進位制檔案的衝突特別有用,你可以簡單地選擇其中一方;或者當你只想從另一個分支合併某些檔案時——你可以執行合併,然後在提交之前從其中一方或另一方檢出特定檔案。

合併日誌

解決合併衝突時另一個有用的工具是 git log。這可以幫助你瞭解哪些內容可能導致了衝突。回顧一些歷史記錄,以記住為什麼兩條開發線觸及了程式碼的同一區域,有時會非常有幫助。

要獲取參與此合併的任一分支中包含的所有唯一提交的完整列表,我們可以使用在 三點語法 中學到的“三點”語法。

$ git log --oneline --left-right HEAD...MERGE_HEAD
< f1270f7 Update README
< 9af9d3b Create README
< 694971d Update phrase to 'hola world'
> e3eb223 Add more tests
> 7cff591 Create initial testing script
> c3ffff1 Change text to 'hello mundo'

這是一個很好的列表,顯示了所有六個相關提交,以及每個提交所屬的開發線。

然而,我們可以進一步簡化這一點,以提供更具體的上下文。如果我們在 git log 中新增 --merge 選項,它將只顯示合併雙方中涉及當前衝突檔案的提交。

$ git log --oneline --left-right --merge
< 694971d Update phrase to 'hola world'
> c3ffff1 Change text to 'hello mundo'

如果你改為使用 -p 選項執行它,你將只獲取到最終發生衝突的檔案的差異。這對於快速為你提供理解衝突原因以及如何更明智地解決衝突所需的上下文非常有幫助。

組合差異格式

由於 Git 會暫存任何成功的合併結果,當你在衝突合併狀態下執行 git diff 時,你只會得到當前仍處於衝突狀態的內容。這有助於檢視你仍需要解決的問題。

當你直接在合併衝突後執行 git diff 時,它將以一種相當獨特的差異輸出格式提供資訊。

$ git diff
diff --cc hello.rb
index 0399cd5,59727f0..0000000
--- a/hello.rb
+++ b/hello.rb
@@@ -1,7 -1,7 +1,11 @@@
  #! /usr/bin/env ruby

  def hello
++<<<<<<< HEAD
 +  puts 'hola world'
++=======
+   puts 'hello mundo'
++>>>>>>> mundo
  end

  hello()

這種格式稱為“組合差異”(Combined Diff),它在每行旁邊為你提供兩列資料。第一列顯示“ours”分支與你工作目錄中的檔案之間該行是否不同(新增或刪除),第二列顯示“theirs”分支與你工作目錄副本之間是否相同。

因此在該示例中,你可以看到 <<<<<<<>>>>>>> 這些行存在於工作副本中,但不存在於合併的任何一方。這很合理,因為合併工具將它們放在那裡是為了提供上下文,但我們應該刪除它們。

如果我們解決衝突並再次執行 git diff,我們將看到相同的內容,但它會更有用一些。

$ vim hello.rb
$ git diff
diff --cc hello.rb
index 0399cd5,59727f0..0000000
--- a/hello.rb
+++ b/hello.rb
@@@ -1,7 -1,7 +1,7 @@@
  #! /usr/bin/env ruby

  def hello
-   puts 'hola world'
 -  puts 'hello mundo'
++  puts 'hola mundo'
  end

  hello()

這向我們表明,“hola world”在我們的一側但不在工作副本中,“hello mundo”在他們的一側但不在工作副本中,最後,“hola mundo”不在任何一側但現在在工作副本中。這在提交解決方案之前進行審查會很有用。

你也可以從任何合併的 git log 中獲取此資訊,以檢視事後如何解決問題。如果你在合併提交上執行 git show,或者在 git log -p 中新增 --cc 選項(預設情況下只顯示非合併提交的補丁),Git 將輸出這種格式。

$ git log --cc -p -1
commit 14f41939956d80b9e17bb8721354c33f8d5b5a79
Merge: f1270f7 e3eb223
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri Sep 19 18:14:49 2014 +0200

    Merge branch 'mundo'

    Conflicts:
        hello.rb

diff --cc hello.rb
index 0399cd5,59727f0..e1d0799
--- a/hello.rb
+++ b/hello.rb
@@@ -1,7 -1,7 +1,7 @@@
  #! /usr/bin/env ruby

  def hello
-   puts 'hola world'
 -  puts 'hello mundo'
++  puts 'hola mundo'
  end

  hello()

撤銷合併

既然你已經知道如何建立合併提交,你很可能會不小心犯一些錯誤。使用 Git 的一大優點是,犯錯沒關係,因為修復它們是可能的(在許多情況下也很容易)。

合併提交也不例外。假設你開始在一個主題分支上工作,不小心將其合併到 master,現在你的提交歷史看起來像這樣:

Accidental merge commit
圖 155. 意外的合併提交

解決這個問題有兩種方法,這取決於你想要達到的結果。

修復引用

如果不需要的合併提交只存在於你的本地倉庫中,最簡單和最好的解決方案是移動分支,使它們指向你想要的位置。在大多數情況下,如果你在錯誤的 git merge 之後緊接著使用 git reset --hard HEAD~,這將重置分支指標,使它們看起來像這樣:

History after `git reset --hard HEAD~`
圖 156. git reset --hard HEAD~ 後的歷史

我們在 重置揭秘 中介紹了 reset,所以理解這裡發生的事情應該不難。這裡快速回顧一下:reset --hard 通常會經過三個步驟:

  1. 移動 HEAD 指向的分支。在這種情況下,我們想將 master 移動到合併提交(C6)之前的位置。

  2. 使索引看起來與 HEAD 相同。

  3. 使工作目錄看起來與索引相同。

這種方法的缺點是它會重寫歷史,這對於共享倉庫來說可能會有問題。請檢視 變基的風險 瞭解可能發生的情況;簡而言之,如果其他人已經擁有你正在重寫的提交,你可能應該避免使用 reset。如果合併後已經建立了任何其他提交,這種方法也不會奏效;移動引用會有效地丟失這些更改。

反轉提交

如果移動分支指標對你不起作用,Git 提供了建立新提交的選項,該提交會撤銷現有提交的所有更改。Git 將此操作稱為“revert”(反轉),在此特定場景中,你會像這樣呼叫它:

$ git revert -m 1 HEAD
[master b1d8379] Revert "Merge branch 'topic'"

-m 1 標誌表示哪個父提交是“主線”並應保留。當你向 HEAD 呼叫合併(git merge topic)時,新提交有兩個父提交:第一個是 HEADC6),第二個是被合併分支的最新提交(C4)。在這種情況下,我們希望撤銷透過合併父提交 #2(C4)引入的所有更改,同時保留父提交 #1(C6)的所有內容。

包含反轉提交的歷史記錄看起來像這樣:

History after `git revert -m 1`
圖 157. git revert -m 1 後的歷史

新提交 ^M 的內容與 C6 完全相同,因此從這裡開始,就好像從未發生過合併一樣,只是現在未合併的提交仍保留在 HEAD 的歷史中。如果你再次嘗試將 topic 合併到 master,Git 會感到困惑:

$ git merge topic
Already up-to-date.

topic 中沒有 master 無法觸及的內容。更糟糕的是,如果你向 topic 新增工作並再次合併,Git 只會引入自反轉合併以來的更改。

History with a bad merge
圖 158. 錯誤的合併歷史

解決此問題的最佳方法是“un-revert”原始合併(即撤銷反轉),因為現在你想要引入那些被反轉掉的更改,然後建立一個新的合併提交:

$ git revert ^M
[master 09f0126] Revert "Revert "Merge branch 'topic'""
$ git merge topic
History after re-merging a reverted merge
圖 159. 反轉合併後重新合併的歷史

在此示例中,M^M 相互抵消。^^M 有效地合併了來自 C3C4 的更改,而 C8 合併了來自 C7 的更改,因此現在 topic 已完全合併。

其他合併型別

到目前為止,我們已經介紹了兩個分支的常規合併,通常使用所謂的“遞迴”(recursive)合併策略來處理。然而,還有其他方法可以合併分支。讓我們快速介紹其中幾種。

偏好“ours”或“theirs”

首先,在常規的“遞迴”合併模式下,我們還可以做另一件有用的事情。我們已經見過透過 -X 傳遞的 ignore-all-spaceignore-space-change 選項,但我們也可以告訴 Git 在遇到衝突時偏向其中一方。

預設情況下,當 Git 發現兩個正在合併的分支之間存在衝突時,它會在你的程式碼中新增合併衝突標記,並將檔案標記為衝突,然後讓你解決它。如果你希望 Git 簡單地選擇特定的一方並忽略另一方,而不是讓你手動解決衝突,你可以向 merge 命令傳遞 -Xours-Xtheirs

如果 Git 看到這一點,它將不會新增衝突標記。任何可合併的差異,它都會合並。任何衝突的差異,它將簡單地完全選擇你指定的一方,包括二進位制檔案。

如果我們回到之前使用的“hello world”示例,我們可以看到合併我們的分支會導致衝突。

$ git merge mundo
Auto-merging hello.rb
CONFLICT (content): Merge conflict in hello.rb
Resolved 'hello.rb' using previous resolution.
Automatic merge failed; fix conflicts and then commit the result.

但是,如果我們使用 -Xours-Xtheirs 執行它,就不會出現衝突。

$ git merge -Xours mundo
Auto-merging hello.rb
Merge made by the 'recursive' strategy.
 hello.rb | 2 +-
 test.sh  | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)
 create mode 100644 test.sh

在這種情況下,檔案中不會出現衝突標記,一側是“hello mundo”,另一側是“hola world”,它會簡單地選擇“hola world”。然而,該分支上所有其他非衝突的更改都已成功合併。

此選項也可以透過執行類似 git merge-file --ours 的命令傳遞給我們之前看到的 git merge-file 命令,用於單個檔案合併。

如果你想這樣做但又不希望 Git 甚至嘗試合併來自另一側的更改,那麼有一個更嚴厲的選項,即“ours”合併策略。這與“ours”遞迴合併選項不同。

這基本上會執行一次偽合併。它會記錄一個新的合併提交,並將兩個分支都作為父提交,但它甚至不會檢視你正在合併的分支。它只會將你當前分支中的確切程式碼作為合併結果記錄下來。

$ git merge -s ours mundo
Merge made by the 'ours' strategy.
$ git diff HEAD HEAD~
$

你可以看到我們所在的分支與合併結果之間沒有區別。

這通常很有用,可以有效地欺騙 Git,讓它認為某個分支在稍後進行合併時已經合併過。例如,假設你從 release 分支建立了一個新分支,並在此分支上完成了一些工作,你希望在某個時候將其合併回 master 分支。與此同時,master 上的一些錯誤修復需要反向移植到你的 release 分支。你可以將錯誤修復分支合併到 release 分支,同時也將相同的分支 merge -s ours 到你的 master 分支(即使修復已經存在於 master 中),這樣當你稍後再次合併 release 分支時,就不會出現來自該錯誤修復的衝突。

子樹合併

子樹合併的理念是你有兩個專案,其中一個專案對映到另一個專案的子目錄。當你指定子樹合併時,Git 通常足夠聰明,能夠識別出一方是另一方的子樹並進行適當的合併。

我們將透過一個例子來演示如何將一個獨立專案新增到現有專案中,然後將第二個專案的程式碼合併到第一個專案的子目錄中。

首先,我們將 Rack 應用程式新增到我們的專案中。我們將 Rack 專案作為遠端引用新增到我們自己的專案中,然後將其檢出到自己的分支中:

$ git remote add rack_remote https://github.com/rack/rack
$ git fetch rack_remote --no-tags
warning: no common commits
remote: Counting objects: 3184, done.
remote: Compressing objects: 100% (1465/1465), done.
remote: Total 3184 (delta 1952), reused 2770 (delta 1675)
Receiving objects: 100% (3184/3184), 677.42 KiB | 4 KiB/s, done.
Resolving deltas: 100% (1952/1952), done.
From https://github.com/rack/rack
 * [new branch]      build      -> rack_remote/build
 * [new branch]      master     -> rack_remote/master
 * [new branch]      rack-0.4   -> rack_remote/rack-0.4
 * [new branch]      rack-0.9   -> rack_remote/rack-0.9
$ git checkout -b rack_branch rack_remote/master
Branch rack_branch set up to track remote branch refs/remotes/rack_remote/master.
Switched to a new branch "rack_branch"

現在我們有了 Rack 專案的根目錄在我們的 rack_branch 分支中,而我們自己的專案在 master 分支中。如果你先檢出一個,再檢出另一個,你會發現它們有不同的專案根目錄:

$ ls
AUTHORS         KNOWN-ISSUES   Rakefile      contrib         lib
COPYING         README         bin           example         test
$ git checkout master
Switched to branch "master"
$ ls
README

這有點像一個奇怪的概念。你的倉庫中並非所有分支都必須是同一個專案的分支。這不常見,因為很少有用,但讓分支包含完全不同的歷史記錄是相當容易的。

在這種情況下,我們希望將 Rack 專案作為子目錄拉入到我們的 master 專案中。我們可以在 Git 中使用 git read-tree 來完成。你將在 Git 內部原理 中瞭解更多關於 read-tree 及其相關命令的資訊,但現在只需知道它將一個分支的根樹讀入你當前的暫存區和工作目錄。我們剛剛切換回你的 master 分支,然後我們將 rack_branch 分支拉入到我們主專案的 master 分支的 rack 子目錄中:

$ git read-tree --prefix=rack/ -u rack_branch

當我們提交時,看起來所有 Rack 檔案都在該子目錄下——就像我們從一個 tarball 中複製進來的一樣。有趣的是,我們可以相當容易地將更改從一個分支合併到另一個分支。因此,如果 Rack 專案更新,我們可以透過切換到該分支並拉取來引入上游更改:

$ git checkout rack_branch
$ git pull

然後,我們可以將這些更改合併回我們的 master 分支。要拉取更改並預填充提交訊息,請使用 --squash 選項以及遞迴合併策略的 -Xsubtree 選項。遞迴策略在這裡是預設的,但為了清晰起見我們還是列出了它。

$ git checkout master
$ git merge --squash -s recursive -Xsubtree=rack rack_branch
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested

來自 Rack 專案的所有更改都已合併並準備好在本地提交。你也可以做相反的操作——在你的 master 分支的 rack 子目錄中進行更改,然後稍後將它們合併到你的 rack_branch 分支中,以便提交給維護者或推送到上游。

這為我們提供了一種類似於子模組工作流程的方法,而無需使用子模組(我們將在 子模組 中介紹)。我們可以在倉庫中保留包含其他相關專案的分支,並偶爾將其子樹合併到我們的專案中。在某些方面它很好,例如所有程式碼都提交到一處。然而,它也有其他缺點,因為它更復雜一些,並且在重新整合更改或不小心將分支推送到不相關的倉庫時更容易犯錯。

另一個稍微奇怪的地方是,要獲取你的 rack 子目錄中的內容與 rack_branch 分支中的程式碼之間的差異——以檢視是否需要合併它們——你不能使用普通的 diff 命令。相反,你必須使用 git diff-tree 並指定你要比較的分支來執行:

$ git diff-tree -p rack_branch

或者,要將你的 rack 子目錄中的內容與你上次抓取時伺服器上 master 分支的內容進行比較,你可以執行:

$ git diff-tree -p rack_remote/master
scroll-to-top