冲突为何发生
在第 4 模块中,Git 自己就把两个分支合并好了。它之所以能做到,是因为它逐行合并:它把每个分支与两者分叉处的提交进行比较,每处改动都从做出这处改动的那一侧取来。对不同文件的改动会自动合并,对同一文件不同部分的改动也一样。
但有时这还不够。当两个分支以不同的方式改动了相同的行,或者一个分支删除了另一个分支改动过的文件时,就会发生合并冲突(merge conflict)。Git 无法知道哪个版本是对的:也许其中一个是对的,也许两边各需要一部分,也许都不对。所以它尽可能完成合并,然后停下来,请你决定剩下的部分。
冲突不是错误,也不意味着任何东西坏了或丢了。两个版本都安然无恙,Git 会准确地告诉你它们在哪里有分歧。冲突是与他人协作时的正常现象,你自己在多个分支上工作时也会遇到。本模块教你如何读懂冲突、如何解决(resolve)冲突、还没准备好时如何退出,以及如何让冲突少而小。
合并并不是遇到冲突的唯一场合。撤销一次旧提交(第 3 模块)、拉取别人的工作(第 6 模块)以及变基(第 8 模块),都以同样的方式组合改动,也可能以同样的方式停下来。冲突标记看起来一样,每个文件的解决方式也一样:编辑它,然后 git add。不同的只是完成或放弃这项操作的命令,例如用 git revert --continue 代替 git commit;git status 会告诉你该用哪一条。
两位编辑在同一份书稿上做标注。一位改写了第 2 章的一个句子,另一位修正了第 9 章的一个错字,两处修改都可以采纳。但如果两人以不同的方式改写了同一个句子,就必须有人决定书中保留哪种措辞。
Git 能自行合并的情况
先来看一次成功的合并,尽管两个分支改动了同一个文件。食谱一开始用 200 g 面粉,面糊静置 30 分钟。在名为 resting-time 的分支上,静置时间缩短为 20 分钟;与此同时,在 main 上,面粉增加到 250 g。两处改动相隔好几行。
要跟着操作,请先给你的食谱加上一行 - 50 g sugar 和方法一行 Mix everything and leave the batter to rest for 30 minutes.(与下面 cat 的输出一致),并在 main 上提交。然后创建 resting-time,修改静置时间并提交;切换回 main,修改面粉用量并提交;最后合并 resting-time。
git merge resting-time
Auto-merging pancakes.md
Merge made by the 'ort' strategy.
pancakes.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
cat pancakes.md
# Pancakes
- 250 g flour
- 2 eggs
- 300 ml milk
- 50 g sugar
Mix everything and leave the batter to rest for 20 minutes.
Auto-merging pancakes.md 表示 Git 需要在文件内部组合改动,下一行则表示组合成功,并生成了一次合并提交。cat 显示了结果:来自 main 的 250 g 面粉,以及来自 resting-time 的 20 分钟。
相邻行上的改动即使没有改到同一行,仍然可能冲突。Git 需要两处改动之间至少隔着一行未改动的内容,才能把它们区分开;如果没有,它就会把它们当作两侧以不同方式做出的同一处改动。
读懂并解决冲突
这一次,两个分支改动了同一行。食谱一开始用 50 g 糖。在 less-sugar 分支上,有人把它改成 30 g(“Use less sugar”);与此同时,在 main 上,有人把它改成 40 g(“Reduce the sugar a little”)。两种改法都合理,但彼此不一致,所以 Git 无法独自合并。
在这个动手环节中,你在 main 上合并 less-sugar。合并因冲突而停下,然后你一步步解决它:用 git status 查看状态,阅读文件,把文件编辑成你想要的版本,标记为已解决,然后提交。要跟着操作,请创建 less-sugar,把糖改成 30 g 并提交,切换回 main,把糖改成 40 g 并提交,然后合并 less-sugar。
git merge less-sugar
Auto-merging pancakes.md
CONFLICT (content): Merge conflict in pancakes.md
Automatic merge failed; fix conflicts and then commit the result.
git status
On branch main
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: pancakes.md
no changes added to commit (use "git add" and/or "git commit -a")
cat pancakes.md
# Pancakes
- 200 g flour
- 2 eggs
- 300 ml milk
<<<<<<< HEAD
- 40 g sugar
=======
- 30 g sugar
>>>>>>> less-sugar
Mix everything and leave the batter to rest for 30 minutes.
pancakes.md,使其内容为:# Pancakes
- 200 g flour
- 2 eggs
- 300 ml milk
- 35 g sugar
Mix everything and leave the batter to rest for 30 minutes.
git add pancakes.md
git status
On branch main
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge)
Changes to be committed:
modified: pancakes.md
git commit
[main a4d11e9] Merge branch 'less-sugar'
* a4d11e9 (HEAD -> main) Merge branch 'less-sugar' |\ | * 7d5360a (less-sugar) Use less sugar * | 3c34e74 Reduce the sugar a little |/ * f49b1d8 Add a pancake recipe
git log --oneline --graph
* a4d11e9 Merge branch 'less-sugar'
|\
| * 7d5360a Use less sugar
* | 3c34e74 Reduce the sugar a little
|/
* f49b1d8 Add a pancake recipe
合并报告了 CONFLICT (content): Merge conflict in pancakes.md 并停了下来,正如最后一行所说:Automatic merge failed; fix conflicts and then commit the result. 合并现在处于进行中,直到你完成或放弃它为止。git status 用 You have unmerged paths 确认了这一点,把 pancakes.md 列为 both modified,甚至还建议了接下来的命令。
cat 显示了两个分支在哪里有分歧。有冲突的部分位于三个冲突标记(conflict marker)之间:
- 从
<<<<<<< HEAD到=======是你当前所在分支main上的版本:40 g。 - 从
=======到>>>>>>> less-sugar是你正在合并的分支上的版本:30 g。
标记之外的内容都已顺利合并,不需要你处理。
如果有多个文件冲突,git status 会把它们都列在 Unmerged paths 之下。逐个解决并 git add,顺序不限;git status 会显示还剩下哪些。
要解决冲突,就把文件编辑成你想要的版本,并删除全部三行标记。你可以保留其中一方、两方都保留,或者写出新的内容;这里,两位厨师商定用 35 g。然后,git add pancakes.md 把文件标记为已解决,git status 显示 All conflicts fixed but you are still merging。最后,git commit 完成这次合并。Git 会打开编辑器,里面是说明 Merge branch 'less-sugar';保存并关闭即可接受,动手环节中也是这样做的。提交图显示了完成的合并:一次合并提交,两次改糖的提交都是它的父提交。
解决冲突是你自己做的编辑,Git 会完全信任它:运行 git add 时文件里是什么,什么就会进入合并提交。添加之前,请把整个文件再读一遍,确保结果仍然合理,而不只是标记已经消失。
Visual Studio Code 等编辑器会用颜色突出显示冲突,并在每处冲突上方提供按钮:Accept Current Change(你所在的分支)、Accept Incoming Change(你正在合并的分支)和 Accept Both Changes。它们只是替你编辑文件。你仍然需要用 git add 和 git commit 完成合并。
放弃一次合并
有时你开始合并后才意识到现在没法解决:你需要问同事哪个版本是对的,或者冲突比预想的大。git merge --abort 会把一切恢复到合并开始之前的样子,就像你从未运行过它一样。在这个动手环节中,糖的冲突再次出现,这一次你选择退出。在你自己的仓库中,less-sugar 已经合并了,所以要试一试,就需要制造一次新的冲突:例如,在一个新分支上和在 main 上以不同方式修改鸡蛋那一行,然后合并那个分支。
git merge less-sugar
Auto-merging pancakes.md
CONFLICT (content): Merge conflict in pancakes.md
Automatic merge failed; fix conflicts and then commit the result.
git merge --abort
git status
On branch main
nothing to commit, working tree clean
cat pancakes.md
# Pancakes
- 200 g flour
- 2 eggs
- 300 ml milk
- 40 g sugar
Mix everything and leave the batter to rest for 30 minutes.
放弃之后,git status 报告工作区是干净的,cat 显示 pancakes.md 仍然是 main 上的 40 g,没有任何标记。你准备好时可以随时再次合并。
放弃只在合并进行期间有效。一旦你提交了合并,就没有什么可放弃的了:合并提交已是你历史中普通的一部分。
如果合并开始时你有未提交的改动,--abort 可能无法恢复它们。所以请在合并之前先提交你自己的改动。
让冲突保持在小范围内
冲突无法完全避免,但有几个习惯能让冲突少发生,发生时也小一些:
- 经常把
main合并到你的分支中。 这样你能尽早遇到别人的改动,趁它们还小、大家还记得清楚的时候,而不是在最后一次性遇到所有改动。第 8 模块会介绍另一种做法:变基。 - 让分支短命,让提交聚焦。 只存在一天的分支比存在一个月的分支意外更少;只做一件事的提交更容易合并,发生冲突时也更容易理解。
- 彼此沟通。 商定谁负责哪些文件;两个人要改写同一部分之前,先说一声。
- 不要在功能分支上重新格式化整个文件。 重新缩进一个文件,或者改变它的换行符,会触及每一行,因此会与对该文件的所有其他改动冲突。如果某个文件确实需要重新格式化,请在一次单独的提交中完成,事先与大家商定,并尽快合并。
- 提交解决结果之前先检查。 在长文件里,遗漏的标记行很容易被忽略。编辑之后、
git add之前,运行git diff --check:它会报告遗留的冲突标记。如果你已经暂存了文件,请使用git diff --staged --check。练习 6 会展示这项检查的用法。
冲突是 Git 在向你提问,而不是报告失败。读懂标记,决定文件应该是什么内容,然后 git add 并 git commit。如果还没准备好回答,git merge --abort 会带你回到起点。
练习
会冲突吗? 判断下列每次合并是否会因冲突而停下。
- 分支 A 修改
soup.md;分支 B 修改bread.md。 - 两个分支都修改
pancakes.md:A 修改第一种配料,B 修改做法的最后一行。 - 两个分支都把
pancakes.md中糖的那一行改成了不同的用量。 - 分支 A 删除了
cake.md;分支 B 修改了它。
查看解答
- 不会。不同的文件会自动合并。
- 不会,只要两处改动之间隔着未改动的行,就像第 2 节那样。
- 会。两个分支对同一行做了不同的修改。
- 会。Git 无法既删除文件又保留修改,所以会请你做出选择。
读懂标记。 一个有冲突的文件包含:
<<<<<<< HEAD
- 40 g sugar
=======
- 30 g sugar
>>>>>>> less-sugar
哪个版本来自你所在的分支,哪个来自你正在合并的分支?解决冲突后,文件中应该包含什么?
查看解答
位于 <<<<<<< HEAD 和 ======= 之间的 - 40 g sugar 来自你所在的分支;位于 ======= 和 >>>>>>> less-sugar 之间的 - 30 g sugar 来自 less-sugar。解决之后,文件中只包含你选定的内容(二者之一、两者都要,或是新的写法),三行标记都不能留下。
保留两处改动。 你在一个分支上给煎饼加了巧克力豆;有人在 main 上同一位置加了一根香蕉。合并该分支,并保留这两种配料。
查看解答
合并会停下来,因为两处改动位于清单的同一行。保留这两行并删除标记:
cat pancakes.md
# Pancakes
- 200 g flour
- 2 eggs
- 300 ml milk
- 50 g sugar
<<<<<<< HEAD
- 1 banana, mashed
=======
- 50 g chocolate chips
>>>>>>> chocolate
Mix everything and leave the batter to rest for 30 minutes.
pancakes.md,使其内容为:# Pancakes
- 200 g flour
- 2 eggs
- 300 ml milk
- 50 g sugar
- 1 banana, mashed
- 50 g chocolate chips
Mix everything and leave the batter to rest for 30 minutes.
git add pancakes.md
git commit
[main a653ada] Merge branch 'chocolate'
git log --oneline --graph
* a653ada Merge branch 'chocolate'
|\
| * 2fb1669 Add chocolate chips
* | 6dc884d Add a banana
|/
* f49b1d8 Add a pancake recipe
解决冲突不一定要选边站:这里正确的答案是两者都要。
提交得太早。 你删除了 pancakes.md 中的标记,然后运行 git commit,但 Git 拒绝了,说因为存在未合并的文件而无法提交。为什么?
查看解答
你漏掉了 git add pancakes.md。在你暂存这个文件之前,无论其内容如何,Git 都把它视为未合并。先运行 git add pancakes.md,再运行 git commit。
防患于未然。 两位厨师在不同的分支上同时修改了糖的那一行。他们本可以怎样避免这次冲突?
查看解答
以下任意一种:事先沟通,由一个人来做这项修改;让分支保持短小,使一项改动在另一项开始之前就已合并;经常把 main 合并进各自的分支,这样第二位厨师在编辑同一行之前就能看到第一项改动。
遗留的标记。 有人匆忙解决了糖的冲突,把仍带着标记的文件提交了。请找出这些标记并修复文件。要动手试试,请像第 3 节那样制造一次冲突,然后不删除标记就 git add 并提交该文件。
查看解答
git diff --check 会报告遗留的冲突标记,发现标记时它会以失败结束;与 HEAD~1 比较可以检查最近一次提交改动的内容:
git diff --check HEAD~1
pancakes.md:6: leftover conflict marker
pancakes.md:8: leftover conflict marker
pancakes.md:10: leftover conflict marker
pancakes.md,使其内容为:# Pancakes
- 200 g flour
- 2 eggs
- 300 ml milk
- 35 g sugar
Mix everything and leave the batter to rest for 30 minutes.
git add pancakes.md
git commit -m "Remove leftover conflict markers"
[main 79d79ee] Remove leftover conflict markers
1 file changed, 1 insertion(+), 5 deletions(-)
在 git add 冲突解决结果之前运行 git diff --check(或者在 git add 之后运行 git diff --staged --check),就不需要这样补救了。
自测
<<<<<<< HEAD 和 ======= 之间是什么?======= 之后的部分来自另一个分支。git commit 完成合并。git diff --check 会就什么发出警告?延伸阅读
- Scott Chacon、Ben Straub,《Pro Git》中文版第 3.2 节“分支的新建与合并”中的“遇到冲突时的分支合并”。
- git merge 参考文档(英文)中的“How conflicts are presented”一节。
- GitHub 文档:使用命令行解决合并冲突。