用 AI 合并一万个文件,我踩了所有该踩的坑

6 阅读13分钟

这篇文章写的是我用 Claude Code 做了一次持续七周的大型代码合并的经历——合并对象是 Dify的上游版本 1.11.3,涉及前端 5527 个文件、后端 2425 个文件、其他目录文件。
文章想解决一个问题:怎么用 AI 完成人工做起来会崩溃的代码合并任务,同时不让 AI 自己也搞崩?


为什么这件事比想象的难

合并代码这事,表面上很机械——你有两个版本,把它们的差异合并进来就好了。

但真正做过大型 fork 维护的人都知道,难的不是技术,是判断。每个有差异的文件,你都要回答三个问题:

  1. 这里 upstream 改了什么、为什么改
  2. 我们自己改了什么、为什么改
  3. 两边的改动能不能同时保留、怎么保留

这三个问题,对于上万个文件,根本不可能靠人工逐一判断。所以我引入了 Claude Code。

结果发现,AI 带来了新问题:AI 很勤快,但它不知道自己在丢东西。

它会在合并一个复杂文件时,悄悄把你们自己写的某个功能用 upstream 的版本覆盖掉,然后告诉你"合并完成"。你如果不检查,根本不知道损失了什么。

这七周里我踩了很多坑,也摸索出了一套相对靠谱的做法。


最大的坑:没有详细地图就出发了

第一周,我的做法是:先让Claude分析当前代码以及upstream 1.11.3中的代码,生成一份合并方案,合并方案看起来很合理,前端、后端每个目录都拆分了大约 12个步骤,告诉 Claude "帮我按照计划书执行 Phase X",它合并一批,我看一看,觉得差不多就继续。

一周后,我做了一次全量文件统计:

git ls-tree -r --name-only upstream-1.11.3 > upstream_files.txt
git ls-tree -r --name-only HEAD > head_files.txt
comm -23 <(sort upstream_files.txt) <(sort head_files.txt) > missing.txt

结果:前端缺少 1107 个文件,后端缺少 867 个文件。

这两千个文件,是我们已经"合并完"的部分里漏掉的。

我当时的感受就是:这一周几乎白干,得从头补。

正确的做法应该是每个部分都做这件事。 先知道要合并的部分有多少文件、哪些已经有了、哪些缺失、哪些两边都改过——建立一张完整的地图,再开始合并。

一定有人要问开始的时候不是让Claude 分析代码并输出合并计划了么?是的,但最开始的时候一下子让Claude分析整个项目,还是两个分支的内容,目录太多太复杂了,Claude 为了控制上下文是会偷懒的,他在计划中给的是大概的数值,所以执行过程中肯定会有遗漏。

现在每次大型合并,我都会在第一天跑这个分类脚本,把所有文件分成四类:

类型含义处理方式
A 类两边内容完全相同不用动
B 类只有 upstream 改了,我们没动直接采纳 upstream
C 类两边都改了需要逐一三方合并
D 类一边有、一边没有逐一裁决

有了这张地图,你知道工作量是多少,知道哪些是高风险区,知道做到什么程度才算完。


最有用的发现:让 AI 扮演两个对立角色

这是整个过程里最有价值的一个实践,我把它叫做 Executor + Judge 模式

起因是一次很让我崩溃的事:AI 花了很长时间"完成"了一个复杂模块的合并,我审查时发现,它悄悄把我们自己写的一个配置解析能力给丢掉了。它没有报错,没有警告,就这么删了。

我当时意识到,AI 是没有"丢失感"的。它不知道自己漏了什么,因为它的目标只是"让合并后的代码能跑起来",不是"确保什么都没丢"。

解决方法是:把 Claude Code 拆成两个角色,分别由两个会话承担。或者让 Claude Code 执行代码合并,让 codex 执行代码审查。这种两个职责分离非常有效,因为它避免了Claude Code 自我审查时产生的认知偏见。codex以全新视角审视输出,完全专注于发现错误和改进空间。

过程中我尝试过让 Claude Code 执行代码合并之后通过 MCP 调用 codex 进行代码审查。

好处是效率极高,过程中不需要我参与,Claude Code 和 codex 可以自行沟通讨论直到审核通过,

但也有弊端,MCP 有时候会因为各种原因调用失败或者 codex 无响应,此时Claude Code 并没有察觉,就会一直等待 codex 返回,这些等待时间就会被浪费,另外,有时候 Claude Code 还会将自己的看法作为 prompt 传给 codex ,导致误导了 codex 的审核结果。

所以综上所述,建议还是单独开 codex 进行合并结果审查。

第一个角色:执行官(Executor)

负责实际合并代码。给它一个严格的约束:

你是代码合并执行官。合并时遵守三条原则:

1. 不删除当前分支的任何功能代码
2. 不遗漏 upstream 的任何功能、优化或 bugfix
3. 所有行为差异必须通过注释明确标注,使用以下格式:

   # TODO [merge] <说明>
   # Upstream: <upstream 的实现>
   # Current: <当前分支的实现>
   # Conflict: <冲突原因>
   # Suggestion: <建议方案>

   # Current branch enhancement (not in upstream)
   # Feature: <功能说明>

   # Merged from upstream X.Y.Z
   # Feature: <功能说明>

第二个角色:审判官(Judge)

负责审查 Executor 的合并结果。关键是它的定位不是"帮 Executor 找通过理由",而是"找漏洞":

你是代码合并审判官。你的职责是审查 Executor 的合并结果是否遗漏或破坏了任何东西。

以下任何一条成立,立即否决:
- upstream 中存在的逻辑,在合并结果中消失了
- 当前分支的独有功能被删除或弱化
- 存在未通过注释标注的行为差异
- 注释内容与代码行为不一致
- 合并后构建失败
- i18n 翻译 key 不存在导致类型报错

审查方法:对每个文件执行三方对比
  - 共同祖先版本(merge base)
  - upstream 版本
  - 合并后的版本

三方都看,才能知道每一方的意图是否被正确保留。

这个模式的价值在于:Judge 在独立会话中运行,没有 Executor 的心理包袱。Executor 干完活会下意识地认为"我做完了",Judge 则是冷眼旁观地找问题。

实战结果:API 的第一个合并 Phase 就被 Judge 否决了,发现了配置解析能力的回退问题。重做之后通过,后来验证那个问题确实是真实的。


门禁:每走一步都要踩实

合并的过程我分成了多个阶段,每个阶段完成后必须通过一组检查,才能进入下一个阶段。

# 后端检查
cd api && uv run ruff check .         # 代码风格
cd api && uv run basedpyright .       # 类型检查
cd api && uv run pytest tests/ -x -q  # 单元测试

# 前端检查
cd web && pnpm type-check    # 类型检查(source 0 errors 才算过)
cd web && pnpm lint:quiet    # 代码风格
cd web && pnpm build:fast    # 构建
cd web && pnpm test          # 测试

这些检查在做之前感觉是额外负担,做了之后发现是救命绳。

前端最严重的时候,合并了大量文件后跑 type-check,出现了 1367 个类型错误。如果没有这个门禁,这些错误会扩散到整个项目,后来根本分不清哪个错误是哪次合并引入的。

有了阶段门禁,每次失败都能精确定位到"这个 Phase 的哪几个文件引入了问题"。

前端最终的轨迹:1367 个 TS 错误 → 0。这个过程分了八个阶段完成,每个阶段都是干净地通过门禁再往前走。


差异标注:给未来的自己留信息

合并时遇到两边都改过同一处代码的情况,最简单的处理是选一边保留。但这样做的问题是,三个月后你完全不记得为什么这里和 upstream 不一样了,也不知道能不能改回去。

所以我养成了一个习惯:所有有意为之的差异,都必须在代码里留下注释,说明是什么、为什么。

对于当前分支独有的功能:

# Current branch enhancement (not in upstream)
# Feature: HTTP-only Cookie 认证,替代 localStorage token 方案
# 原因: 企业安全要求,防止 XSS 攻击获取 token
def set_auth_cookie(response, token):
    ...

对于从 upstream 合入的新功能:

# Merged from upstream 1.11.3
# Feature: 新增 MCP(Model Context Protocol)工具调用支持
class MCPToolNode:
    ...

对于两边都改了、需要人工决定的地方:

# TODO [merge] 超时配置策略差异
# Upstream: 统一使用 30s 超时
# Current: 分场景设置超时(RAG 120s,普通请求 30s)
# Conflict: 企业场景大文档处理需要更长超时
# Suggestion: 保留当前分支的分场景策略
TIMEOUT_RAG = 120
TIMEOUT_DEFAULT = 30

这些注释不是给 AI 看的,是给我们看的,提交之前人工审核有TODO [merge]标识的部分。当 upstream 1.13.0 出来的时候,也能够一眼就能看到"这里曾经有个冲突,当初的决定是什么,现在是否还适用"。


让 AI 跨会话工作:进度文档比记忆更可靠

AI 的会话是有 context 限制的。一次大型合并不可能在一个会话里完成,通常要跨几十个会话。

做法:把进度全部写进一个文档,每次新会话直接 @ 这个文档。

文档的格式大概是这样的:

# Merge Plan: upstream 1.11.3 → current

当前状态:
- Phase 1 (基础设施) ✅ 完成
- Phase 2 (依赖合并) ✅ 完成
- Phase 3 (类型/配置层) 🔄 进行中
- Phase 4-8 ⬜ 待执行

执行规则:
1. 不删除当前分支任何功能代码
2. 不遗漏 upstream 任何功能
3. 所有差异必须标注

关键定制功能(不可丢失):
- HTTP-only Cookie 认证
- CVTE Portal SSO 登录
- Celery 队列(CLOUD/SELF_HOSTED 两套配置)
- ...

门禁命令:
  cd api && uv run basedpyright .
  cd web && pnpm type-check
  ...

这个文档就是每次会话的起点。新开一个会话,把这个文档扔进去,AI 立刻知道背景、规则、当前进度、不能碰的地方。

文档代替了记忆,无论会话切换多少次,上下文都不会丢失。


一个容易被忽视的细节:三方对比

合并代码时,大多数人(和 AI)只会看"两边的差异"。但这是不够的。

正确的方法是三方对比:同时看共同祖先、upstream、当前分支这三个版本。

MERGE_BASE=$(git merge-base HEAD upstream/main)

git show "$MERGE_BASE":api/services/app_service.py > /tmp/base.py
git show "upstream/main":api/services/app_service.py > /tmp/upstream.py
git show HEAD:api/services/app_service.py > /tmp/current.py

diff3 /tmp/current.py /tmp/base.py /tmp/upstream.py

只看两边差异,你只知道"现在不一样"。看三方,你才能知道"是谁改的、改了什么、为什么改"。

举个真实例子:某个函数,upstream 重构了,我们也改了(加了企业功能)。只看两边,感觉冲突很复杂。三方一看,发现 upstream 的重构和我们的改动改的是完全不同的部分,可以直接合并,根本不冲突。


并行作业:独立的任务可以同时跑

合并任务里,有些文件之间完全独立,没有依赖关系。比如前端的一批组件文件、后端的一批 service 文件。

Claude Code 支持同时启动多个 Agent 并行工作。Claude Code 春节前才更新了这个功能。

对于没有依赖关系的文件组,可以这样分配:

Agent 1: 合并 api/services/A/、api/services/B/
Agent 2: 合并 api/services/C/、api/services/D/
Agent 3: 合并 api/controllers/X/

三个 Agent 同时工作,速度提升明显。关键是要提前做好依赖分析,确认哪些可以并行、哪些必须串行。注意,不要乱用 Agent Team,一定是要有可以并行的内容,否则团队之间通讯的通讯成本就是白费的。


安全别忘了

七周的合并完成后,我做了一次专项安全审计。

这一步很重要,原因是:合并过程中有大量地方为了让代码"能跑"做了妥协或改动,安全检查没有在每一步进行。收尾时的统一审计能发现很多在过程中没注意的问题。

我们的审计发现了一个 HIGH 级别问题:某处使用 dangerouslySetInnerHTML 时没有经过 DOMPurify 净化,存在 XSS 风险。是合并 upstream 一个新功能时引入的,在那次合并的单测里完全没覆盖到这个场景。

建议把安全审计作为收尾阶段的必选项,而不是"有空再说"。


给想做同样事情的人的建议

如果你也面临一次大型 fork 合并,最重要的几个点:

1. 先建地图,再出发

第一天就跑全量文件分类,知道总量、分布、高风险区。不要一边合并一边发现漏洞。

2. 用两个对立角色制衡 AI

Executor 负责合并,Judge 负责找问题。两个角色分开会话,Judge 不带 Executor 的上下文。这比在同一个会话里让 AI 自检要可靠得多。

3. 每走一步踩实一步

设置阶段门禁(构建/lint/类型检查/测试),每个阶段通过才进下一个。宁可走得慢,不要走得虚。

4. 把规则写在文档里,不要放在脑子里

合并的关键规则(什么能动、什么不能动、遇到冲突怎么处理)写成文档,每次新会话都带着这个文档。不要依赖 AI 的"记忆"。

5. 标注差异的目的是给下次合并用的

合理使用差异标注,但控制数量。每个"有意为之的不同"都解释清楚,方便下次合并时快速判断这里是否还适用。


这七周的合并,我写了 80 多份报告、经历了无数次门禁失败、被 Judge 否决过几次、修复了 1367 个类型错误。

最后的结果是:5527 个前端文件里 5526 个成功合入(1 个有充分理由的豁免),安全审计评分 9.2/10,27 个端到端测试全部通过,产出了两个专门用于dify 合并的 skill,一个用于搭建单元测试及端到端测试的skill,这个具体内容下次再和大家分享。

这件事用纯人工根本做不完。用 AI 做,只要方法对,是可以做到的。