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