章节 ▾ 第二版

7.8 Git 工具 - 高级合并

高级合并

在 Git 中合并通常相当简单。由于 Git 使得多次合并同一个分支变得非常容易,这意味着你可以拥有一个长期存在的分支,并在开发过程中不断地对其进行更新,从而在开发早期解决小冲突,而不是在最后面临一个巨大的冲突。

然而,有时确实会发生棘手的冲突。与其他一些版本控制系统不同,Git 在解决合并冲突时不会试图做得过于聪明。Git 的哲学是在确定合并解决方案是否明确时保持智能,但如果存在冲突,它不会尝试自动解决这些冲突。因此,如果你在合并两个快速分叉的分支时等待时间过长,就可能会遇到一些问题。

在本节中,我们将探讨其中一些问题可能是什么,以及 Git 提供了哪些工具来帮助你处理这些更棘手的情况。我们还将介绍你可以执行的一些非标准类型的合并,以及如何撤销你已经执行过的合并。

合并冲突

虽然我们在 基础合并冲突 中介绍了一些解决合并冲突的基础知识,但对于更复杂的冲突,Git 提供了一些工具来帮助你弄清楚发生了什么以及如何更好地处理这些冲突。

首先,如果可能的话,在进行可能出现冲突的合并之前,请尽量确保你的工作目录是干净的。如果你有正在进行的工作,要么将其提交到一个临时分支,要么使用 stash 命令暂存起来。这样做可以让你撤销你在操作中尝试的任何事情。如果你在尝试合并时工作目录中有未保存的更改,下面的一些技巧可能有助于你保留这些工作。

让我们来看一个非常简单的例子。我们有一个超级简单的 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 将使用稍微不同的冲突标记版本,不仅为你提供“我们的”和“他们的”版本,还会内联提供“基础”版本,为你提供更多上下文。

$ 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),在每一行旁边为你提供两列数据。第一列显示该行在“我们的”分支与工作目录中的文件之间是否不同(添加或删除),第二列显示“他们的”分支与工作目录副本之间是否存在相同的情况。

因此在那个例子中,你可以看到 <<<<<<<>>>>>>> 行在工作副本中,但在合并的任何一方中都没有。这是合理的,因为合并工具为了我们的上下文把它们放在了那里,但我们应该删除它们。

如果我们解决了冲突并再次运行 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,所以弄清楚这里发生了什么应该不难。这是一个快速复习: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 后的历史记录

新的提交 ^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)策略来处理。然而,还有其他合并分支的方法。让我们快速介绍其中几种。

我们的或他们的偏好

首先,我们可以在正常的“递归”合并模式下做另一件有用的事情。我们已经看到了使用 -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 分支(即使修复程序已经在那里了),这样当你以后再次合并 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 read-tree 在 Git 中做到这一点。你将在 Git 内部原理 中了解更多关于 read-tree 及其相关命令的知识,但目前只需知道它会将一个分支的根树读入你当前的暂存区和工作目录。我们刚刚切换回你的 master 分支,并将 rack_branch 分支拉入我们主项目 master 分支的 rack 子目录中

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

当我们提交时,看起来我们在这个子目录中拥有所有的 Rack 文件——就好像我们从压缩包中把它们复制进来一样。有趣的是,我们可以相当容易地将更改从一个分支合并到另一个分支。因此,如果 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