章节 ▾ 2nd Edition (第二版)

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'

这是一个很好的六个总提交的列表,以及每个提交所在的开发线。

我们可以进一步简化这一点,以便为我们提供更具体的上下文。如果我们将 --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()

该格式称为“组合差异”,并在每一行旁边为您提供两列数据。第一列显示该行在“我们的”分支和工作目录中的文件之间是否不同(添加或删除),第二列在“他们的”分支和您的工作目录副本之间执行相同的操作。

所以在该示例中,您可以看到 <<<<<<<>>>>>>> 行在工作副本中,但不在合并的任何一方中。这是有道理的,因为合并工具将它们放在那里是为了我们的上下文,但我们应该删除它们。

如果我们解决冲突并再次运行 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(默认情况下仅显示非合并提交的补丁),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) 中的所有内容。

带有 revert 提交的历史记录如下所示

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

其他类型的合并

到目前为止,我们已经介绍了两个分支的正常合并,通常使用所谓的“递归”合并策略来处理。但是,还有其他方法可以将分支合并在一起。让我们快速介绍其中的一些。

我们的或他们的偏好

首先,我们可以使用正常的“递归”合并模式做另一件有用的事情。我们已经看到了 ignore-all-spaceignore-space-change 选项,它们通过 -X 传递,但我们也可以告诉 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 上的一些 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 文件 - 就像我们从 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