ApplyPatchHandler:複数ファイルパッチと SEARCH/REPLACE
役割
ApplyPatchHandler は Cline v4 で導入された複数ファイルパッチハンドラで、ClineDefaultTool.APPLY_PATCH ツール名に対応する。LLM が吐き出した *** Begin Patch / *** End Patch テキストを丸ごと受け取り、一つの tool_use block で複数ファイルを同時に変更・追加・削除・移動する。かつては write_to_file / replace_in_file を N 回に分けて呼ばないと終わらなかった複数ファイル編集を、一度のツール呼び出しに圧縮する (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 でユーザーに提示する。そのため複数ファイルパッチの承認時にはファイルごとに diff が表示され、一度に黒箱に入るわけではない (
prepareFileChange:632)。 - bash ラッパーを剥ぐ安全網: モデルはよく patch を
```bash/apply_patch/EOFで包んで出力するが、stripBashWrapperがこれらの外殻を剥がしてから解析する (stripBashWrapper:433)。 - 不完全な sentinel はエラー:
*** Begin Patchだけ、あるいは*** End PatchだけのときはDiffErrorを投げてモデルに小さく切り分けてリトライさせ、推測はしない (preprocessLines:416-431)。 - MOVE は create + delete で処理: Update +
*** Move to:のとき、prepare 段階では新パスを create 扱いし、save が成功してから元パスを削除する (move handling:316-323)。 - 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 sentinel を補完し、片方だけ欠けていれば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 sentinel 定数。
データフロー
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 は欠けた sentinel を補完するか拒否する (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 の無効化を一括で行う。
境界と失敗
- 不完全な sentinel: 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