Skip to content

ApplyPatchHandler: Multi-File Patches and SEARCH/REPLACE

源码版本v4.0.10

Responsibilities

ApplyPatchHandler is the multi-file patch handler introduced in Cline v4, registered to the tool name ClineDefaultTool.APPLY_PATCH. It takes a block of text the LLM emits between *** Begin Patch / *** End Patch markers and modifies, adds, deletes, and moves multiple files in a single tool_use block — collapsing what used to require N separate write_to_file/replace_in_file calls into one tool invocation (class declaration:45-46).

It does not share the diff path with WriteToFileToolHandler. The replace_in_file tool uses 7-character-fenced SEARCH/REPLACE blocks (parsed by constructNewFileContent; see SEARCH block markers:1-3) and can only touch one file at a time. ApplyPatchHandler uses a different format: it begins with *** Begin Patch, separates per-file blocks with *** Add File: path / *** Update File: path / *** Delete File: path, and within each block uses +/- lines to express additions and deletions (PATCH_MARKERS:4-13). This format originates from OpenAI's apply_patch protocol and is well suited to expressing cross-file changes in a single shot.

Within Cline's tool system it also implements IFullyManagedTool, but its state is more complex than WriteToFileToolHandler: it holds two helpers — PathResolver and FileProviderOperations — and execute runs a pipeline of "preprocess → load all files to modify → PatchParser parse → convert to commit → per-file prepare → approve → save → finalize". Each file goes through its own prepare + approve + save cycle, not one bulk pass.

Design motivation

  • Multiple files, one call: lets the model emit a set of logically related changes (e.g. editing an interface and all of its call sites at once) in a single output, avoiding the back-and-forth of N serial WriteToFileToolHandler calls (execute entry:210).
  • Reuse the diff view: each file's change still goes through DiffViewProvider, so a multi-file patch is presented to the user as a per-file diff at approval time, not a single black-box operation (prepareFileChange:632).
  • Bash-wrapper fallback: models often wrap the patch in ```bash / apply_patch / EOF. stripBashWrapper strips these shells off before parsing (stripBashWrapper:433).
  • Incomplete sentinels throw: when only *** Begin Patch or only *** End Patch is present, it throws a DiffError to prompt the model to retry with a smaller patch — no guessing (preprocessLines:416-431).
  • MOVE goes through create + delete: for Update + *** Move to:, the prepare phase treats the new path as a create; only after the save succeeds does it delete the original path (move handling:316-323).
  • Fuzz is passed through: PatchParser allows fuzzy matching during parsing; when fuzz > 0, a hint line is attached to the returned result so the model knows the match was not exact (fuzz note:403-405).

Key files

  • class declaration:45export class ApplyPatchHandler implements IFullyManagedTool, name = ClineDefaultTool.APPLY_PATCH.
  • handlePartialBlock:66 — streams a preview of the first file's patch: calls extractAllFiles to find the path, then previewPatchStream to open the diff view.
  • execute:210 — main flow: preprocess → loadFiles → PatchParser.parse → patchToCommit → per-file prepare/approve/save.
  • preprocessLines:416-431 — fills in missing BEGIN/END sentinels; if only one is missing, throws DiffError("incomplete sentinels").
  • stripBashWrapper:433 — strips %%bash / apply_patch / EOF / ``` wrappers, keeping the patch body.
  • loadFiles:514 — reads all UPDATE/DELETE paths in one pass; clineignore hits and missing files both throw DiffError.
  • patchToCommit:539 — converts the Patch into a Commit structure: DELETE stores oldContent, ADD stores newContent, UPDATE calls applyChunks.
  • applyChunks:584 — slices the original file by each chunk's origIndex, copying unchanged segments, inserting insLines, and skipping delLines.
  • prepareFileChange:632 — uses FileProviderOperations to open and update the diff view, but does not save.
  • handleApproval:718 — asks the user once per file, attaching fileOps (filesCreated/Deleted/Moved) to telemetry.
  • constructNewFileContent:245 — the SEARCH/REPLACE parser used by replace_in_file; an independent path from apply_patch.
  • PATCH_MARKERS:4 — all patch sentinel constants.

Data flow

execute opens with preprocessing and loading: it parses the whole patch text into a structured commit, then runs the approve-and-save loop file by file. The core is the patch → commit conversion:

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 first fills in or rejects inputs with missing sentinels (preprocessLines:416); loadFiles reads the original files touched by UPDATE/DELETE into memory; PatchParser parses the line array into a Patch (actions + chunks + fuzz factor); and patchToCommit converts each action into a FileChange. The UPDATE branch calls applyChunks, which is essentially slicing and reassembling the original file's line array by 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))

Once the commit is built, generateChangeSummary emits a ClineSayTool message for each file, and then for each file it runs prepareFileChange (open diff view, update, no save) → handleApproval (auto or ask) → saveFileChange. For MOVE, after the new file saves successfully, it calls deleteFile(originalPath). After every file is done, it runs markFileAsEditedByCline on each changedFilePath, calls trackFileContext("cline_edited"), and invalidates the fileReadCache.

Boundaries and failure modes

  • Incomplete sentinels: only BEGIN or only END throws DiffError immediately, with a hint to "retry with a smaller patch" (incomplete sentinels:429-431).
  • clineignore hit: if any file hits clineignore during loadFiles, a DiffError is thrown and the entire patch aborts — no partial application (clineignore throw:522-526).
  • File not found: when an UPDATE/DELETE target file is not on disk, a File not found DiffError is thrown (file not found:528-530).
  • One user rejection rolls back everything: when handleApproval returns false, the handler immediately calls revertChanges + reset and returns the rejection message; the remaining files are not processed (reject abort:304-310).
  • MOVE invalidates the old path: invalidating the fileReadCache for a MOVE must cover both the new path and the old path, so a later read of the old file does not return the now-deleted content from cache (move cache invalidate:343-345).
  • Partial block is silent: any parse failure inside previewPatchStream is catch-silently returned; the handler waits for more data to arrive and retries (partial catch:83-85).
  • Out-of-order chunks: when currentIndex is greater than origIndex (i.e. chunk order does not correspond to the original file), a DiffError is thrown (chunk order check:597-599).

Summary

ApplyPatchHandler is the multi-file version of WriteToFileToolHandler. It uses the *** Begin Patch / *** End Patch protocol to express cross-file changes in a single tool_use. It and replace_in_file walk separate parsing paths: the former slices lines via PatchParser, the latter locates blocks via constructNewFileContent using SEARCH/REPLACE. Both ultimately land on DiffViewProvider for presentation to the user.

To dig deeper, continue with:

  • Single-file overwrite / SEARCH/REPLACE: /edit-tools/write-to-file
  • The diff view internals: /edit-tools/diff-view-provider
  • How the Task schedules tools: /agent-loop/task-class

See official docs: Cline 文档 · README