章节 ▾ 第二版

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