apply-patch: A Diff Designed for Models
The model fills in a line-number-free patch; people see a unified diff computed after the fact. Same edit — watch where each format wrecks.
@@ -l,s +l,s; and why a single file stays off disk when context misses — and why that promise does not hold across files.
pass in greet with return 123- The grammar’s
@@is an anchor only — no start line, no spanparser.rs L20 - Update-file chunks must stay in the order they appear in the fileparser.rs L74
- With
change_context, search downward fromline_indexfile_update.rs L99 - Exact whole-line first, then strip trailing whitespace, then trim both sidesseek_sequence.rs L40
- If the anchor is missing, fail immediately with Failed to find context — no nearby-line guessingfile_update.rs L109
- All chunks of one file finish in memory before write_file runslib.rs L695
- A cross-file failure returns with already-committed deltas — no rollbacklib.rs L453
- The unified diff people see is computed later with TextDifffile_update.rs L328
@@ header must get old start, old span, new start, and new span all right. Insert two lines above, and all four numbers die together.@@ def greet(): and searches at runtime. Two inserted lines still match — line numbers never entered this format.You ask the model to change a function: replace pass in greet with return 123. It emits a standard unified diff whose first line reads @@ -47,3 +47,3 @@.
Another edit just inserted two lines, so greet is already on line 49. The model counted inside a numbered excerpt, then has to add start and span itself when writing the patch. Getting all four numbers wrong is the normal case.
patch(1) hunts by line number, then fuzzes if it misses. Fail the fuzz and the whole patch dies. Worse: it may stick the change onto a neighboring function. Tests stay green — you just edited the wrong function.
Codex splits filling from reading. The copy the model fills has no line numbers. Updating a hunk, the first line is just @@ def greet():. A bare @@ means keep searching from here. Location is handed to runtime seek_sequence.
When a person reads the change, the UI separately builds a standard unified diff with similar::TextDiff. The Codex format in the tool arguments is done by then.
codex-rs/apply-patch/src/file_update.rs lines 328–329
The grammar writes “no line numbers” into the productions. Both @@ forms carry a text anchor only:
//! change_context: ("@@" | "@@ " /(.+)/) LF
//! change_line: ("+" | "-" | " ") /(.+)/ LF
//! eof_line: "*** End of File" LF
openai/codex repo; verified against codex-rs/apply-patch/src/parser.rs, commit 4f39251a01, verified 2026-08-22. Code blocks keep the original source. These three lines are the diff header for the model: anchors, no line numbers.The spec sent to the model calls this language a “stripped-down, file-oriented diff format designed to be easy to parse and safe to apply.” Add, Delete, and Move are three markers in the grammar; the parser dispatches on the marker. The model never has to remember how to assemble ---, +++, /dev/null, or a rename header.
Coordinates come from addition; content comes from recognition. Having the model count line numbers doesn’t get better if you swap the model or the language. Moving “which hunk” from fill-time arithmetic to apply-time string search doesn’t depend on Rust, or on unified diff as a format.
When the model writes a patch, a trailing space, or an en-dash in the file that it wrote as a minus, are high-frequency accidents. Fail the whole patch every time and the model can only rewrite. Retry a few lines near the number, and you’re back to fuzz landing on the wrong function.
seek_sequence searches four grades, tight to loose. Grade 1: exact whole-line equality. Grade 2: drop trailing whitespace, then compare. Grade 3: trim() both sides. Grade 4: fold common Unicode dashes and curly quotes to ASCII. Fail all four and it returns empty — “Failed to find context” or “Failed to find expected lines.” There is no nearby-line retry loop.
codex-rs/apply-patch/src/seek_sequence.rs lines 40–114
An early incident added the fourth grade specifically for odd Unicode. It loosens whitespace and punctuation, not position. Full-width Chinese quotes are not in the normalize table: if the model writes a full-width left quote and the file has a half-width one, all four grades fail.
Tolerance has to split two kinds of difference. Trailing spaces and curly quotes are meaningless byte noise — normalize them. A two-line number shift is the wrong place — error out and make the model rewrite. That boundary still holds if you rewrite in another language.
A patch has two chunks. The second misses, the first already went in, and the file becomes a half-finished product. The person debugging sees a half-applied file — harder to fix than a total failure.
Inside one file, compute_replacements first collects each chunk into a replacement list. One miss returns an error immediately — write_file never runs. That file stays as-is.
codex-rs/apply-patch/src/file_update.rs lines 109–113
Across files it’s another story. apply_hunks_to_files writes hunks in order; on failure it returns with already-committed AppliedPatchDelta — the loop has no rollback. Test 015 nails this: it first succeeds at adding created.txt, then updates a path that does not exist, and created.txt is still on disk.
codex-rs/apply-patch/src/lib.rs line 453, and the hunk loop from line 504
The scope you compute-then-commit must match the unit you can handle atomically. One file can finish every replacement in memory, then write once. Multiple files already hit disk; rollback means writing again, plus half-success cases like Move where both source and target moved. Cross-file transactions are a product choice, not a promise of the format. In the spec you give the model, don’t sell the single-file guarantee as a global one.
DeepSeek Harness: read first, then edit
DSH’s editIntent checks whether this session has observed the file. No observation, and it throws FS_NOT_OBSERVED. What it records is a version joined from dev:ino:size:mtimeNs:ctimeNs, not a content hash.
Even past that gate, applyLiteralEdit by default requires old_string to appear once; multiple hits throw FS_AMBIGUOUS_EDIT. The safety sits on an event gate and literal uniqueness. Codex has no read-first constraint: location lives in the patch and is searched at runtime; if old_lines appears twice it takes the first, with no ambiguity error.
Claude Code: refuse if unread; refuse if it hits more than once
FileEditTool wants two things at once. The file must have been read; unread reports errorCode 6, verbatim “File has not been read yet.” If old_string appears more than once and replace_all is false, it reports errorCode 9 and asks for more context so this occurrence is singled out.
Fuzz covers quotes only. findActualString searches exact first, then folds curly quotes to straight ones. No Codex-style three trailing-whitespace grades, and no dash normalization. Want one fewer tool round trip? Copy Codex’s format. Want more specific error text for the model? Copy these refusals with errorCodes.
Two identical old_lines — which one gets edited?
The file has two identical old_lines blocks. The model only wants the second, but didn’t give enough @@ anchors. Which block does seek_sequence edit, and why?
Follow-up: if you want only the second block, which line should the anchor sit in front of? In the same patch, if you first succeed at adding a file, then update a path that doesn’t exist, what stays on disk?