AI 编辑后 merge 冲突:解决指南

你的分支加上 AI 改动跟 main 冲突了。在不丢掉 AI 真正改进的前提下解决冲突,别再把格式噪音当成逻辑冲突。

让 Claude Code、Cursor 或 Codex 跑了半小时,写出 6 个文件的改动,结果 git pull --rebase origin main 一打回车就满屏 CONFLICT (content): Merge conflict in src/...。最难受的不是冲突本身,而是 AI 同时改了空白、import 顺序和真正的业务逻辑,让你分不清哪段是”逻辑冲突”、哪段是”格式噪音”。

TL;DR(最快的修法)

  1. 先把 AI 的改动 commit 一次,让它成为可恢复的对象,再开一个备份分支。
  2. 重新跑 rebase,让 git 自动解决纯格式的 hunk:git pull --rebase -X theirs origin main 会在冲突 hunk 上保留 AI 的版本,同时照常拉进 main 上不冲突的提交。
  3. 对剩下的几个仍在冲突的文件做三路对比,只手工改真正的逻辑部分。
  4. 跑测试 / lint / build,然后 git rebase --continue

关键坑:rebase 过程中,ourstheirs 跟普通 merge 是反的。被重放的提交(也就是 AI 的改动)是 theirs,你要落到的目标分支(main)是 ours。本文后面全部按 rebase 的含义来写。如果你是通过 git merge 走到这一步的,请把下文所有 ours/theirs 反过来理解。

常见原因

按 AI 编辑产生 merge 冲突的命中率从高到低排序。

1. AI 改的文件 main 也动了

最常见的一种:你 fork 出去半小时,期间同事 push 了 3 个 commit 都碰了同一份 src/server/auth.ts。AI 不知道远端动了,按本地状态全文重写。

CONFLICT (content): Merge conflict in src/server/auth.ts
Auto-merging src/server/auth.ts

如何判断: git log origin/main --since="2 hours ago" -- <文件> 看远端最近是否有别人动过该文件。

2. import 自动整理顺序和团队不一致

Cursor / Copilot 经常在保存时跑 eslint --fix 或 Prettier 自动排序 import;如果团队配置和 AI 触发的配置不同(比如分组方式不同),整个 import 块会被整体重写,产生大段”看似全改”的冲突。

<<<<<<< HEAD
import { z } from "zod";
import { db } from "@/lib/db";
import type { User } from "@/types";
=======
import type { User } from "@/types";
import { db } from "@/lib/db";
import { z } from "zod";
>>>>>>> ai-branch

如何判断: 冲突 hunk 里前后两份 import 集合完全相同,只是顺序 / 空行不同。

3. 空白和换行被 AI 顺手改了

Aider / Windsurf 默认会让模型重写整段函数,模型有时把 tab→空格、CRLF→LF、行尾分号风格全部按它的偏好写一遍。git diff 显示”整文件全红全绿”,但 git diff -w 几乎为空。

如何判断: git diff --stat 显示大量增删行,但 git diff -w --stat 行数骤降到个位数。

4. AI 跨文件搬代码,main 也在搬

AI 决定把 utils/format.ts 里的 formatDate 搬到 lib/date.ts;同一时间 main 把同一个函数搬到 shared/format.ts。rebase 时两份”删除”加两份”新增”对不上,git 报 add/add 冲突。

CONFLICT (add/add): Merge conflict in lib/date.ts

如何判断: git status 里同时出现 both addeddeleted by us / deleted by them

5. AI 修了某个 bug,main 也修了同一个 bug

经典的”两边都对、但写法不同”。你让 AI 修 race condition 加了 mutex;同事用 atomic 写了别的方案。语义都对,但代码冲突。

如何判断: 冲突两边单独都能跑测试通过,只是实现思路不同。

6. lockfile / 生成文件冲突

package-lock.jsonpnpm-lock.yamlmigrations/*.sqlschema.prisma 这类自动生成文件几乎一冲突就是几百到几千行。单独有一篇 AI 编辑后 lockfile 冲突 专门讲怎么处理。

如何判断: 冲突文件名是 lockfile / migration / generated 目录下的产物。

最短修复路径

按顺序做,每一步都可回退——最坏情况你可以 git rebase --abort 回到 Step 1。

Step 1:先把 AI 改动凝固成一个 commit

如果 AI 的改动还在 working tree 没提交,先 commit 一次,让它变成一个可对比、可 reset 的对象。

git add -A
git commit -m "WIP: AI edits before rebase"
git rev-parse HEAD  # 记下这个 SHA,待会要 cherry-pick / 对比

接着新建一个安全分支保险:

git branch backup/ai-edits-$(date +%Y%m%d-%H%M)

这样即使 rebase 彻底搞砸,git checkout backup/... 一行就能回来。

Step 2:打开 rerere,让每个冲突只解一次

如果 AI 重跑(很常见:你 abort 掉、重新提示,它又生成了同样的改动),你会再撞上同一个冲突。打开 git rerere,让 git 记住你的解法,下次自动重放:

git config --global rerere.enabled true

merge 和 rebase 时 git 都会自动调用 rerere。要在乱糟糟的 rebase 之前打开,而不是之后。

Step 3:用 -X 策略让 git 自动吃掉纯格式冲突

很多冲突其实只是 import 顺序、空白、换行风格。先在 hunk 级别让 git 偏向某一边,再处理剩下的。记住 TL;DR 里那条 rebase 反转。

# 在冲突 hunk 上保留 AI 的版本,同时照常拉进 main 的其他提交。
# rebase 时,被重放的 AI 提交是 "theirs"。
git pull --rebase -X theirs origin main

# 或者反过来:在冲突 hunk 上偏向 main 的版本。
# rebase 时,你要落到的目标分支 main 是 "ours"。
git pull --rebase -X ours origin main

-X theirs / -X ours 只在冲突 hunk 上做取舍,不会丢掉只存在于某一边、没有重叠的新增代码。它是个决胜规则,不是删除。这一步通常能一次性消掉大部分纯格式冲突,只剩下少数真冲突需要手工解。

如果只想吃掉纯空白差异(默认的 ort 策略支持这个选项):

git rebase -X ignore-all-space origin/main

Step 4:剩下的冲突按类型分类处理

git status --short | grep "^UU"  # 列出还在冲突的文件(两边都改了)

按下面三类分别处理。这些 git checkout 命令默认你正处在 rebase 中,所以侧别标签按 rebase 的含义来:

类型处理方式
纯 import / 格式保留 AI 版本:git checkout --theirs <file>,然后跑一次本地 formatter
lockfile(按 main 重新生成)取 main 版本:git checkout --ours <file>,然后 npm install 重新生成
真正的逻辑冲突手工编辑,参考 Step 5 的三路对比

如果不确定自己是在 rebase 还是 merge,看一下:git status 会显示 interactive rebase in progress / rebase in progress,或者 git merge 之后的 You have unmerged pathsours/theirs 的含义在这两种情况下是相反的。

Step 5:用三路对比看真正的逻辑冲突

对于剩下的逻辑冲突文件,打开三路视图:

git mergetool --tool=vimdiff  # 或 vscode / meld

或手动取出每个 stage。:1 是共同祖先,:2ours:3theirs。rebase 时这意味着 :2 是 main,:3 是你的 AI commit:

git show :1:src/server/auth.ts > /tmp/base.ts    # 共同祖先
git show :2:src/server/auth.ts > /tmp/ours.ts    # main 端(rebase 时)
git show :3:src/server/auth.ts > /tmp/theirs.ts  # AI 端(rebase 时)
diff -u /tmp/base.ts /tmp/ours.ts    # main 改了什么
diff -u /tmp/base.ts /tmp/theirs.ts  # AI 改了什么

只有看到”main 改了什么”和”AI 改了什么”两份独立 diff,才能判断该如何合并,而不是盲选一边。

Step 6:解决完跑全套验证,再继续 rebase

git add <已解决的文>
npm test && npm run lint && npm run build
git rebase --continue

如果中途想放弃:

git rebase --abort  # 回到 Step 1 的 commit

如何确认已经修好

  • git status 显示没有 unmerged paths,并且 nothing to commit, working tree clean
  • git rebase --continue 顺利跑完,不再反复弹冲突(或者本来就没有 rebase 在进行)。
  • git diff origin/main...HEAD --stat 只列出你打算改的文件,没有意外冒出来的 lockfile 或纯空白文件。
  • 测试、lint、build 在最终的整棵树上都通过,而不只是各自单边通过。

预防建议

  • 让 AI 开始改之前先 git pull --rebase origin main,把分叉窗口缩到最短。
  • CLAUDE.md / .cursorrules / AGENTS.md 里写明:“改前先读文件、不要重排 import、不要批量改空白”。
  • AI commit 保持小而频繁(一次只做一件事),冲突时只损失一个 commit 的工作量。
  • 仓库根加 .editorconfig 和 pre-commit 的 formatter hook,让 AI 跑出来的代码风格自动和团队一致。
  • lockfile / 生成文件加一条 .gitattributes 规则,省掉手工对比。内置的 union 驱动(package-lock.json merge=union)会保留两边的行,之后用 npm install 重新生成即可。光写 merge=ours 是不行的,得先定义它:git config merge.ours.driver true
  • 全局打开一次 git config --global rerere.enabled true,让重复冲突自动套用上次的解法。
  • 大改动让 AI 先写计划再执行,你确认文件范围后再让它动文件。

常见问题

为什么 git checkout --theirs 取出来的是反的版本? 因为你在 rebase,不是 merge。rebase 时标签是反的:theirs 是被重放的提交(AI 改动),ours 是你要落上去的分支(main)。如果你在 rebase 中想要 main 的版本,用 git checkout --ours

-X theirs 会把 AI 新写的代码丢掉吗? 不会。-X theirs(和 -X ours)只决定冲突 hunk 上哪边胜出。只存在于某一边、没有重叠的代码,两种情况下都会保留。它是决胜规则,不是删除。

AI 每次重跑都撞上同一个冲突,怎么不用反复解? 在 rebase 之前打开 git rereregit config --global rerere.enabled true)。git 会记下你手工解的结果,下次再出现完全相同的冲突时自动重放。

整个文件都显示冲突,但其实只是空白差异,最快怎么修? 先用 git diff -w --stat 确认(如果行数掉到接近 0,就是纯空白),然后用 git rebase -X ignore-all-space origin/main 重跑,让 git 不再把空白差异当冲突。

怎么不逐行读就分辨出真逻辑冲突和格式噪音? 对比 git diff --statgit diff -w --stat。第二个很小就是空白问题。对于 import,看冲突 hunk 两边是不是同一批标识符、只是顺序不同。这两步过完还剩下的,才是值得做三路对比的真语义冲突。

能不能在不丢 AI 工作的前提下直接退出? 能。git rebase --abort 会回到 Step 1 的那个 commit,而你的 backup/ai-edits-* 分支不管怎样都是一份独立副本。

相关阅读

标签: #AI 编程 #排查 #排查