您的担忧是正确的:Git对语言一无所知,其内置合并算法严格基于逐行比较。但
您不必使用这个内置的合并算法,大多数人使用它是因为它通常可以正常工作,并且没有太多的替代方案。
请注意,这取决于您的合并策略(
-s
参数);下面的文本是针对默认的
recursive
策略。
resolve
策略与
recursive
非常相似;
octopus
策略适用于不止两个提交;
ours
策略则完全不同(与
-X ours
也不一样)。您还可以使用
.gitattributes
和“合并驱动程序”为特定文件选择替代策略或算法。对于Git已经决定信任的“二进制”文件,所有这些都不适用:对于这些文件,它甚至不尝试合并。(我不会在这里涵盖任何内容,只介绍默认的
recursive
策略如何处理文件。)
< h3 >
git merge
如何工作(使用默认的
-s recursive
时)
- 合并以两个提交开始:当前提交(也称为“ours”,“local”和
HEAD
),以及另一个提交(也称为“theirs”和“remote”)
- 合并查找这些提交之间的合并基础
- 通常只有一个其他提交:在隐含分支1连接的第一个点处的提交
- 在某些特殊情况下(多个合并基础候选项),Git 必须发明一个“虚拟合并基础”(但我们将在此忽略这些情况)
- 合并运行两个差异:
git diff base local
和 git diff base other
- 这些都打开了重命名检测
- 您可以自己运行这些相同的差异,以查看合并将看到什么
你可以把这两个差异看作是“我们做了什么”和“他们做了什么”。合并的目标是将“我们做的”和“他们做的”结合起来。这些差异是基于行的,来自最小编辑距离算法,实际上只是Git关于我们做了什么和他们做了什么的猜测。第一个差异(基础与本地)的输出告诉Git哪些基础文件对应哪些本地文件,即如何从当前提交回到基础。然后Git可以使用基础名称来发现其他提交中的重命名或删除。在大多数情况下,我们可以忽略重命名和删除问题,以及新文件创建问题。请注意,Git版本2.9默认为所有差异打开重命名检测功能,而不仅仅是合并差异。 (在早期的Git版本中,您可以通过配置diff.renames为true来自己打开此功能;还可以查看git config设置diff.renameLimit。)
如果文件仅在一个侧面(基地到本地或基地到其他)更改,则Git只需采取这些更改。当文件在两个侧面上都更改时,Git才必须执行三方合并。
要执行三方合并,Git基本上会遍历两个差异(基地到本地和基地到其他),一次“差异块”比较一个,比较已更改的区域。如果每个块影响原始基础文件的不同部分,则Git只需要采取该块。如果某些块影响基础文件的相同部分,则Git尝试采取该更改的一个副本。
例如,如果本地更改说“添加关闭括号行”,而远程更改说“添加(相同位置,相同缩进)关闭括号行”,则Git将只采取一个关闭括号的副本。如果两者都说“删除关闭括号行”,Git将只删除该行一次。
只有当两个差异
冲突时 - 例如,一个说“添加一个缩进12个空格的关闭括号行”,另一个说“添加一个缩进11个空格的关闭括号行” - Git才会宣布冲突。默认情况下,Git将冲突写入文件,显示两组更改 - 如果您将
merge.conflictstyle
设置为
diff3
,还会显示来自文件合并基础版本的代码
。
任何非冲突的差异块都会被应用。如果存在冲突,则Git通常会将文件保留在“冲突合并”状态中。但是,两个
-X
参数(
-X ours
和
-X theirs
)会修改此行为:使用
-X ours
,Git选择在冲突中“我们”的差异块,并将其更改放入其中,忽略“他们”的更改。使用
-X theirs
,Git选择“他们”的差异块,并将其更改放入其中,忽略“我们”的更改。这两个
-X
参数确保Git最终不会声明冲突。
如果Git能够自己解决文件的所有内容,那么它会在工作树和索引/暂存区中返回基础文件、本地更改和其他更改。如果Git无法自行解决所有内容,则会将文件的基础版本、其他版本和本地版本放入索引/暂存区,并使用三个特殊的非零索引槽。工作树版本始终是“Git能够解决的内容加上各种可配置项指定的冲突标记”。每个索引条目都有四个插槽,通常情况下,像
foo.java
这样的文件被暂存在零号插槽中,这意味着它现在可以进入一个新的提交。另外三个插槽是空的,因为已经有了零号插槽条目。在冲突合并期间,零号插槽保持为空,插槽1-3用于保存合并基础版本、本地或
--ours
版本和其他或
--theirs
版本。工作树中保存正在进行的合并。
你可以使用
git checkout
提取其中任何一个版本,或者使用
git checkout -m
重新创建合并冲突。所有成功的
git checkout
命令都会更新文件的工作区版本。
一些
git checkout
命令会保留不同的插槽。一些
git checkout
命令写入slot 0,擦除slots 1-3中的条目,以便文件准备提交。(要知道哪些做什么,你只需要记住它们。我曾经很长一段时间在脑海中弄错了。)
在清除所有未合并插槽之前,无法运行
git commit
。你可以使用
git ls-files --unmerged
查看未合并插槽,或者使用更人性化的版本
git status
。(提示:经常使用
git status
.)
成功合并并不意味着良好的代码。
即使 git merge
成功自动合并了所有内容,这并不意味着结果是正确的! 当然,当它停止并出现冲突时,这也意味着 Git 无法自动合并所有内容,而不是它自己自动合并的内容是正确的。我喜欢将 merge.conflictstyle
设置为 diff3
,以便我可以看到 Git 认为的基础是什么,在用两个合并方案替换"基础"代码之前。通常,冲突发生是因为 diff 选择了错误的基础,比如一些匹配的大括号和/或空行,而不是因为必须有一个实际的冲突。
使用“patience”diff 理论上能够帮助解决糟糕的基础选择。但我自己没有尝试过。Git 2.9 中的新“紧缩启发式算法” 很有前途,但我也没有尝试过。
您必须始终检查和/或测试合并的结果。 如果合并已经提交,您可以编辑文件,构建和测试,git add
更正版本,并使用git commit --amend
将以前(不正确的)合并提交推到一边,并放入具有相同父项的不同提交。 (git commit --amend
中的--amend
部分是虚假广告。它不会更改当前提交本身,因为它不能; 相反,它创建一个新的提交,其父ID与当前提交相同,而不是使用当前提交的ID作为新提交的父项的常规方法。)
您也可以使用
--no-commit
来取消合并的自动提交。实际上,我发现很少需要这样做:大多数合并都能正常工作,通过快速查看
git show -m
和/或“它编译并通过单元测试”就能发现问题。然而,在冲突或
--no-commit
合并期间,简单的
git diff
将为您提供一个组合差异(与在没有
-m
的情况下使用
git show
提交合并后获得的相同类型),这可能会有所帮助,也可能更加混乱。您可以运行更具体的
git diff
命令和/或检查三个(基本、本地、其他)插槽条目,如
Gregg在评论中指出。
查看 Git 将要查看的内容
除了将
diff3
用作您的
merge.conflictstyle
之外,您还可以查看
git merge
将看到的差异。您只需要运行两个
git diff
命令-与
git merge
运行的相同两个命令。
要执行这些操作,您必须找到或至少告诉
git diff
找到
合并基础。 您可以使用
git merge-base
,它会查找(或所有)合并基础并将其打印出来:
$ git merge-base --all HEAD foo
4fb3b9e0570d2fb875a24a037e39bdb2df6c1114
这段话表示当前分支和分支
foo
之间的合并基础是提交
4fb3b9e...
(且只有一个这样的合并基础)。我可以运行
git diff 4fb3b9e HEAD
和
git diff 4fb3b9e foo
。但是,如果我能够
假设只有一个合并基础,那么就有一种更简单的方法:
$ git diff foo...HEAD
这段话告诉我们,
git diff
(仅限于
git diff
)需要找到
foo
和
HEAD
之间的合并基础,然后将该提交-合并基础与提交
HEAD
进行比较。
$ git diff HEAD...foo
这个命令的作用是找到
HEAD
和
foo
之间的合并基础,"合并基础"是可交换的,所以这应该与另一种方式相同,就像7+2和2+7都是9一样,但这次是将合并基础与提交的
foo
进行差异比较。
1
(对于其他命令 - 不是
git diff
的东西 - 三个点的语法产生一个对称差异:所有在任一分支上但不在两个分支上的提交的集合。对于具有单个合并基础提交的分支,这是“合并基础之后的每个提交,在每个分支上”:换句话说,是两个分支的并集,不包括合并基础本身和任何早期提交。对于具有多个合并基础的分支,这会减去
所有合并基础。对于
git diff
,我们只假设有一个合并基础,而不是将其及其祖先减去,我们将其用作差异的左侧或“before”部分。)
1在Git中,分支名字标识了一个特定的提交,即分支的tip。实际上,这就是分支的工作原理:分支名称命名了一个特定的提交,并且为了将另一个提交添加到该分支 - 这里的“分支”指的是提交链 - Git会创建一个新的提交,其父提交是当前分支tip,然后将分支名称指向新提交。单词“分支”可以指分支名称或整个提交链; 我们应该通过上下文来确定具体指哪一个。
在任何时候,我们都可以命名一个特定的提交,并将其视为分支,方法是采用该提交及其所有祖先:其父级,其父级的父级等。在此过程中,当我们遇到合并提交 - 具有两个或多个父级的提交 - 我们将采取所有父提交及其父级的父级等。
2这个算法实际上是可选择的。默认的myers
是基于Eugene Myers提出的算法,但Git还有其他几个选项。