如何逐步解决困难的 Git 合并冲突

通过读取我们的、他们的和基础版本,处理重命名、变基、二进制文件和测试,解决困难的 Git 合并冲突。

如何逐步解决困难的 Git 合并冲突

困难的 Git 合并冲突很少是因为冲突标记本身。困难是因为你必须同时保留两个不同更改的意图。一个分支重命名了一个函数,而另一个分支改变了它的行为。一个分支移动了一个文件,而另一个分支编辑了它。一个分支更改了数据库迁移序列。Git 可以显示重叠部分,但无法决定软件之后应该是什么意思。

当合并因冲突而停止时,不要立即开始删除标记。首先了解情况。

git status

Git 会列出未合并的路径。它可能会显示 both modifieddeleted by usdeleted by themboth added 或类似信息。这些短语告诉你冲突的形状。

如果合并感觉不对,或者你还没有准备好解决它,请在做出更多更改之前中止:

git merge --abort

对于变基冲突,等效命令是:

git rebase --abort

这不是失败。这是干净地重置到操作之前的状态,当你意识到需要更多上下文时,这通常是最明智的做法。

将冲突视为三个版本

正常的冲突标记如下所示:

<<<<<<< HEAD
当前分支版本
=======
传入分支版本
>>>>>>> feature-branch

在合并期间,HEAD 是运行 git merge 时检出的分支。底部是正在合并的分支。

对于困难冲突,使用 Git 在索引中保留的三个阶段:

git show :1:path/to/file   # 共同祖先
git show :2:path/to/file   # 我们的
git show :3:path/to/file   # 他们的

共同祖先是两个分支开始的版本。它很有用,因为它显示了每个分支实际更改了什么。没有它,你可能会比较两个最终版本而错过它们背后的原因。

你也可以使用:

git diff
git diff --ours -- path/to/file
git diff --theirs -- path/to/file
git diff --base -- path/to/file

这是许多人走得太快的地方。目标不是选择“我们的”或“他们的”作为团队忠诚度投票。目标是生成正确的最终文件。

安全的手动工作流程

对每个冲突文件使用此例程:

  1. 打开文件并找到每个冲突标记。
  2. 阅读周围的代码,而不仅仅是标记的行。
  3. 检查两个分支上触及该文件的提交。
  4. 将文件编辑为最终意图版本。
  5. 删除所有冲突标记。
  6. 运行最小的相关测试或构建检查。
  7. 暂存文件。

执行此操作时有用的命令:

git log --oneline --left-right --merge -- path/to/file
git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'

git diff --check 捕获遗留的空白问题。git grep 捕获在到达 CI 之前被遗忘的冲突标记。

解决一个文件后:

git add path/to/file

当所有冲突都已暂存时:

git status
git commit

在变基期间,使用:

git rebase --continue

当两个分支更改了同一个函数时

这是常见情况。假设一个分支添加了验证,另一个重命名了参数:

<<<<<<< HEAD
function createUser(email) {
  return db.users.insert({ email });
}
=======
function createUser(rawEmail) {
  const email = rawEmail.trim().toLowerCase();
  return db.users.insert({ email });
}
>>>>>>> normalize-email

正确的答案可能结合两者:

function createUser(rawEmail) {
  const email = rawEmail.trim().toLowerCase();
  return db.users.insert({ email });
}

但前提是调用者已更新为传递 rawEmail,并且仍然需要规范化。搜索该函数:

git grep -n 'createUser'

困难的冲突通常需要检查附近的文件。一个文件中的函数签名冲突可能需要更新测试、路由、类型、模拟或文档。

重命名和编辑冲突

重命名冲突很烦人,因为你想要的文件可能不在你期望的位置。从状态开始:

git status --short

然后检查名称状态信息:

git diff --name-status --diff-filter=R

如果一个分支将 src/user.js 重命名为 src/account.js,而另一个编辑了 src/user.js,你通常希望将编辑的内容应用到新路径。可视化合并工具可以提供帮助,但概念很简单:保留重命名并保留有意义的编辑。

在决定最终路径后,如果需要,删除过时的路径并暂存最终路径:

git rm old/path.js
git add new/path.js

除非最终项目确实应该包含两个文件,否则不要暂存两个文件。

被我们删除或被他们删除

删除/修改冲突意味着一个分支删除了一个文件,而另一个分支更改了它。Git 无法知道删除是否使更改变得无关紧要。

如果文件应该保持删除状态:

git rm path/to/file

如果文件应该保留,选择你想要的版本并暂存它:

git checkout --theirs path/to/file
git add path/to/file

或者:

git checkout --ours path/to/file
git add path/to/file

在变基期间要小心 --ours--theirs。在变基中,标签可能会感觉颠倒,因为 Git 正在将你的提交重放到另一个基础上。如果不确定,检查阶段:

git show :2:path/to/file
git show :3:path/to/file

二进制文件冲突

Git 无法合并大多数二进制文件。如果两个分支更改了相同的图像、存档、文档或编译资产,你必须选择一个版本或手动创建新文件。

要使用我们的版本:

git checkout --ours path/to/file.bin
git add path/to/file.bin

要使用他们的版本:

git checkout --theirs path/to/file.bin
git add path/to/file.bin

如果二进制文件是生成的,最好的答案可能是在解决文本文件后从源代码重新生成它。如果二进制文件是设计资产或文档,请与更改另一侧的人交谈。猜测可能会破坏工作。

当文件太难阅读时使用合并工具

一个好的合并工具显示四件事:基础版本、你的版本、他们的版本和结果。配置一个你真正喜欢的。Visual Studio Code 很常见:

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'

然后运行:

git mergetool

其他团队更喜欢 Meld、KDiff3、Beyond Compare 或 IDE 集成工具。工具不如理解三个版本重要。不要仅仅为了让红色标记消失而点击“接受传入”通过复杂的冲突。

使用合并工具后,检查备份文件,如 .orig

git status --short

如果你不想要它们,可以全局禁用合并工具备份文件:

git config --global mergetool.keepBackup false

策略选项不是魔法

你可能会看到这样的建议:

git merge -X theirs feature

这并不意味着“用 feature 替换我的分支。”这意味着当 Git 的合并策略看到冲突块时,它应该为这些块优先选择另一侧。两个分支的非冲突更改仍然合并。这对于生成的锁文件或机械格式化冲突可能有用,但对于业务逻辑有风险。

-X ours-X theirs 是策略选项。ours 合并策略不同:

git merge -s ours old-branch

这会记录一个合并,同时保留当前树。这是一个专门的工具,通常用于标记一个分支已合并而不采用其内容。除非你非常确定,否则不要将其用于正常的冲突解决。

变基冲突

在变基期间,Git 一次重放一个提交。这意味着你可能会解决几个较小的冲突,而不是一个大的合并冲突。

循环是:

git status
# 编辑文件
git add resolved-file
git rebase --continue

如果正在重放的提交不再需要,因为新基础已经包含该更改,请使用:

git rebase --skip

谨慎使用 skip。它会从变基分支中删除该提交。首先阅读提交:

git show

同样,--ours--theirs 在变基中可能会令人困惑。如有疑问,检查 :2::3:

测试解决方案,而不仅仅是合并

合并可以在语法上解决,但仍然错误。暂存文件后,运行触及更改区域的测试。对于前端冲突,可能是类型检查和聚焦的组件测试。对于后端冲突,可能是一个服务测试或迁移检查。对于锁文件冲突,重新安装依赖项并运行包管理器的验证命令。

至少:

git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'

然后运行项目特定的检查,该检查会捕获错误的组合。

减少未来的冲突

最好的冲突是你从未创建的冲突。保持分支短命,定期从主分支变基或合并,避免将机械更改与功能更改混合。格式化仅限的 PR 不应同时更改逻辑。如果可以避免,文件移动不应同时重写文件。

对于总是令人痛苦的文件,考虑所有权或结构更改。大型配置文件、生成的快照、锁文件、迁移列表和中央路由注册表通常会创建重复冲突,因为每个人都在编辑相同的区域。有时修复是流程。有时修复是拆分文件或从较小的源生成文件。

对需要特殊合并行为的文件使用 .gitattributes。例如,某些生成的锁文件可能有特定于包管理器的合并驱动程序。不要随意发明一个,但检查你的生态系统是否有推荐的驱动程序。

合并冲突既是技术工作,也是沟通。如果你不理解另一个分支的意图,请询问。与作者交谈十分钟比默默合并通过测试但删除了他们正在构建的功能的代码更便宜。

锁文件、迁移和其他高摩擦文件

某些文件更频繁地冲突,因为许多分支编辑相同的狭小区域。依赖锁文件是一个常见的例子。如果两个分支添加包,锁文件冲突可能在技术上很大,但概念上很简单:在解决清单文件后使用包管理器重新生成它。

对于 Node 项目,这可能意味着解决 package.json,然后运行拥有锁文件的包管理器:

npm install
# 或 pnpm install
# 或 yarn install

然后暂存清单和锁文件。除非你理解其格式,否则不要手动编辑复杂的锁文件。包管理器不太可能犯微妙的依赖图错误。

数据库迁移需要更多注意。如果两个分支创建了具有排序假设的迁移,接受两个文件可能不够。检查迁移时间戳、序列号、依赖项以及两个迁移是否修改了相同的表或数据。有时正确的解决方案是一个新的后续迁移,以协调两个分支。

生成的快照和黄金文件具有相同的模式:首先解决源更改,重新生成输出,然后审查生成的差异。如果生成的差异巨大,请问它是否属于同一个合并提交。巨大的生成更改可能隐藏糟糕的手动解决方案。

当冲突跨越文件时,在编辑之前写下预期的最终行为。一个简短的注释,如“保留功能 A 的新验证,保留功能 B 的重命名服务,重新生成客户端类型”,可以防止你在本地解决每个文件时丢失整体设计。

对于特别有风险的合并,在开始之前创建一个临时分支:

git switch -c merge-test/main-with-feature
git merge feature

如果解决方案变得混乱,你可以放弃临时分支而不干扰原始分支。这个小习惯使困难的冲突不那么有压力,因为你总是有一条干净的返回路径。

像审查自己的更改一样审查最终合并

冲突解决方案是新工作。在审查中这样对待它。最终的差异应该显示两个分支的更改,以及你为使它们协同工作而编写的任何粘合代码。如果合并提交很大,在提交消息或拉取请求评论中解释解决方案。审查者不应该必须逆向工程为什么选择了一侧。

在推送之前,尽可能将最终结果与两个父级进行比较:

git diff HEAD^1..HEAD -- path/to/file
git diff HEAD^2..HEAD -- path/to/file

对于未提交的合并,检查暂存的更改:

git diff --cached

查找意外删除的测试、不再使用的导入、重复的配置条目以及两个分支在不同名称下添加了类似逻辑的代码路径。这些是 Git 无法为你识别的错误。

如果冲突涉及行为,添加或更新一个测试,如果你选择了错误的一侧,该测试会失败。该测试不仅证明今天的合并。它保护该决定在下次重构中不被撤销。