git merge:如何合并分支并解决冲突
git merge 把另一个分支的历史整合进当前分支:如果当前分支没有前进,Git 只移动指针(快进);否则会创建一个有两个父提交的合并提交。切换到目标分支后执行 git merge 分支名;出现冲突时修改文件,执行 git add 和 git commit,或用 git merge --abort 全部撤销。
git merge 的作用
命令 git merge 取来另一个分支的修改,并把它们嵌入你当前所在的分支。注意合并的方向:是“合并到自己”。要把 feature/login 合并进 main,先切换到 main,然后执行合并。
git switch main
git pull # 从服务器拉取最新的 main
git merge feature/login
Git 并不会逐行手工复制。它先找到两个分支的共同祖先(merge base),将两边的版本都与该祖先比较,然后把修改合并起来。如果双方改动的是不同位置,结果会自动生成。如果改动的是同一位置,就会产生冲突,由你来决定最终结果。
两种模式:快进与三方合并
快进(fast-forward):不需要合并提交
假设你从 main 分出了 feature,做了三个提交,而这期间 main 没有任何新提交。那么 main 的历史恰好是 feature 历史的开头部分。Git 会把 main 的指针直接向前移动到 feature 的最后一个提交,不会创建新的提交。
$ git merge feature
Updating 3f2a1c4..9b7e0d2
Fast-forward
src/auth.js | 42 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 42 insertions(+)
历史保持线性,但“这里曾经有一个 feature 分支”的信息也就消失了。这有时是好事,有时不是,下面的 --no-ff 一节会详细说明。
三方合并与合并提交
如果两个分支都在共同祖先之后前进了,就无法简单地移动指针。Git 执行三方合并:基础版本(共同祖先)、“我们的”一侧(当前分支)和“他们的”一侧(被合并的分支)。结果被记录为一个合并提交,它有两个父提交。
$ git merge feature
Merge made by the 'ort' strategy.
src/auth.js | 42 ++++++++++++++++++++
src/router.js | 3 +-
2 files changed, 44 insertions(+), 1 deletion(-)
Git 会打开编辑器,并给出类似 Merge branch 'feature' 的说明。你可以保留它,也可以补充说明为什么要合并。想直观地看到这些“分叉”,可以执行:
git log --oneline --graph --all
从 Git 2.34 起,默认策略是 ort;之前使用的是 recursive。日常工作中无需修改策略。
git merge 的常用参数
| 参数 | 作用 | 何时使用 |
|---|---|---|
--no-ff | 即使可以快进,也总是创建合并提交 | 希望在历史中看出哪些提交来自同一个功能 |
--ff-only | 只允许快进,否则直接报错 | 更新本地分支,又不想产生意外的合并提交 |
--squash | 把分支的所有修改放入暂存区,但不提交,也不记录与该分支的关联 | 想把一个功能压缩成一个整洁的提交 |
--no-commit | 执行合并,但在提交之前停下 | 想在提交前检查结果或运行测试 |
-m "文本" | 指定合并提交的说明 | 在脚本中,或不打开编辑器时 |
--abort | 中止正在进行的合并,恢复到合并前的状态 | 冲突太多,想重新开始 |
--continue | 解决冲突后完成合并 | 作为最后一步 git commit 的替代 |
-X ours / -X theirs | 冲突片段自动采用“我们的”或“他们的”版本 | 明确知道哪个版本正确时(请谨慎使用!) |
--no-ff:保留功能的边界
很多团队只用 --no-ff 合并功能分支。这样在提交图中,每个任务都会呈现为一个“气泡”,并且可以通过一条 git revert -m 1 <合并提交> 整体撤销。如果是快进,就没有这样的边界,撤销时只能逐个提交处理。
--squash:用一个提交代替十个
git switch main
git merge --squash feature/login
git commit -m "Login with e-mail and password"
使用 --squash 之后,Git 不会把该分支视为已合并:git branch -d feature/login 会拒绝删除,并提示 “not fully merged”。确认所有内容都已正确保存后,再用 git branch -D 强制删除。另外,squash 之后不要继续在同一个分支上工作:下一次合并会再次带入已经合并过的修改,几乎一定会产生冲突。
合并冲突:如何解决
冲突是正常的工作情况,而不是事故。它只意味着一件事:两个分支修改了相同的行(或者一方删除了另一方正在修改的文件),而 Git 不会擅自猜测哪个版本才是正确的。
$ git merge feature/login
Auto-merging src/config.js
CONFLICT (content): Merge conflict in src/config.js
Automatic merge failed; fix conflicts and then commit the result.
第 1 步:查看哪些文件冲突
$ 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/config.js
第 2 步:编辑文件
Git 会在冲突文件中插入标记:
<<<<<<< HEAD
const TIMEOUT = 5000;
=======
const TIMEOUT = 10000;
>>>>>>> feature/login
上半部分是当前分支(HEAD),下半部分是被合并的分支。保留正确的版本(或将两者合并),删除这三行标记,然后保存文件。常见错误是遗漏了文件中间的某个标记;提交前可以用 git diff --check 找出残留的标记。
第 3 步:标记已解决并完成合并
git add src/config.js
git commit # 或者 git merge --continue
如果整个冲突只需选择一方的版本,就不必手动编辑文件:
git checkout --ours src/config.js # 保留当前分支的版本
git checkout --theirs src/config.js # 采用被合并分支的版本
git add src/config.js
在编辑器中处理更方便
VS Code、JetBrains IDE 等编辑器会以三栏形式显示冲突,并提供“接受当前 / 接受传入 / 接受两者”的按钮。也可以通过 git mergetool 调用外部工具。一个有用的设置是 git config --global merge.conflictStyle zdiff3:标记中会额外显示共同祖先的原始版本,便于看出双方各自改了什么。
改变主意:撤销合并
git merge --abort
该命令会把工作目录和暂存区恢复到执行 git merge 之前的状态。因此合并之前最好保持工作区干净:先提交或用 git stash 暂存未完成的修改,否则它们很难与合并结果区分开。
如何撤销已经完成的合并
如果合并提交还没有推送到服务器,最简单的做法是把分支重置回去:
git reset --hard ORIG_HEAD
ORIG_HEAD 是 Git 在执行危险操作(包括合并)之前记录的分支位置。--hard 会丢弃未提交的修改,所以执行前请检查 git status。
如果合并已经在共享仓库中,就不能改写历史,而应该创建一个反向提交:
git revert -m 1 a1b2c3d
参数 -m 1 表示“主线”是第一个父提交(被合并进的那个分支)。需要注意一个陷阱:经过这样的 revert 之后,Git 会认为该功能的提交已经合并过。如果以后想重新合并修正后的功能,必须先 revert 这个 revert 提交。
团队协作中的合并
git pull 就是 fetch 加上 merge
普通的 git pull 会下载修改,并把远程分支合并进本地分支。如果你和同事同时提交,就会出现“Merge branch 'main' of ...”这样的提交。为了避免这些技术性的合并提交,可以设置 git config --global pull.ff only(无法快进时 pull 失败,由你决定如何处理),或者使用 git pull --rebase。
Pull request 与 Merge 按钮
在 GitHub 和 GitLab 上,合并通常通过 pull/merge request 完成。“Create a merge commit”按钮相当于 git merge --no-ff,“Squash and merge”相当于压缩合并,“Rebase and merge”相当于变基合并。选择哪种方式由团队约定决定,其底层机制与命令行完全相同。
把 main 合并进自己的分支
长期存在的分支会逐渐偏离 main,冲突也会不断累积。应当定期把主分支合并进来:
git switch feature/login
git fetch origin
git merge origin/main
这样冲突会随着出现一点点地解决,最后合并进 main 时就会顺利得多。
常见错误
- 合并方向搞反了。 在
main中执行了git merge main,或者把一个功能合并进另一个功能。合并前可以用git branch --show-current确认当前分支。 - 与过时的分支合并。 合并的是一周没有更新的本地
main。应该先git pull,再合并。 - “Your local changes would be overwritten by merge”。 工作目录中有与合并涉及的文件相同的未提交修改。先提交或放入 stash。
- “refusing to merge unrelated histories”。 两个分支没有共同祖先,例如本地仓库是用
git init创建的,而远程仓库已经包含了 README。如果确实需要合并,可以加上--allow-unrelated-histories。 - 提交了冲突标记。 构建因
<<<<<<<而失败。提交之前用git diff --check检查,并运行测试。
总结
git merge 是一种安全的分支合并方式:它不改写已有的提交,只会添加一个新提交(或移动指针)。应当在目标分支上执行合并,保持工作区干净;如果需要保留功能的边界,使用 --no-ff;如果需要一个提交,使用 --squash。冲突的处理分三步:修改文件,git add,git commit;而 git merge --abort 总能让你回到起点。分支合并与冲突解决在 Git 课程中有实践讲解。
常见问题
git merge 和 git pull 有什么区别?
git pull 先从服务器下载修改(即 git fetch),然后把远程分支合并进当前分支,设置为 rebase 时则变基。git merge 只处理本地已有的内容。
能否一次合并多个分支?
可以,git merge 功能A 功能B 会执行章鱼式合并(octopus),生成多父提交,但前提是没有冲突。实际中通常一次只合并一个分支。
如何查看哪些分支已经合并到当前分支?
git branch --merged 显示已完全合并到当前分支的分支,git branch --no-merged 显示尚未合并的分支。已合并的分支可以放心地用 git branch -d 删除。
合并之前如何预览会带来什么?
执行 git log main..feature 查看 main 中没有的提交,执行 git diff main...feature 查看分支相对于共同祖先的修改。
合并之后的 ORIG_HEAD 是什么?
它是 Git 在合并之前保存的引用,指向分支合并前的位置。只要合并还没有推送到服务器,就可以用 git reset --hard ORIG_HEAD 撤销本地的合并。