Git
章節 ▾ 第二版

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 選項會嘗試回復到您執行合併之前的狀態。唯一可能無法完美回復的情況是,當您執行合併時,您的工作目錄中有尚未儲存的變更(unstashed, uncommitted changes),否則應該可以正常運作。

如果因為某些原因您只想重新開始,您也可以執行 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(-)

由於在這個例子中,實際的檔案變更並沒有衝突,一旦我們忽略空白變更,所有內容都會順利合併。

如果您的團隊中有人喜歡不時地將所有內容從空格重新格式化為 Tab 或反之亦然,這絕對是救星。

手動重新合併檔案

雖然 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 plumbing 命令來取得每個檔案的 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'

這是總共六個相關提交的詳細列表,以及每個提交在哪個開發方向上。

不過,我們可以進一步簡化它,為我們提供更具體的背景資訊。如果我們將 --merge 選項添加到 git log,它只會顯示合併雙方中觸及目前有衝突的檔案的提交。

$ 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()

這種格式稱為「組合差異」,在每一行旁邊提供兩欄資料。第一欄顯示該行在「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,或者如果您將 --cc 選項添加到 git log -p (預設情況下只顯示非合併提交的 patch),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 的一個好處是,犯錯沒關係,因為可以(而且在許多情況下很容易)修正它們。

合併提交 (Merge commits) 也沒有什麼不同。假設你開始在主題分支上工作,不小心將其合併到 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) 時,新的提交有兩個父提交:第一個是 HEAD (C6),第二個是正在合併的分支的尖端 (C4)。在這種情況下,我們想要撤銷合併父提交 #2 (C4) 所引入的所有變更,同時保留父提交 #1 (C6) 的所有內容。

還原提交後的歷史記錄看起來像這樣

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

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

$ git merge topic
Already up-to-date.

topic 中沒有任何內容是 master 無法訪問的。更糟糕的是,如果你將工作新增到 topic 並再次合併,Git 只會引入還原合併以來的變更

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

解決這個問題的最佳方法是取消還原原始合併,因為現在你想要引入已還原的變更,然後建立一個新的合併提交

$ 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 已完全合併。

其他類型的合併

到目前為止,我們介紹了兩個分支的正常合併,通常使用所謂的「遞迴」合併策略來處理。但是,還有其他方法可以將分支合併在一起。讓我們快速介紹其中的幾個。

「我們」或「他們」的偏好

首先,我們可以使用普通的「遞迴」合併模式來完成另一件有用的事情。我們已經看過使用 -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 命令,方法是執行類似 git merge-file --ours 來進行個別檔案合併。

如果你想要執行類似的操作,但不希望 Git 嘗試合併另一方的變更,則有一個更嚴厲的選項,即「我們」(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 分支中(即使修復已在那裡),因此當你稍後再次合併 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_branch 分支中擁有 Rack 專案的根目錄,在 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