2a367bf043
Slots a new "apply_patch" variant alongside the existing edit modes
(replace, patch, hashline, chunk, vim). The mode accepts a single input
string containing a Codex *** Begin Patch / *** End Patch envelope,
parses it with a new lenient parser (heredoc-tolerant), and fans each
file-op out to the existing executePatchSingle so LSP writethrough,
plan-mode guards, fs-cache invalidation and diagnostics are shared
with the patch mode.
Exposes both tool shapes from the spec: the JSON function-tool variant
(§1.2, {input: string}) and the OpenAI custom-tool / Lark-grammar
"freeform" variant (§1.1, raw patch string). The edit tool advertises
a Lark grammar via customFormat and a wire name via customWireName;
openai-responses emits it as a grammar-constrained custom tool when a
model opts in with applyPatchToolType: "freeform" in models.json.
custom_tool_call / custom_tool_call_output are plumbed end-to-end
through the shared responses code (emission, streaming, history
replay), and the agent-loop dispatcher matches tool calls by either
name or customWireName so returned calls route correctly.
Also threads preview/diff rendering for apply_patch through the TUI
(tool-execution + edit renderer) so streaming patches show per-file
diffs like the other edit modes.
Default edit mode is unchanged (hashline); opt in via edit.mode or
PI_EDIT_VARIANT=apply_patch.
847 lines
33 KiB
Markdown
847 lines
33 KiB
Markdown
# `apply_patch`: Codex Patch Format Specification
|
||
|
||
This document is a full, reimplementation-quality specification of the `apply_patch`
|
||
tool used by the Codex coding harness. It covers:
|
||
|
||
1. How the tool is exposed to the model (schema + freeform grammar variants).
|
||
2. The exact prompt / instruction text shown to the model.
|
||
3. The patch format grammar.
|
||
4. The parser (lexical rules, lenient mode, streaming mode, errors).
|
||
5. The application algorithm (Add / Delete / Update / Move), including the
|
||
`seek_sequence` fuzzy matcher.
|
||
6. Invocation forms the harness accepts (freeform args, JSON args, shell
|
||
heredoc wrappers, stdin).
|
||
7. Result presentation and error reporting.
|
||
8. Test-derived edge cases.
|
||
|
||
All normative behavior below is drawn from the `codex-rs/apply-patch` crate
|
||
(the parser in `src/parser.rs`, the applier in `src/lib.rs`, the matcher in
|
||
`src/seek_sequence.rs`) and the tool registration in `codex-rs/tools`.
|
||
|
||
---
|
||
|
||
## 1. Tool registration
|
||
|
||
`apply_patch` is registered in the tool registry when the current model's
|
||
`apply_patch_tool_type` metadata is set (see
|
||
`codex-rs/models-manager/models.json` — GPT-5.x variants use `"freeform"`;
|
||
older / non-reasoning models use `"function"`). It is registered as
|
||
`supports_parallel_tool_calls = false`, meaning the harness will not issue
|
||
two concurrent `apply_patch` calls. (Registration in
|
||
`codex-rs/tools/src/tool_registry_plan.rs`.)
|
||
|
||
There are two wire formats for the same underlying command.
|
||
|
||
### 1.1 Freeform (GPT-5 and later)
|
||
|
||
The freeform variant uses OpenAI's custom-tool mechanism: the model emits a
|
||
single opaque string whose shape is constrained by a Lark grammar.
|
||
|
||
```jsonc
|
||
{
|
||
"type": "custom",
|
||
"name": "apply_patch",
|
||
"description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
|
||
"format": {
|
||
"type": "grammar",
|
||
"syntax": "lark",
|
||
"definition": "<see §3.2>"
|
||
}
|
||
}
|
||
```
|
||
|
||
(See `codex-rs/tools/src/apply_patch_tool.rs::create_apply_patch_freeform_tool`.)
|
||
|
||
The freeform call payload is the patch text itself — no JSON envelope, no
|
||
wrapping quotes. Example (the model types this verbatim as the tool's
|
||
"input"):
|
||
|
||
```
|
||
*** Begin Patch
|
||
*** Add File: hello.txt
|
||
+Hello, world!
|
||
*** End Patch
|
||
```
|
||
|
||
### 1.2 JSON function (legacy / gpt-oss)
|
||
|
||
For providers that only support function-style tool calls, a JSON variant is
|
||
registered:
|
||
|
||
```jsonc
|
||
{
|
||
"type": "function",
|
||
"name": "apply_patch",
|
||
"description": "<APPLY_PATCH_JSON_TOOL_DESCRIPTION, see §2>",
|
||
"strict": false,
|
||
"parameters": {
|
||
"type": "object",
|
||
"additionalProperties": false,
|
||
"required": ["input"],
|
||
"properties": {
|
||
"input": {
|
||
"type": "string",
|
||
"description": "The entire contents of the apply_patch command"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
(See `codex-rs/tools/src/apply_patch_tool.rs::create_apply_patch_json_tool`.)
|
||
|
||
### 1.3 Handler dispatch
|
||
|
||
Both variants land in the same handler, which normalizes the arguments into
|
||
`ApplyPatchToolArgs { input: String }` and passes `input` to
|
||
`apply_patch::apply_patch(...)` (see
|
||
`codex-rs/core/src/tools/handlers/apply_patch.rs`). The `input` is the full
|
||
patch text, including the `*** Begin Patch` / `*** End Patch` envelope.
|
||
|
||
---
|
||
|
||
## 2. Agent prompt (verbatim)
|
||
|
||
The model is taught the format via two equivalent pieces of text: the
|
||
Markdown file `codex-rs/apply-patch/apply_patch_tool_instructions.md`
|
||
(embedded in system prompts), and the string constant
|
||
`APPLY_PATCH_JSON_TOOL_DESCRIPTION` in
|
||
`codex-rs/tools/src/apply_patch_tool.rs` which is used as the JSON tool's
|
||
`description`. They are identical in content.
|
||
|
||
A reimplementation SHOULD ship the following text verbatim (note: the
|
||
original uses Unicode curly quotes in a few places — they are reproduced
|
||
here; the final "smart quote" versus "typewriter quote" choice is not
|
||
semantic):
|
||
|
||
````markdown
|
||
## `apply_patch`
|
||
|
||
Use the `apply_patch` shell command to edit files.
|
||
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||
|
||
*** Begin Patch
|
||
[ one or more file sections ]
|
||
*** End Patch
|
||
|
||
Within that envelope, you get a sequence of file operations.
|
||
You MUST include a header to specify the action you are taking.
|
||
Each operation starts with one of three headers:
|
||
|
||
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
|
||
*** Delete File: <path> - remove an existing file. Nothing follows.
|
||
*** Update File: <path> - patch an existing file in place (optionally with a rename).
|
||
|
||
May be immediately followed by *** Move to: <new path> if you want to rename the file.
|
||
Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
|
||
Within a hunk each line starts with:
|
||
|
||
For instructions on [context_before] and [context_after]:
|
||
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change's [context_after] lines in the second change's [context_before] lines.
|
||
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
|
||
@@ class BaseClass
|
||
[3 lines of pre-context]
|
||
- [old_code]
|
||
+ [new_code]
|
||
[3 lines of post-context]
|
||
|
||
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
|
||
|
||
@@ class BaseClass
|
||
@@ def method():
|
||
[3 lines of pre-context]
|
||
- [old_code]
|
||
+ [new_code]
|
||
[3 lines of post-context]
|
||
|
||
The full grammar definition is below:
|
||
Patch := Begin { FileOp } End
|
||
Begin := "*** Begin Patch" NEWLINE
|
||
End := "*** End Patch" NEWLINE
|
||
FileOp := AddFile | DeleteFile | UpdateFile
|
||
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
|
||
DeleteFile := "*** Delete File: " path NEWLINE
|
||
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
|
||
MoveTo := "*** Move to: " newPath NEWLINE
|
||
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
|
||
HunkLine := (" " | "-" | "+") text NEWLINE
|
||
|
||
A full patch can combine several operations:
|
||
|
||
*** Begin Patch
|
||
*** Add File: hello.txt
|
||
+Hello world
|
||
*** Update File: src/app.py
|
||
*** Move to: src/main.py
|
||
@@ def greet():
|
||
-print("Hi")
|
||
+print("Hello, world!")
|
||
*** Delete File: obsolete.txt
|
||
*** End Patch
|
||
|
||
It is important to remember:
|
||
|
||
- You must include a header with your intended action (Add/Delete/Update)
|
||
- You must prefix new lines with `+` even when creating a new file
|
||
- File references can only be relative, NEVER ABSOLUTE.
|
||
|
||
You can invoke apply_patch like:
|
||
|
||
```
|
||
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
|
||
```
|
||
````
|
||
|
||
Notes for implementers:
|
||
|
||
- The "You can invoke apply_patch like ..." footer is only shown when the
|
||
patch command is exposed as a `shell` command (the `codex` fallback path).
|
||
When the freeform tool is registered, the model invokes the tool directly
|
||
and the footer is redundant but harmless.
|
||
- The prompt asserts some things the parser is lenient about in practice:
|
||
- *"File references can only be relative"* — the parser accepts absolute
|
||
paths, but the model is instructed to produce relative ones.
|
||
- *"+ line even when creating a new file"* — `+` MUST be the first
|
||
character of every content line in an Add File section.
|
||
|
||
---
|
||
|
||
## 3. Patch grammar
|
||
|
||
### 3.1 EBNF (canonical)
|
||
|
||
```
|
||
Patch := Begin { FileOp }+ End
|
||
Begin := "*** Begin Patch" NEWLINE
|
||
End := "*** End Patch" [NEWLINE]
|
||
|
||
FileOp := AddFile | DeleteFile | UpdateFile
|
||
AddFile := "*** Add File: " path NEWLINE { "+" text NEWLINE }+
|
||
DeleteFile := "*** Delete File: " path NEWLINE
|
||
UpdateFile := "*** Update File: " path NEWLINE
|
||
[ "*** Move to: " path NEWLINE ]
|
||
Change?
|
||
|
||
Change := (ChangeContext | ChangeLine)+ EofLine?
|
||
ChangeContext := ("@@" | "@@ " text) NEWLINE
|
||
ChangeLine := (" " | "-" | "+") text NEWLINE
|
||
EofLine := "*** End of File" NEWLINE
|
||
```
|
||
|
||
The Lark grammar that the OpenAI freeform tool uses to constrain model
|
||
output is in `codex-rs/tools/src/tool_apply_patch.lark`:
|
||
|
||
```lark
|
||
start: begin_patch hunk+ end_patch
|
||
begin_patch: "*** Begin Patch" LF
|
||
end_patch: "*** End Patch" LF?
|
||
|
||
hunk: add_hunk | delete_hunk | update_hunk
|
||
add_hunk: "*** Add File: " filename LF add_line+
|
||
delete_hunk: "*** Delete File: " filename LF
|
||
update_hunk: "*** Update File: " filename LF change_move? change?
|
||
|
||
filename: /(.+)/
|
||
add_line: "+" /(.*)/ LF -> line
|
||
|
||
change_move: "*** Move to: " filename LF
|
||
change: (change_context | change_line)+ eof_line?
|
||
change_context: ("@@" | "@@ " /(.+)/) LF
|
||
change_line: ("+" | "-" | " ") /(.*)/ LF
|
||
eof_line: "*** End of File" LF
|
||
|
||
%import common.LF
|
||
```
|
||
|
||
### 3.2 Reserved tokens
|
||
|
||
| Token | Meaning |
|
||
| ---------------------------- | --------------------------------------------------------------- |
|
||
| `*** Begin Patch` | Required first significant line of the envelope. |
|
||
| `*** End Patch` | Required last significant line (trailing LF optional). |
|
||
| `*** Add File: <path>` | Start of an Add File section. |
|
||
| `*** Delete File: <path>` | Standalone Delete File directive. |
|
||
| `*** Update File: <path>` | Start of an Update File section. |
|
||
| `*** Move to: <path>` | Optional rename target, immediately after `*** Update File:`. |
|
||
| `@@` or `@@ <header>` | Starts a chunk inside an Update File. |
|
||
| `*** End of File` | Terminates a chunk; asserts the chunk ended at EOF. |
|
||
| `+<text>` / `-<text>` / ` <text>` | Added / deleted / context line inside a chunk. |
|
||
|
||
Action headers match **with a trailing space**: the parser uses literal
|
||
`strip_prefix("*** Add File: ")` etc. Everything after the space is the
|
||
path; no escaping, no quoting.
|
||
|
||
### 3.3 Lines and newlines
|
||
|
||
- Input is split on `\n` (LF only). CRLF is not supported by the parser —
|
||
producers MUST use LF. (Tool output goes through Rust string handling which
|
||
preserves CRs as literal bytes on the content lines, which then fail to
|
||
match.)
|
||
- Each content line in a hunk starts with exactly one byte (`+`, `-`, or
|
||
space) followed by the line's text and then a newline. An empty ` `-prefixed
|
||
line is representable as a single space followed by LF; a bare `+\n` is a
|
||
one-character added empty line.
|
||
- A completely blank line (no prefix byte at all) inside an Update File
|
||
section is **skipped**, and is used only for visual separation between
|
||
chunks. This is a deliberate leniency; do not rely on blank lines to carry
|
||
data.
|
||
|
||
---
|
||
|
||
## 4. Parser
|
||
|
||
Implementation: `codex-rs/apply-patch/src/parser.rs`.
|
||
|
||
### 4.1 Public entry points
|
||
|
||
- `parse_patch(text: &str) -> Result<ApplyPatchArgs, ParseError>` —
|
||
production parse. Uses `ParseMode::Lenient` (see §4.3).
|
||
- `parse_patch_streaming(text: &str) -> Result<ApplyPatchArgs, ParseError>` —
|
||
same format, but tolerates a missing `*** End Patch` (the harness calls
|
||
this from a streaming response handler to show progress). Its output
|
||
MUST NOT be used to actually apply the patch.
|
||
|
||
The result is:
|
||
|
||
```rust
|
||
pub struct ApplyPatchArgs {
|
||
pub patch: String, // canonicalized patch text (heredoc stripped)
|
||
pub hunks: Vec<Hunk>,
|
||
pub workdir: Option<String>, // populated only when parsing a shell
|
||
// invocation that begins with `cd <path> &&`
|
||
}
|
||
```
|
||
|
||
### 4.2 Lexical canonicalization
|
||
|
||
Before parsing, the input is `trim()`ed, then split into lines by `\n`.
|
||
Marker-line matching is done against `line.trim()`, so arbitrary
|
||
leading/trailing whitespace around the sentinel lines (`*** Begin Patch`,
|
||
etc.) is accepted. Content lines (those starting with `+`, `-`, ` `) are
|
||
NOT trimmed — their whitespace is significant.
|
||
|
||
### 4.3 Parse modes
|
||
|
||
The parser operates in one of three modes:
|
||
|
||
1. **Strict** — requires line 0 = `*** Begin Patch` and the last line to be
|
||
`*** End Patch`. Not used by the harness today (`PARSE_IN_STRICT_MODE =
|
||
false`); retained as a fallback.
|
||
|
||
2. **Lenient** (default). Tries strict first. If that fails, attempts to
|
||
strip a heredoc wrapper: the first line must be exactly one of
|
||
`<<EOF`, `<<'EOF'`, or `<<"EOF"`, and the last line must be `EOF`. The
|
||
inner region is then parsed strictly. This was introduced to handle
|
||
gpt-4.1, which insisted on wrapping the patch in a heredoc body.
|
||
Mismatched quotes (e.g. `<<"EOF'`) are rejected.
|
||
|
||
3. **Streaming** — requires `*** Begin Patch` but does NOT require
|
||
`*** End Patch`. Individual hunks are parsed on a best-effort basis; the
|
||
last incomplete hunk is dropped. Used for progress UI only.
|
||
|
||
### 4.4 Hunk parsing state machine
|
||
|
||
```
|
||
loop:
|
||
trim line[i]
|
||
if line[i] starts with "*** End Patch": break
|
||
match line[i].strip_prefix(...):
|
||
"*** Add File: " -> parse AddFile
|
||
"*** Delete File: " -> parse DeleteFile
|
||
"*** Update File: " -> parse UpdateFile
|
||
otherwise -> InvalidHunkError at line i
|
||
```
|
||
|
||
**AddFile** consumes subsequent lines while they start with `+`. Each line
|
||
becomes one element of `contents`, with the leading `+` stripped. The lines
|
||
are joined with `\n`; an additional `\n` is appended after the join so the
|
||
resulting file ends with exactly one newline.
|
||
|
||
**DeleteFile** consumes only the header line; no content follows.
|
||
|
||
**UpdateFile** consumes the header, optionally a `*** Move to: <path>` line,
|
||
then zero or more chunks. The UpdateFile section ends when the next line
|
||
starts with `***` (the next hunk header, or `*** End Patch`) or input is
|
||
exhausted. An UpdateFile section with zero chunks is an error
|
||
(`"Update file hunk for path '<p>' is empty"`).
|
||
|
||
**Chunk** parsing within an UpdateFile:
|
||
|
||
```
|
||
chunk:
|
||
optional context line:
|
||
"@@" -> change_context = None (empty marker)
|
||
"@@ <header>" -> change_context = Some("<header>")
|
||
otherwise -> if this is the FIRST chunk of the hunk, fall through
|
||
(context-less first chunk); otherwise error.
|
||
then:
|
||
loop over lines:
|
||
if line starts with "*" -> stop (end of chunk; next hunk or end)
|
||
if line == "" -> append empty string to BOTH old_lines
|
||
and new_lines (empty context line)
|
||
if line starts with ' ' -> append line[1..] to BOTH old_lines and
|
||
new_lines
|
||
if line starts with '-' -> append line[1..] to old_lines
|
||
if line starts with '+' -> append line[1..] to new_lines
|
||
if line == "*** End of File" -> set is_end_of_file = true, stop
|
||
otherwise -> error: unexpected line in update hunk
|
||
```
|
||
|
||
Invariants enforced by the parser:
|
||
|
||
- A chunk must contain at least one non-context line (pure context chunks
|
||
are rejected).
|
||
- `*** End of File` cannot be the first line of a chunk.
|
||
- Chunks are stored in order and the applier relies on each chunk's match
|
||
position being ≥ the previous chunk's match position.
|
||
|
||
### 4.5 Error taxonomy
|
||
|
||
```rust
|
||
pub enum ParseError {
|
||
InvalidPatchError(String), // envelope errors
|
||
InvalidHunkError { message: String, line_number: usize }, // content errors
|
||
}
|
||
```
|
||
|
||
Specific messages (implementers SHOULD match these literally so tests and
|
||
downstream tools keep working):
|
||
|
||
| Error | Condition |
|
||
| ------------------------------------ | ---------------------------------------------------------------------- |
|
||
| `The first line of the patch must be '*** Begin Patch'` | Missing/wrong `Begin` marker. |
|
||
| `The last line of the patch must be '*** End Patch'` | Missing/wrong `End` marker (strict/lenient only). |
|
||
| `'<line>' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'` | Unknown file-level directive. |
|
||
| `Update file hunk for path '<p>' is empty` | UpdateFile section has zero chunks. |
|
||
| `Expected update hunk to start with a @@ context marker, got: '<line>'` | Missing `@@` when required (non-first chunk). |
|
||
| `Update hunk does not contain any lines` | Chunk with a context but no +/-/space line before EOF/next chunk. |
|
||
| `Unexpected line found in update hunk: '<line>'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)` | Invalid diff prefix. |
|
||
|
||
---
|
||
|
||
## 5. Intermediate representation
|
||
|
||
```rust
|
||
pub enum Hunk {
|
||
AddFile { path: PathBuf, contents: String },
|
||
DeleteFile { path: PathBuf },
|
||
UpdateFile {
|
||
path: PathBuf,
|
||
move_path: Option<PathBuf>,
|
||
chunks: Vec<UpdateFileChunk>, // MUST be non-empty
|
||
},
|
||
}
|
||
|
||
pub struct UpdateFileChunk {
|
||
pub change_context: Option<String>, // text after "@@ "; None for bare "@@"
|
||
pub old_lines: Vec<String>, // lines to match in the file
|
||
pub new_lines: Vec<String>, // replacement lines
|
||
pub is_end_of_file: bool, // "*** End of File" present
|
||
}
|
||
```
|
||
|
||
Paths are stored exactly as the patch wrote them — no canonicalization.
|
||
Resolution to an absolute path happens at apply time via
|
||
`AbsolutePathBuf::resolve_path_against_base(path, cwd)`.
|
||
|
||
---
|
||
|
||
## 6. Application algorithm
|
||
|
||
Implementation: `codex-rs/apply-patch/src/lib.rs`.
|
||
|
||
### 6.1 Top-level flow
|
||
|
||
```
|
||
apply_patch(input, cwd, fs, sandbox):
|
||
args := parse_patch(input)?
|
||
if args.hunks is empty: error "No files were modified."
|
||
affected := { added: [], modified: [], deleted: [] }
|
||
for each hunk in args.hunks:
|
||
apply_hunk(hunk, cwd, fs, sandbox, affected)?
|
||
return affected
|
||
```
|
||
|
||
Hunks are applied **in the order they appear in the patch** and **not
|
||
atomically**: if hunk N fails, hunks `0..N-1` have already written to disk
|
||
and are not rolled back. Hunks `N+1..` are skipped. (Test scenario
|
||
`015_failure_after_partial_success_leaves_changes` pins this behavior.)
|
||
|
||
A reimplementation MAY add transactional semantics, but MUST document the
|
||
deviation — callers today rely on partial application being observable.
|
||
|
||
### 6.2 Add File
|
||
|
||
```
|
||
path_abs := resolve_path_against_base(hunk.path, cwd)
|
||
try: fs.write_file(path_abs, contents)
|
||
on NotFound: fs.create_directory(parent(path_abs), { recursive: true })
|
||
fs.write_file(path_abs, contents)
|
||
append hunk.path to affected.added
|
||
```
|
||
|
||
Characteristics:
|
||
|
||
- Parent directories are created on demand (recursive). This is done lazily
|
||
— only after a first write fails with `NotFound`.
|
||
- An existing file at `path_abs` is **silently overwritten** (scenario
|
||
`011_add_overwrites_existing_file`). No confirmation, no diff.
|
||
- `contents` is the literal joined string from the parser — the parser
|
||
already terminates it with `\n`.
|
||
|
||
### 6.3 Delete File
|
||
|
||
```
|
||
path_abs := resolve_path_against_base(hunk.path, cwd)
|
||
meta := fs.get_metadata(path_abs)
|
||
if meta.is_directory: error "path is a directory"
|
||
fs.remove(path_abs, { recursive: false, force: false })
|
||
append hunk.path to affected.deleted
|
||
```
|
||
|
||
- If the file does not exist, the metadata call returns `NotFound` and the
|
||
whole `apply_patch` fails with `Failed to delete file <p>: ...`.
|
||
- Deleting directories is explicitly rejected.
|
||
|
||
### 6.4 Update File
|
||
|
||
```
|
||
applied := derive_new_contents_from_chunks(path_abs, hunk.chunks, fs)?
|
||
if hunk.move_path.is_some():
|
||
dest_abs := resolve_path_against_base(move_path, cwd)
|
||
write_with_missing_parent_retry(dest_abs, applied.new_contents)
|
||
ensure source isn't a directory; fs.remove(path_abs)
|
||
else:
|
||
fs.write_file(path_abs, applied.new_contents)
|
||
append hunk.path to affected.modified
|
||
```
|
||
|
||
Note: a renamed file is reported as **modified** (`M`) with the original
|
||
path, not as `D` + `A`. This is intentional — it mirrors git's rename
|
||
detection.
|
||
|
||
`derive_new_contents_from_chunks`:
|
||
|
||
```
|
||
text := fs.read_file_text(path_abs) // utf-8, error if missing
|
||
lines := text.split('\n') // retains trailing '' if text ends '\n'
|
||
if lines.last() == "": lines.pop() // normalize off trailing-\n artifact
|
||
|
||
replacements := compute_replacements(lines, chunks)?
|
||
new_lines := apply_replacements(lines, replacements)
|
||
if new_lines.last() != "": new_lines.push("") // re-add trailing newline
|
||
return new_lines.join('\n')
|
||
```
|
||
|
||
Postcondition: **every update produces a file ending in exactly one `\n`**,
|
||
regardless of whether the input had one. Reimplementations MAY choose to
|
||
preserve "no trailing newline" when present — doing so is a deliberate
|
||
deviation.
|
||
|
||
### 6.5 Computing replacements
|
||
|
||
```
|
||
line_index := 0
|
||
replacements := []
|
||
for each chunk:
|
||
# 1. If the chunk has a "@@ ctx" marker, locate that line first.
|
||
if chunk.change_context.is_some():
|
||
idx := seek_sequence([ctx], lines, start=line_index, eof=false)?
|
||
line_index := idx + 1 # search for old_lines AFTER ctx
|
||
|
||
# 2. Pure-addition chunks (old_lines empty):
|
||
if chunk.old_lines.is_empty():
|
||
insertion_idx := (if lines.last() == "" then lines.len() - 1
|
||
else lines.len())
|
||
replacements.push((insertion_idx, 0, chunk.new_lines))
|
||
continue
|
||
|
||
# 3. Normal replacement: match old_lines in the file.
|
||
pattern := chunk.old_lines
|
||
new_slc := chunk.new_lines
|
||
found := seek_sequence(lines, pattern, line_index, chunk.is_end_of_file)
|
||
if found.is_none() and pattern.last() == "":
|
||
pattern := pattern[..pattern.len()-1] # drop trailing empty sentinel
|
||
if new_slc.last() == "":
|
||
new_slc := new_slc[..new_slc.len()-1]
|
||
found := seek_sequence(lines, pattern, line_index, chunk.is_end_of_file)
|
||
match found:
|
||
Some(start): replacements.push((start, pattern.len(), new_slc))
|
||
line_index := start + pattern.len()
|
||
None: error "Failed to find expected lines in <path>:\n<old_lines joined>"
|
||
|
||
replacements.sort_by_start_index()
|
||
```
|
||
|
||
Key invariants:
|
||
|
||
- Context (`@@ ctx`) is matched by *one line*; it is a locator only and is
|
||
never modified. After a successful context match, the old_lines search
|
||
begins **immediately after** the context line.
|
||
- A chunk's `old_lines` search starts at `line_index` (the cursor after the
|
||
previous chunk), so chunks must appear in file order.
|
||
- Pure-addition chunks (no `-` or ` ` lines, only `+`) append at the end of
|
||
the file. They do NOT honor `line_index`; they always go to the end.
|
||
- The "trailing empty sentinel" retry exists because unified-diff-style
|
||
tools often emit a blank line at the end of `old_lines` representing the
|
||
file's terminal newline. Our line-splitting strips that element from the
|
||
file, so a literal match fails; we retry with the sentinel removed.
|
||
|
||
Replacements are then applied **in reverse order of `start_index`** so
|
||
earlier edits do not shift later edits' indices:
|
||
|
||
```
|
||
apply_replacements(lines, replacements):
|
||
for (start, old_len, new_seg) in replacements.reversed():
|
||
delete lines[start .. start + old_len]
|
||
insert new_seg at position start
|
||
```
|
||
|
||
### 6.6 `seek_sequence` — the fuzzy matcher
|
||
|
||
Implementation: `codex-rs/apply-patch/src/seek_sequence.rs`.
|
||
|
||
Signature:
|
||
|
||
```rust
|
||
fn seek_sequence(
|
||
lines: &[String], pattern: &[String],
|
||
start: usize, eof: bool,
|
||
) -> Option<usize>
|
||
```
|
||
|
||
Contract:
|
||
|
||
- Returns the smallest `i ≥ search_start` such that
|
||
`lines[i..i + pattern.len()]` matches `pattern` under one of four match
|
||
predicates (tried in order). `search_start = lines.len() - pattern.len()`
|
||
if `eof` and the pattern fits, else `start`.
|
||
- Empty `pattern` → `Some(start)`.
|
||
- `pattern.len() > lines.len()` → `None` (MUST NOT panic).
|
||
|
||
The four match predicates, tried in order (first success wins):
|
||
|
||
1. **Exact**. `lines[i + k] == pattern[k]` for all k.
|
||
2. **Rstrip**. `lines[i + k].trim_end() == pattern[k].trim_end()`.
|
||
3. **Full trim**. `lines[i + k].trim() == pattern[k].trim()`.
|
||
4. **Unicode-normalized trim**. Trim, then fold common typographic
|
||
punctuation to ASCII, then compare.
|
||
|
||
Normalization table (MUST be implemented identically):
|
||
|
||
| Folded to | Source code points |
|
||
| --------- | ------------------ |
|
||
| `-` | U+2010 HYPHEN, U+2011 NON-BREAKING HYPHEN, U+2012 FIGURE DASH, U+2013 EN DASH, U+2014 EM DASH, U+2015 HORIZONTAL BAR, U+2212 MINUS SIGN |
|
||
| `'` | U+2018, U+2019, U+201A, U+201B |
|
||
| `"` | U+201C, U+201D, U+201E, U+201F |
|
||
| ` ` (space) | U+00A0 NBSP, U+2002, U+2003, U+2004, U+2005, U+2006, U+2007, U+2008, U+2009, U+200A, U+202F, U+205F, U+3000 |
|
||
|
||
All other code points are passed through unchanged. This lets the model
|
||
emit ASCII hyphens/quotes/spaces even when the source file contains
|
||
typographic variants (e.g. an em-dash pasted from a doc).
|
||
|
||
**Per-chunk EOF hint.** When `eof == true` (set from `is_end_of_file`), the
|
||
matcher first tries to match at the tail of the file
|
||
(`i = lines.len() - pattern.len()`) before falling through to the normal
|
||
forward search from `start`.
|
||
|
||
**What's deliberately not supported.**
|
||
|
||
- No "floating" / best-effort match. If all four passes fail, the chunk
|
||
fails; there is no nearest-match heuristic.
|
||
- No matching across non-adjacent lines — the pattern must appear as a
|
||
contiguous block.
|
||
- No multi-match disambiguation: the **first** match wins. Chunks must
|
||
carry enough context (or a `@@ header`) that the first hit at or after
|
||
`line_index` is the intended one.
|
||
|
||
---
|
||
|
||
## 7. Path resolution
|
||
|
||
- `cwd` is the `AbsolutePathBuf` passed to `apply_patch(...)`. In the CLI /
|
||
harness it defaults to the process working directory, possibly further
|
||
qualified by a workdir extracted from a `cd X && apply_patch <<EOF` shell
|
||
invocation (§8.3).
|
||
- `resolve_path_against_base(path, cwd)`:
|
||
- If `path` is absolute → `path` (cwd is ignored).
|
||
- If `path` is relative → `cwd.join(path)`.
|
||
- The patch grammar does not define an escape mechanism. Paths containing
|
||
spaces, tabs, or Unicode are supported as-is (the parser takes the full
|
||
rest of the header line); paths containing literal newlines are
|
||
unrepresentable by construction.
|
||
- A `FileSystemSandboxContext` MAY be passed in; when present, every
|
||
filesystem call is routed through it. All of `read_file_text`,
|
||
`write_file`, `remove`, `get_metadata`, `create_directory` receive the
|
||
sandbox. The sandbox is responsible for enforcing path restrictions —
|
||
the applier does no check of its own.
|
||
|
||
---
|
||
|
||
## 8. Invocation forms
|
||
|
||
The applier accepts the patch text through several transport layers. A
|
||
reimplementation only strictly needs §8.1 (tool arg) — the others exist for
|
||
historical compatibility.
|
||
|
||
### 8.1 Direct tool argument (freeform or JSON)
|
||
|
||
The preferred form. Either the freeform tool's `input` or the JSON tool's
|
||
`input` string is the full patch, e.g.:
|
||
|
||
```
|
||
*** Begin Patch
|
||
*** Add File: hello.txt
|
||
+Hello, world!
|
||
*** End Patch
|
||
```
|
||
|
||
### 8.2 Heredoc-wrapped
|
||
|
||
When the patch is invoked via `shell` (the legacy path), the model wraps
|
||
the patch in a heredoc. The parser's lenient mode strips the outermost
|
||
heredoc wrapper:
|
||
|
||
```
|
||
<<EOF
|
||
*** Begin Patch
|
||
...
|
||
*** End Patch
|
||
EOF
|
||
```
|
||
|
||
The opener must be one of `<<EOF`, `<<'EOF'`, `<<"EOF"`; the closer must be
|
||
`EOF` on its own line. Mismatched quoting (`<<"EOF'`) or a missing closer
|
||
is rejected.
|
||
|
||
### 8.3 Shell script with workdir
|
||
|
||
The harness also recognizes a `cd <path> && apply_patch <<'EOF' ... EOF`
|
||
shell invocation (parsed via Tree-sitter in
|
||
`codex-rs/apply-patch/src/invocation.rs`). The `<path>` is extracted into
|
||
`ApplyPatchArgs.workdir` and used to qualify `cwd` before applying hunks.
|
||
Any other pre- or post-commands cause the parse to fail over to "treat as
|
||
a regular shell command" rather than `apply_patch`.
|
||
|
||
### 8.4 stdin (standalone executable)
|
||
|
||
`codex-rs/apply-patch/src/standalone_executable.rs` lets the binary be
|
||
invoked as `apply_patch` with the patch on argv[1], OR with no args and the
|
||
patch piped on stdin.
|
||
|
||
---
|
||
|
||
## 9. Result presentation
|
||
|
||
### 9.1 Success
|
||
|
||
After a successful apply, the caller renders a git-style summary
|
||
(`codex-rs/apply-patch/src/lib.rs::print_summary`):
|
||
|
||
```
|
||
Success. Updated the following files:
|
||
A <added path 1>
|
||
A <added path 2>
|
||
M <modified or renamed path>
|
||
D <deleted path>
|
||
```
|
||
|
||
- Sections appear in the order Added / Modified / Deleted.
|
||
- Paths are the ones spelled in the patch (not canonicalized).
|
||
- Renamed files appear under `M` with the **original** path, not the
|
||
destination.
|
||
- Exit status 0.
|
||
|
||
### 9.2 Failure
|
||
|
||
- Parse errors: written to stderr as
|
||
`Invalid patch: <message>` or
|
||
`Invalid patch hunk on line <N>: <message>`.
|
||
- Apply errors (context miss / old_lines miss / IO): written to stderr
|
||
with the Rust `anyhow` chain, e.g.:
|
||
- `Failed to find context '<ctx>' in <path>`
|
||
- `Failed to find expected lines in <path>:\n<block>`
|
||
- `Failed to read file to update <path>: <io err>`
|
||
- `Failed to write file <path>: <io err>`
|
||
- `Failed to delete file <path>: <io err>`
|
||
- `Failed to remove original <path>: <io err>`
|
||
- `Failed to create parent directories for <path>: <io err>`
|
||
- Exit status 1 (apply/parse failure) or 2 (argv usage error).
|
||
|
||
### 9.3 Harness-side tool call result
|
||
|
||
When invoked through the harness, the handler wraps the above in
|
||
`ExecToolCallOutput { exit_code, stdout, stderr, aggregated_output, duration,
|
||
timed_out }`. The model sees `aggregated_output`.
|
||
|
||
### 9.4 Progress events
|
||
|
||
The `PatchApplyUpdatedEvent` is emitted to the TUI as each hunk is applied
|
||
(only when the progress feature is on). This is a UX detail and is not part
|
||
of the observable patch semantics.
|
||
|
||
---
|
||
|
||
## 10. Edge cases (test-derived)
|
||
|
||
| Case | Behavior |
|
||
| --------------------------------------------------- | -------- |
|
||
| Patch with zero hunks | Error: `No files were modified.` |
|
||
| Add File overwriting an existing file | Silent overwrite. |
|
||
| Delete File on a directory | Error: `path is a directory`. |
|
||
| Delete File on a nonexistent file | Error propagated from `fs.get_metadata` / `fs.remove`. |
|
||
| Move to an existing destination | Destination overwritten; source removed. |
|
||
| Update File with 0 chunks | Parse error: `Update file hunk for path '<p>' is empty`. |
|
||
| Chunk with only `+` lines (pure addition) | Inserts at end of file (before final empty line if any). |
|
||
| Chunk whose `old_lines` end in an empty string | Retry without the trailing empty; lets EOF edits match. |
|
||
| Patch with `*** End of File` marker | `is_end_of_file = true`; matcher tries tail-of-file first. |
|
||
| Unicode dash/quote/NBSP mismatch between patch and file | Normalized-trim match (4th seek pass) matches. |
|
||
| Leading/trailing whitespace on a sentinel line | Ignored (`line.trim()` before marker compare). |
|
||
| Blank line inside an Update File between chunks | Ignored (used as visual separator). |
|
||
| Heredoc wrapper around the whole patch | Stripped in Lenient mode. |
|
||
| Streaming: `*** End Patch` absent yet | OK in `parse_patch_streaming`; last incomplete hunk is dropped. |
|
||
| First chunk of an Update File lacks `@@` | Allowed (context-less first chunk). |
|
||
| Non-first chunk missing `@@` | Parse error. |
|
||
| Absolute path in patch | Accepted by parser; model is told not to emit these. |
|
||
| Multiple chunks touching the same file | Applied in reverse start-order; must be in file-order in the patch. |
|
||
| One hunk of N fails | Prior hunks remain applied; later hunks skipped. |
|
||
| File with no trailing newline as input | Output gains one (post-condition). |
|
||
|
||
---
|
||
|
||
## 11. Reimplementation checklist
|
||
|
||
To reimplement this format end-to-end, a conforming implementation MUST:
|
||
|
||
- [ ] Accept the exact sentinel tokens in §3.2 with the trailing space
|
||
where required; match marker lines after `trim()` only.
|
||
- [ ] Parse the grammar in §3.1 including the context-less first chunk
|
||
allowance, the `*** End of File` terminator, blank-line separation
|
||
between chunks, and the `*** Move to:` renames.
|
||
- [ ] Implement Lenient mode (heredoc strip) and Streaming mode as
|
||
described in §4.3.
|
||
- [ ] Emit the error messages in §4.5 verbatim (test compatibility).
|
||
- [ ] For Update File, read the target with UTF-8, split on `\n`, drop the
|
||
trailing empty element, apply chunks via the replacement machinery in
|
||
§6.5, and re-add a trailing newline before writing.
|
||
- [ ] Implement `seek_sequence` with the four-pass strictness hierarchy and
|
||
the exact Unicode normalization table in §6.6, including the
|
||
pattern-longer-than-input → `None` guard and the `eof` tail-first
|
||
search.
|
||
- [ ] Apply hunks sequentially and non-atomically; do not rollback on
|
||
partial failure.
|
||
- [ ] Silently overwrite existing destinations for Add File and Move.
|
||
- [ ] Emit the `Success. Updated the following files:` / `A`/`M`/`D`
|
||
summary in §9.1 on success, and stderr messages in §9.2 on failure.
|
||
- [ ] Register both freeform-grammar and JSON-function tool variants with
|
||
`supports_parallel_tool_calls = false`.
|
||
- [ ] Ship the agent prompt in §2 verbatim.
|
||
|
||
Optional / harness features (not required for correctness):
|
||
|
||
- Heredoc `cd <dir> && apply_patch <<'EOF' ... EOF` shell-form detection
|
||
with workdir extraction.
|
||
- Streaming progress events to the UI.
|
||
- Unified-diff rendering (`unified_diff_from_chunks`) for displaying a
|
||
user-visible diff after apply.
|