Skip to content

ApplyPatchHandler:多文件补丁与 SEARCH/REPLACE

源码版本v4.0.10

职责

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 复杂:持有 PathResolverFileProviderOperations 两个助手,在 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:45export 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 的转换:

typescript
// 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 在原文件行数组上切片重组:

typescript
// 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 found DiffError(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

对照官方资料:Cline 文档 · README