ApplyPatchHandler:多文件补丁与 SEARCH/REPLACE
职责
ApplyPatchHandler 是 Cline v4 引入的多文件补丁处理器,对应 ClineDefaultTool.APPLY_PATCH 工具名。它接 LLM 吐出的一整段 *** Begin Patch / *** End Patch 文本,在单个 tool_use block 里同时改、加、删、移动多个文件,把过去要拆成 N 次 write_to_file/replace_in_file 才能做完的多文件修改压成一次工具调用(class declaration:45-46)。
它和 WriteToFileToolHandler 走的不是同一条 diff 路径。replace_in_file 用 7 字符围栏的 SEARCH/REPLACE 块(由 constructNewFileContent 解析,见 SEARCH block markers:1-3),一次只能改一个文件;ApplyPatchHandler 用的是另一种格式:*** Begin Patch 起头,*** Add File: path / *** Update File: path / *** Delete File: path 分文件块,每块内用 +/- 行表示增删(PATCH_MARKERS:4-13)。这种格式源自 OpenAI 的 apply_patch 协议,适合一次表达跨多个文件的改动。
它在 Cline 体系里同样实现 IFullyManagedTool,但状态比 WriteToFileToolHandler 复杂:持有 PathResolver、FileProviderOperations 两个助手,在 execute 里跑「预处理 → 加载所有待改文件 → PatchParser 解析 → 转 commit → 逐文件 prepare → 审批 → save → 收尾」的流水。每个文件单独走一次 prepare + 审批 + save,不是一把全过。
设计动机
- 多文件单调用:让模型一次输出一组逻辑相关的改动(比如改接口同时改所有调用方),避免 WriteToFileToolHandler 串行改 N 次的来回开销(
execute entry:210)。 - 复用 diff 视图:每个文件的改动仍然走 DiffViewProvider 呈现给用户,所以多文件 patch 在审批时是逐文件 diff,不是一次性黑盒(
prepareFileChange:632)。 - bash 包裹兜底:模型经常把 patch 包在
```bash/apply_patch/EOF里,stripBashWrapper把这些外壳剥掉再解析(stripBashWrapper:433)。 - 不完整哨兵报错:只有
*** Begin Patch或只有*** End Patch时抛DiffError让模型拆小重试,不尝试猜(preprocessLines:416-431)。 - MOVE 单独走 create+delete:Update +
*** Move to:时,prepare 阶段把新路径当 create 处理,save 成功后再删原路径(<SrcLink path="apps/vscode/src/core/task/tools/handlers/ApplyPatchHandler.ts" lines="316-323" label="move handling"/>)。 - fuzz 透传:PatchParser 解析时允许 fuzz 匹配,fuzz > 0 时在返回结果里附一行提示让模型知道匹配不精确(
fuzz note:403-405)。
关键文件
class declaration:45—export class ApplyPatchHandler implements IFullyManagedTool,name = ClineDefaultTool.APPLY_PATCH。handlePartialBlock:66— 流式预览第一个文件的 patch,先extractAllFiles看 path,再previewPatchStream开 diff 视图。execute:210— 主流程:preprocess → loadFiles → PatchParser.parse → patchToCommit → 逐文件 prepare/approve/save。preprocessLines:416-431— 补齐 BEGIN/END 哨兵,只缺一个就抛DiffError("incomplete sentinels")。stripBashWrapper:433— 剥%%bash/apply_patch/EOF/ ``` 包裹,保留 patch 主体。loadFiles:514— 一次性把所有 UPDATE/DELETE 路径读进来,clineignore 命中或文件不存在都抛 DiffError。patchToCommit:539— 把 Patch 转成Commit结构:DELETE 存 oldContent,ADD 存 newContent,UPDATE 调applyChunks。applyChunks:584— 按 chunk 的 origIndex 把原文件切片,拷贝未改段、插入 insLines、跳过 delLines。prepareFileChange:632— 用 FileProviderOperations 开 diff 视图并 update,但不 save。handleApproval:718— 每个文件单独 ask 一次,带 fileOps(filesCreated/Deleted/Moved)上报遥测。constructNewFileContent:245— replace_in_file 走的 SEARCH/REPLACE 解析器,与 apply_patch 是两条独立路径。PATCH_MARKERS:4— 所有 patch 哨兵常量定义。
数据流
execute 起手先做预处理和加载,把整段 patch 文本解析成结构化 commit,再逐文件走审批-落盘循环。核心是 patch → commit 的转换:
// apps/vscode/src/core/task/tools/handlers/ApplyPatchHandler.ts
const lines = this.preprocessLines(rawInput)
const filesToLoad = this.extractFilesForOperations(rawInput, [PATCH_MARKERS.UPDATE, PATCH_MARKERS.DELETE])
const currentFiles = await this.loadFiles(config, filesToLoad)
const parser = new PatchParser(lines, currentFiles)
const { patch, fuzz } = parser.parse()
const commit = await this.patchToCommit(patch, currentFiles)preprocessLines 先把缺哨兵的输入补齐或拒掉(preprocessLines:416),loadFiles 把 UPDATE/DELETE 涉及的原文件读进内存,PatchParser 把行数组解析成 Patch(actions + chunks + fuzz 因子),patchToCommit 再把每个 action 转成 FileChange。UPDATE 分支调 applyChunks,本质就是按 origIndex 在原文件行数组上切片重组:
// apps/vscode/src/core/task/tools/handlers/ApplyPatchHandler.ts
for (const chunk of chunks) {
if (chunk.origIndex > lines.length) {
throw new DiffError(`${path}: chunk.origIndex ${chunk.origIndex} > lines.length ${lines.length}`)
}
// Copy lines before the chunk
result.push(...lines.slice(currentIndex, chunk.origIndex))
const originalLines = lines.slice(chunk.origIndex, chunk.origIndex + chunk.delLines.length)
const insertedLines = chunk.insLines.map((line) => {
if (tryPreserveEscaping && originalText) {
return preserveEscaping(originalText, line)
}
return line
})
result.push(...insertedLines)
currentIndex = chunk.origIndex + chunk.delLines.length
}
result.push(...lines.slice(currentIndex))commit 构造好后,generateChangeSummary 为每个文件生成一条 ClineSayTool 消息,然后逐文件 prepareFileChange(开 diff 视图、update、不 save)→ handleApproval(auto 或 ask)→ saveFileChange。MOVE 操作在 save 新文件成功后再 deleteFile(originalPath)。所有文件都走完后,统一在每个 changedFilePath 上 markFileAsEditedByCline + trackFileContext("cline_edited") + 失效 fileReadCache。
边界与失败
- 不完整哨兵:只有 BEGIN 或只有 END 直接抛 DiffError,提示模型「拆成更小的 patch 重试」(
incomplete sentinels:429-431)。 - clineignore 命中:loadFiles 阶段命中就抛 DiffError,整个 patch 中止,不会部分应用(
clineignore throw:522-526)。 - 文件不存在:UPDATE/DELETE 针对的文件在磁盘上找不到,抛
File not foundDiffError(file not found:528-530)。 - 用户拒一个,全部回退:
handleApproval返回 false 时立刻revertChanges+reset并返回拒绝消息,后续文件不再处理(reject abort:304-310)。 - MOVE 的旧路径失效:MOVE 的 fileReadCache 失效要同时清新路径和旧路径,避免下次读旧文件还能从缓存读到已删内容(
move cache invalidate:343-345)。 - partial block 静默:
previewPatchStream里任何解析失败都catch静默返回,等更多数据流进来再重试(partial catch:83-85)。 - chunk 顺序错乱:currentIndex 比 origIndex 大(说明 chunk 顺序与原文件不对应)时抛 DiffError(
chunk order check:597-599)。
小结
ApplyPatchHandler 是 WriteToFileToolHandler 的多文件版本,用 *** Begin Patch / *** End Patch 协议在一个 tool_use 里表达跨文件改动。它和 replace_in_file 各走一条独立的解析路径:前者用 PatchParser 按行切片,后者用 constructNewFileContent 按 SEARCH/REPLACE 块定位。两者最终都落到 DiffViewProvider 上呈现给用户。
想往里钻可以接着看:
- 单文件覆盖 / SEARCH/REPLACE:
/edit-tools/write-to-file - diff 视图底层:
/edit-tools/diff-view-provider - Task 如何调度工具:
/agent-loop/task-class