apply_patch: fuzzy seek_sequence matching silently corrupts context line indentation
Summary
apply_patch silently corrupts the indentation of context lines (lines starting with in the patch) when the model emits them with slightly wrong leading whitespace. The patch succeeds and a diff is shown, but context lines that should be unchanged get their indentation overwritten with the model's version.
Root Cause
The bug is in seek_sequence (codex-rs/apply-patch/src/seek_sequence.rs), which has 4 matching passes of decreasing strictness:
- Exact match (line 35)
- rstrip match (line 44) —
lines[i].trim_end() == pat.trim_end() - Trim both sides (line 55) —
lines[i].trim() == pat.trim()← problematic - Unicode normalize + trim (line 77) —
normalise(lines[i]) == normalise(pat)
When the model emits context lines with different leading whitespace than the file (e.g. 3 spaces instead of 4), passes 1–2 fail, but pass 3 succeeds because trim() strips all leading whitespace.
The problem is that context lines are stored in both old_lines and new_lines (streaming_parser.rs lines 341–342):
chunk.old_lines.push(line_to_add.to_string());
chunk.new_lines.push(line_to_add.to_string());
When seek_sequence returns a fuzzy match position, compute_replacements (lib.rs line 788) schedules:
replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
Then apply_replacements (lib.rs line 806) removes the correctly-indented file lines and inserts new_lines, which contains the model's incorrectly-indented context lines. The original file's indentation is lost.
Reproduction
Minimal Rust reproduction:
// seek_sequence returns Some(0) even though the pattern has 3-space indent
// while the file has 4-space indent — because trim() strips the difference.
fn seek_sequence(lines: &[String], pattern: &[String], start: usize, eof: bool) -> Option<usize> {
if pattern.is_empty() { return Some(start); }
if pattern.len() > lines.len() { return None; }
let search_start = if eof && lines.len() >= pattern.len() {
lines.len() - pattern.len()
} else { start };
// Exact match
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
if lines[i..i + pattern.len()] == *pattern { return Some(i); }
}
// rstrip match
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if lines[i + p_idx].trim_end() != pat.trim_end() { ok = false; break; }
}
if ok { return Some(i); }
}
// trim both sides — THIS PASS CAUSES THE BUG
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if lines[i + p_idx].trim() != pat.trim() { ok = false; break; }
}
if ok { return Some(i); }
}
None
}
fn main() {
// File content (4-space indent)
let file_lines: Vec<String> = vec![
" if lower.startswith(\"kimi-\"):".into(),
" return f\"Kimi\"".into(),
" if lower.startswith(\"minimax-\"):".into(),
" return f\"MiniMax\"".into(),
];
// Context lines from patch (3-space indent — model mistake)
let old_lines: Vec<String> = vec![
" if lower.startswith(\"kimi-\"):".into(),
" return f\"Kimi\"".into(),
];
// Pass 1 (exact): fails (3 vs 4 spaces)
// Pass 2 (rstrip): fails (trailing whitespace not the issue)
// Pass 3 (trim both sides): SUCCEEDS — "if lower.startswith(...)" == "if lower.startswith(...)"
//
// Result: seek_sequence returns Some(0)
// apply_replacements removes lines 0..2 (correctly indented) and
// inserts new_lines containing the 3-space context lines.
// The kimi/minimax lines silently lose their 4-space indent.
let found = seek_sequence(&file_lines, &old_lines, 0, false);
println!("seek_sequence returned: {:?}", found);
// Output: seek_sequence returned: Some(0)
}
Concrete example
Original file:
if lower.startswith("kimi-"):
return f"Kimi {slug[5:].upper()}"
if lower.startswith("minimax-"):
return f"MiniMax {slug[8:].upper()}"
Model emits patch (context lines with 3-space indent):
*** Update File: example.py
@@
if lower.startswith("kimi-"):
return f"Kimi {slug[5:].upper()}"
+ if lower.startswith("qwen"):
+ return f"Qwen {slug[4:].upper()}"
if lower.startswith("minimax-"):
return f"MiniMax {slug[8:].upper()}"
*** End Patch
Result after apply_patch (kimi line lost its 4-space indent → 3-space):
if lower.startswith("kimi-"):
return f"Kimi {slug[5:].upper()}"
if lower.startswith("qwen"):
return f"Qwen {slug[4:].upper()}"
if lower.startswith("minimax-"):
return f"MiniMax {slug[8:].upper()}"
The diff does show the indentation change (the -/+ lines differ), but since apply_patch succeeds and shows a normal-looking diff, the corruption is easy to miss — especially in large patches where context lines are far from the actual changes.
Suggested Fix
When a fuzzy (trim) match succeeds in seek_sequence, apply_replacements should preserve the original file's leading whitespace for context lines rather than overwriting them with the model's version. One approach:
- In
compute_replacements, when the match was found via a fuzzy pass (not exact), detect whichold_linesare context lines (present in bothold_linesandnew_linesat the same position) and replace theirnew_linesentry with the actual file line at the matched position, preserving correct indentation.
Alternatively, seek_sequence could return a flag indicating which match pass succeeded, and apply_replacements could use the original file lines for context in the fuzzy case.
Environment
- Codex commit:
a0d5fd7(latest onmain) - File:
codex-rs/apply-patch/src/seek_sequence.rs - Key lines:
seek_sequence.rs#L53-L61(trim-both-sides pass),streaming_parser.rs#L341-L342(context stored in both old+new),lib.rs#L788(replacement scheduled with new_slice)
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗