Skip to content
This page has been auto-translated and may contain errors.View in English

合并分支与解决冲突 ​

docs.scrimba.com

你的功能分支已经完成了。切换开关能正常工作,你也把改动提交了,现在你希望这份工作能进入团队其他人代码所在的 main 分支。多数时候,把它并进去是又快又安静的一件事:Git 会比较两个分支,发现两边没有重叠的改动,就直接把工作合进去,不需要你额外做什么。但有时候,你和同事在不同分支上改动了完全相同的那几行代码,这时候 Git 无法猜出你想保留哪个版本。这种情况在任何有多人一起提交代码的项目里几乎都会遇到。这是分支协作中很正常、很日常的一部分,它的意思是 Git 发现了两种关于同一批代码的不同想法,需要你来拍板决定留哪个。

用 git merge 把分支并回来 ​

假设你在自己的分支 add-fahrenheit-toggle 上开发了华氏度切换功能,而 main 从你分出这个分支之后就没有再变动过。切换到 main,然后用 git merge 加上你想并入的分支名:

bash
$ git switch main
$ git merge add-fahrenheit-toggle
Updating 4f2a891..8c3d1a0
Fast-forward
 src/index.js | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

git merge <branch> 总是把指定的分支合并到你当前所在的那个分支上,所以先切换到 main 很关键。你站在哪个分支上,接收合并结果的就是它。命令执行完后,main 就拥有了 add-fahrenheit-toggle 上的所有提交。功能分支本身丝毫未变:你可以继续在它上面开发,也可以把它删掉,因为它的工作现在也留在 main 上了。

输出里的那一行 Fast-forward 值得仔细看看。它的意思是:从你分出分支之后,main 没有新增任何提交,所以 Git 根本不需要合并任何东西——它只是把 main 这个标签往前挪,指向了 add-fahrenheit-toggle 已经指向的那个提交。**快进合并(fast-forward merge)**说的就是 Git 把一个标签追赶到它本来就该到的位置,仅此而已。

如果你在开发期间 main 上又多了别的提交——比如同事顺手合并了一个 bug 修复——Git 就没法再简单地把标签往前挪了,因为 main 和你的分支从分开的那一刻起就走上了不同的方向。这时 Git 会创建一个新的提交,把两条历史线连接起来,这个提交叫合并提交(merge commit):

bash
$ git switch main
$ git merge add-fahrenheit-toggle
Merge made by the 'ort' strategy.
 src/index.js | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

这种合并之后,git log --graph --oneline 会显示出分叉与汇合:两条历史线并排延伸,最后在合并提交处重新汇聚。这两种结果都不需要你额外做什么。Git 会根据两个分支是否发生了分叉来决定用哪一种方式,无论哪种,你的功能现在都已经成为 main 的一部分。

上面两种结果背后其实是同一个原理:三方合并(3-way merge)。要合并两个分支,Git 比较的不只是两个分支的最新提交。它先找出两个分支共同拥有的那个起点提交,也就是合并基点(merge base),然后同时查看三份快照:这个基点,以及两个分支各自的最新提交。把每个分支的最新提交分别与共同基点比较,Git 就能准确知道每一侧各自改动了哪些行,正是这一点让 Git 大多数时候能自动合并两组改动,而不用每次都来问你。

快进合并是这三份快照中恰好有一份多余的特殊情况:基点和其中一个分支的最新提交其实是同一个提交,那一侧根本没有需要合并的内容,所以 Git 只是移动标签,不创建任何新东西。合并提交则是一般情况:两侧从基点开始都各自改动了内容,Git 会记录一个新的快照,它有两个父提交,分别指向两条分支历史。接下来这一节你会遇到的每一个合并冲突,都源自这同一种三方比较发现两边改动了相同的行,而它自己没办法合并。

Juno用 git merge 把分支并回来git merge branch-name 会把那个分支的提交并入你当前所在的分支,所以先切换到 main。多数合并都在后台悄无声息地完成,你继续做别的事就好。功能分支本身不会有任何变化;main 拿到这份工作之后,你可以继续用它,也可以把它删掉。
Juno用 git merge 把分支并回来 快进合并意味着 main 没有动过,所以 Git 只是把标签往前滑了一下。一旦 main 在这期间多了别的提交,合并就会创建一个真正带两个父提交的合并提交。不管是哪种情况,命令都一样是 git merge branch-name;具体走哪条路由 Git 自己判断。
Juno用 git merge 把分支并回来 三方合并把共同的基点提交和每个分支的最新提交分别比较,从而算出每一侧改了什么,这也是大多数时候两个分支能自动合并的原因。快进合并只是基点恰好和其中一个最新提交重合的特殊情况。面对一张比较乱的提交图,我自己有时还是得停下来想一想哪个提交才算基点,如果你也拿不准,不妨把图画出来看看。

合并冲突长什么样 ​

当合并的两边都改动了相同的行时,就会发生冲突,Git 没办法判断你想保留哪个版本。假设同事把一个加载动画合并进了 main,改动了 src/index.js 里的某个函数,而你的 add-fahrenheit-toggle 分支为了加切换开关也改了同一个函数。这时合并会在中途停下,而不是直接完成:

bash
$ git switch main
$ git merge add-fahrenheit-toggle
Auto-merging src/index.js
CONFLICT (content): Merge conflict in src/index.js
Automatic merge failed; fix conflicts and then commit the result.

这就是**合并冲突(merge conflict)**。打开这个文件,你会看到 Git 已经精确标出了两个版本不一致的地方:

<<<<<<< HEAD
  showLoadingSpinner(true);
=======
  const tempF = celsiusToFahrenheit(tempC);
>>>>>>> add-fahrenheit-toggle

<<<<<<< HEAD 标出的是当前分支(这里是 main)上的内容的起始位置。======= 把两个版本分隔开。>>>>>>> add-fahrenheit-toggle 标出的是要并入分支版本的结束位置。两个标记之间夹着的就是同一小段代码的两种不同写法。

出现冲突意味着 Git 需要你来判断,并不代表哪里坏了。Git 发现同一批代码有两处改动,没办法猜出你想要哪一个,于是就停下来,等你来做决定。编辑这个文件,直到内容变成你想要的样子——可以保留其中一个版本、两个都留,或者写一个融合两者的新版本——然后把标记那几行删掉:

  showLoadingSpinner(true);
  const tempF = celsiusToFahrenheit(tempC);

文件内容看起来没问题之后,像处理其他改动一样把它加入暂存并提交:

bash
$ git add src/index.js
$ git commit

这里不带消息直接运行 git commit,会打开编辑器,里面已经有 Git 帮你准备好的、描述这次合并的提交信息。保存并关闭它,合并就完成了。

冲突还没解决的时候,最值得先看一眼的是 git status。它会在"Unmerged paths"下列出所有还需要处理的文件,所以在一次涉及多个冲突文件的大合并中,你能清楚知道还剩几个要处理:

bash
$ 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:   src/index.js

git add 是在告诉 Git,某个文件的冲突已经解决了。但它并不会检查你是不是真的删掉了那些标记,所以在暂存之前最好把文件内容通读一遍。如果发现这次合并比预想的乱得多,或者选错了分支,git merge --abort 会把这次合并完全撤销,把工作目录恢复到运行 git merge 之前的样子,不会留下任何做了一半的东西。

冲突并不是 git merge 独有的。**Rebase(变基)**是另一种让一条历史线追上另一条的方式,它会遇到同样的冲突,只是解决之后的结果不一样。合并会用一个带两个父提交的合并提交把两条历史线连接起来,保留了这个分支存在过、以及它确切在何时重新汇入的事实。而变基则是把你分支上的提交一个一个重放到另一个分支的最新提交之上,最终得到一条直线式的历史,没有合并提交,也看不出这两条线曾经分开过:

bash
$ git switch add-fahrenheit-toggle
$ git rebase main

这里确实存在取舍。变基产生的线性历史在 git log 里读起来很干净,一个提交接着一个提交,有些团队更喜欢这种整洁的叙事。而合并保留了事情真实发生的样子,包括某个功能分支具体是在哪个点重新汇入 main 的,有些团队更看重这种准确性。两种选择都没有错;每个团队选定一种约定,保持一致就好。

不管你选哪种约定,有一条规则始终适用:永远不要对别人已经拉取过、或者已经在其上开发过的分支做变基。变基会把原来的提交改写成 ID 全新的提交,如果一个共享分支的历史在协作者不知情的情况下变了,他们本地的副本和被改写过的远程分支就再也对不上号,只能靠他们自己手动修复。在还没有其他人拉取你的分支之前,尽情地在自己的分支上变基;一旦分支被共享出去,就改用合并。

Juno合并冲突长什么样 合并冲突意味着两个分支改动了相同的行,Git 需要你来选定最终结果。打开文件,找到 <<<<<<<、======= 和 >>>>>>>,编辑到你想要的样子,然后把这些标记行删掉。最后用 git add 和 git commit 收尾。我第一次遇到冲突时感觉像出了大事,结果发现它只是 Git 在问我一个挺平常的问题。
Juno合并冲突长什么样git status 会把所有未解决的文件列在 unmerged paths 下面,一旦冲突涉及不止一个文件,这个功能就很好用。git add 只是标记文件已解决,并不会检查你改得对不对,所以暂存之前先通读一遍。如果一次合并搞乱了,git merge --abort 能带你回到一个干净的状态,什么都不会留一半。
Juno合并冲突长什么样 Rebase 会遇到和 merge 一样的冲突;它是把提交重放到一个新的基点上,而不是把两条历史线连起来,用保留分支形状换来一条直线。把这种取舍只用在只有你自己在用的分支上。一旦分支被共享出去,用变基改写它的提交会给已经拉取过它的人带来麻烦,这时应该换成合并。

把它们串起来 ​

合并正是让分支这件事变得值得做的原因:你在 Branches 里学到的那条属于自己的历史线上安全地做实验,等准备好了,再把这份工作并回 main,冲突也一并处理好。在 GitHub 上,同样的操作通常不是在你本机敲一句 git merge,而是通过 pull request 完成的;pull request 工作流这一章会讲到如何在共享项目中提出改动、进行审查并完成合并。如果某次合并在你已经提交之后,落到了你并不想要的地方,撤销操作会讲到如何安全地退回去。