章节 ▾ 第二版

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 将所有这些版本存储在索引中,按“阶段”划分,每个阶段都有关联的数字。阶段 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 会使用稍微不同的冲突标记版本,它不仅提供“我们的”和“他们的”版本,还会提供“基础”版本,以便提供更多上下文。

$ 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 选项运行它,你将只获得导致冲突的文件的 diff。这对于快速提供你所需的上下文,以帮助理解为什么会出现冲突以及如何更智能地解决它,将非常有用。

合并的组合 diff 格式

由于 Git 会暂存任何成功的合并结果,当你在冲突合并状态下运行 git diff 时,你只会看到仍然处于冲突状态的内容。这有助于了解你仍然需要解决什么。

当你直接在合并冲突后运行 git diff 时,它会以一种相当独特的 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”,并在每行旁边提供两列数据。第一列显示该行在“我们的”分支和你的工作目录文件之间是否有差异(添加或删除),第二列显示“他们的”分支和你的工作目录副本之间是否有差异。

所以在这个例子中,你可以看到 <<<<<<<>>>>>>> 行存在于工作副本中,但并未存在于合并的任何一方。这是有道理的,因为合并工具为了我们的上下文放入了它们,但我们应该删除它们。

如果我们解决了冲突并再次运行 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~ 后的历史

我们已经在 Git Reset 详解 中介绍过 reset,所以理解这里发生了什么应该不难。这里快速回顾一下:reset --hard 通常经过三个步骤:

  1. 移动 HEAD 指向的分支。在这种情况下,我们想将 master 移回合并提交(C6)之前的位置。

  2. 使索引看起来像 HEAD。

  3. 使工作目录看起来像索引。

这种方法的缺点是它会重写历史,这对于共享存储库来说可能是有问题的。请查看 Rebase 的危险 以了解更多可能发生的情况;简而言之,如果其他人拥有你正在重写的提交,你应该避免 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)的所有内容。

带有 revert 提交的历史看起来像这样:

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. 错误合并的历史

解决这个问题最好的方法是反转原始合并,因为你现在想将已反转的更改重新引入,然后创建一个新的合并提交。

$ 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”合并策略。然而,还有其他方法可以将分支合并在一起。让我们快速介绍其中一些。

偏向我方或对方

首先,我们可以在正常的“recursive”合并模式下做另一件有用的事情。我们已经看到了使用 -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”合并*策略*。这与“ours”递归合并*选项*不同。

这基本上会进行一个假的合并。它将记录一个以两个分支为父项的新合并提交,但它根本不会查看你正在合并的分支。它只会将你当前分支中的代码作为合并结果记录下来。

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

你可以看到,你所在的分支与合并结果之间没有任何区别。

这通常很有用,可以让你在稍后合并时欺骗 Git,让它认为一个分支已经被合并了。例如,假设你从一个 release 分支分叉出来,并在上面做了一些工作,这些工作你以后会想合并回你的 master 分支。与此同时,master 中的一个 bugfix 需要反向移植到你的 release 分支。你可以将 bugfix 分支合并到 release 分支,并且还可以使用 merge -s ours 将同一个分支合并到你的 master 分支(即使修复已经存在),这样当你稍后再次合并 release 分支时,就不会因为 bugfix 而产生冲突。

子目录合并

子目录合并的想法是你有两个项目,其中一个项目映射到另一个项目的子目录。当你指定子目录合并时,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 文件都在该子目录下——就像我们从一个 tar 包中复制进来一样。有趣的是,我们可以相当容易地将一个分支中的更改合并到另一个分支。所以,如果 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——以查看是否需要合并它们——你不能使用普通的 diff 命令。相反,你必须运行 git diff-tree 并指定要与之比较的分支。

$ git diff-tree -p rack_branch

或者,要将你的 rack 子目录中的内容与服务器上的 master 分支在你上次获取时的内容进行比较,你可以运行:

$ git diff-tree -p rack_remote/master