Skip to content

Patch Application

This page covers patch application — using git apply to apply unified diffs, handling three-way merge conflicts, classifying results, and supporting rollback via reverse patches. It also covers text-level patch application: how agents find matching context in files and replace it with new content, including fuzzy matching fallbacks when exact matching fails. This is distinct from edit formats, which documents how LLM output is parsed into edits, and streaming edits, which covers the streaming application of search/replace blocks. The scope here is: given a parsed edit (unified diff, custom patch, or search/replace block), how does an agent apply it to a file, and what happens when the context does not match?

Only Codex uses git apply. Aider and OpenCode apply edits at the text level in their respective languages, bypassing git entirely for the application step. Despite this difference, all three share a common challenge: matching context lines from the patch against the current file content, which may have drifted since the patch was generated.

Aider does not use git apply. All edits are applied through custom Python code in the coder classes. Git is only invoked for commits (repo.py:131-319) after edits are already written to disk. There is no three-way merge, no conflict detection beyond “context not found” errors, and no rollback mechanism beyond creating a git commit before each turn.

Aider has three separate text-level application engines, each designed for a different edit format.

udiff_coder.py (430 lines) handles the diff edit format where the LLM produces unified diffs inside fenced code blocks.

find_diffs() (udiff_coder.py:312-334) extracts hunks from the LLM’s response by scanning for ```diff fenced blocks:

def find_diffs(content):
lines = content.splitlines(keepends=True)
edits = []
line_num = 0
while line_num < len(lines):
line = lines[line_num]
if line.startswith("```diff"):
line_num, these_edits = process_fenced_block(lines, line_num + 1)
edits += these_edits
break
line_num += 1
return edits

process_fenced_block() (udiff_coder.py:337-400) parses the content between fences. It handles --- a/file and +++ b/file headers, stripping git prefixes (a/, b/), and splits hunks at @@ markers. A keeper flag tracks whether the current hunk contains any actual changes (lines starting with + or -). Context-only hunks (all lines start with space) are discarded.

The parser supports multiple hunks per fence and multiple file sections within a single fenced block. Each hunk is returned as a list of lines preserving the leading +, -, or character.

apply_edits() (udiff_coder.py:69-118) receives a list of (path, hunk) tuples and applies them sequentially:

  1. Deduplication: Hunks are stringified and checked against a seen set to skip duplicates (the LLM sometimes repeats itself).
  2. Normalization: normalize_hunk() cleans up whitespace inconsistencies.
  3. Application: For each unique hunk, the file is read from disk, and do_replace() attempts the replacement.
  4. Error accumulation: Failures do not stop processing — errors are collected and returned to the LLM as a batch.

do_replace() (udiff_coder.py:121-145) handles three distinct cases:

  • File creation: If the file does not exist and the “before” text (extracted from - lines) is empty, the file is created with the “after” text.
  • Append: If the “before” text is empty but the file exists, the “after” text is appended.
  • Replace: Otherwise, apply_hunk() is called to find and replace the matching section.

apply_hunk() — Progressive Context Dropping

Section titled “apply_hunk() — Progressive Context Dropping”

apply_hunk() (udiff_coder.py:151-198) is the core matching algorithm. It implements progressive context relaxation:

Level 0 — Direct match: directly_apply_hunk() attempts an exact match of the full hunk (context + changes) against the file content. If the hunk lines match a contiguous section of the file, the replacement succeeds immediately.

Level 1+ — Context dropping: If direct match fails, the hunk is decomposed into alternating sections of context and changes. The algorithm identifies context sections (lines starting with ) and change sections (lines starting with + or -). It then applies each change section independently via apply_partial_hunk(), using only the immediately surrounding context lines.

apply_partial_hunk() — Exhaustive Context Combinations

Section titled “apply_partial_hunk() — Exhaustive Context Combinations”

apply_partial_hunk() (udiff_coder.py:282-309) tries all combinations of preceding and following context lines:

def apply_partial_hunk(content, preceding_context, changes, following_context):
len_prec = len(preceding_context)
len_foll = len(following_context)
use_all = len_prec + len_foll
for drop in range(use_all + 1):
use = use_all - drop
for use_prec in range(len_prec, -1, -1):
if use_prec > use:
continue
use_foll = use - use_prec
if use_foll > len_foll:
continue
this_prec = preceding_context[-use_prec:] if use_prec else []
this_foll = following_context[:use_foll]
res = directly_apply_hunk(content, this_prec + changes + this_foll)
if res:
return res

For N preceding and M following context lines, this tries every combination from (N, M) down to (0, 0). The outer loop (drop) controls how much total context is removed; the inner loop distributes the remaining context between preceding and following. This allows matching even when the surrounding code has shifted significantly.

hunk_to_before_after() (udiff_coder.py:403-429) splits a hunk into “before” and “after” text by examining the first character of each line: - lines go to “before”, + lines go to “after”, context lines go to both. Lines shorter than 2 characters (bare newlines) are treated as context.

Two error templates guide the LLM on retry:

  • no_match_error (udiff_coder.py:16-26): Fires when context matching fails entirely. Instructs the LLM not to skip blank lines, comments, or docstrings.
  • not_unique_error (udiff_coder.py:29-39): Fires when the hunk matches multiple locations in the file. Instructs the LLM to add more context lines for disambiguation.

Search/Replace Application (editblock_coder.py)

Section titled “Search/Replace Application (editblock_coder.py)”

editblock_coder.py (658 lines) handles the SEARCH/REPLACE block format where the LLM produces <<<<<<< SEARCH / ======= / >>>>>>> REPLACE blocks.

Block Parsing: find_original_update_blocks()

Section titled “Block Parsing: find_original_update_blocks()”

find_original_update_blocks() (editblock_coder.py:439-535) is a line-by-line parser that extracts SEARCH/REPLACE blocks from the LLM output. The regex patterns allow 5-9 characters per marker:

HEAD = r"^<{5,9} SEARCH>?\s*$"
DIVIDER = r"^={5,9}\s*$"
UPDATED = r"^>{5,9} REPLACE\s*$"

The parser also handles shell command blocks (```bash, ```sh, ```shell) by yielding them as separate entries. It maintains a current_filename state so consecutive blocks for the same file do not need to repeat the filename. Filename inference looks at the 3 lines preceding the <<<<<<< SEARCH marker, checking for recognized file paths.

do_replace() (editblock_coder.py:364-383) handles file creation (empty “before” text + file does not exist), append mode (empty “before” text + file exists), and replacement via replace_most_similar_chunk().

replace_most_similar_chunk() — Four Strategies

Section titled “replace_most_similar_chunk() — Four Strategies”

replace_most_similar_chunk() (editblock_coder.py:157-183) implements a four-level fallback chain:

Strategy 1 — perfect_or_whitespace(): First tries perfect_replace() (editblock_coder.py:146-154) which does an exact line-by-line tuple comparison. If that fails, tries replace_part_with_missing_leading_whitespace() which detects and compensates for indent mismatches.

Strategy 2 — Drop leading blank line: If the search text starts with a blank line (common LLM artifact), the first line is dropped and Strategy 1 is retried.

Strategy 3 — try_dotdotdots() (editblock_coder.py:190-240): Handles ... line elisions where the LLM uses ellipsis to skip unchanged sections. The search and replace texts are split on ... lines, and each non-ellipsis segment is applied independently via string replacement. Raises ValueError on structural mismatches (unpaired ... in search vs replace) or ambiguous matches (segment appears multiple times in the file).

Strategy 4 — Fuzzy matching (disabled in current code): A similarity-based matcher that was commented out due to false positive matches.

replace_part_with_missing_leading_whitespace()

Section titled “replace_part_with_missing_leading_whitespace()”

This function (editblock_coder.py:243-273) handles the common case where the LLM’s SEARCH block has less indentation than the actual code. It computes the minimum leading whitespace across all non-empty lines in both the search and replace blocks, strips that amount, then searches for a match. When found, it detects the actual indentation in the file and re-adds it to the replacement text. This allows the LLM to produce un-indented or less-indented code without breaking the match.

perfect_replace() (editblock_coder.py:146-154) is the simplest matcher: convert search lines to a tuple, slide a window across the file lines, and check for equality. Returns the full reconstructed file content on match, None on failure.

Custom Patch Format Application (patch_coder.py)

Section titled “Custom Patch Format Application (patch_coder.py)”

patch_coder.py (707 lines) handles the *** Begin Patch / *** End Patch format (inspired by Codex’s text-level format, distinct from unified diffs).

class ActionType(str, Enum):
ADD = "Add"
DELETE = "Delete"
UPDATE = "Update"
@dataclass
class Chunk:
orig_index: int = -1 # Position within context block
del_lines: List[str] = field(default_factory=list)
ins_lines: List[str] = field(default_factory=list)
@dataclass
class PatchAction:
type: ActionType
path: str
new_content: Optional[str] = None # For ADD
chunks: List[Chunk] = field(default_factory=list) # For UPDATE
move_path: Optional[str] = None # For rename

find_context_core() — Three-Level Fuzz Matching

Section titled “find_context_core() — Three-Level Fuzz Matching”

find_context_core() (patch_coder.py:59-78) searches for a context block in the file at three fuzz levels:

Fuzz LevelComparisonWhat It Tolerates
0lines[i] == context[i]Nothing — exact match only
1lines[i].rstrip() == context[i].rstrip()Trailing whitespace differences
100lines[i].strip() == context[i].strip()Leading and trailing whitespace

The search is linear from a starting index. Each level does a full scan, so worst case is 3 * O(n * m) where n is file length and m is context length.

find_context() (patch_coder.py:81-93) wraps find_context_core() with EOF handling. When the eof flag is set (from *** End of File markers), the function first attempts to match at the very end of the file. If that fails, it falls back to a forward search from the start index but adds a 10,000-point fuzz penalty. This penalty ensures EOF-anchored matches at the file’s end are strongly preferred over matches elsewhere.

peek_next_section() — Chunk Parsing State Machine

Section titled “peek_next_section() — Chunk Parsing State Machine”

peek_next_section() (patch_coder.py:96-191) parses one section of an UPDATE block using a state machine with three modes: keep (context lines), add (insertion lines), and delete (deletion lines). Lines are classified by their first character: + for add, - for delete, for keep. When the mode transitions from add/delete back to keep, a Chunk is finalized with orig_index set to len(context_lines) - len(del_lines) — the position within the context block where the deleted lines begin.

The parser terminates at section markers (@@, *** End Patch, *** Update File:, etc.) and handles the *** End of File marker by setting an is_eof flag on the chunk.

_apply_update() — Chunk-by-Chunk Sequential Application

Section titled “_apply_update() — Chunk-by-Chunk Sequential Application”

_apply_update() (patch_coder.py:642-706) applies UPDATE chunks to a file:

  1. Sort chunks by orig_index to process in file order.
  2. For each chunk: Copy unmodified lines from the last position to the chunk start, verify that deleted lines match (using .strip() normalization), then insert the new lines.
  3. Overlap detection: If a chunk’s start index is before the current position, raise a DiffError for overlapping chunks.
  4. Append remaining: After all chunks, append any remaining original lines.
  5. Trailing newline: Ensure the result ends with \n.

The normalized comparison (.strip() on both sides) at line 679 makes the application tolerant of whitespace differences in deleted lines, matching the fuzz behavior of find_context_core().

ADD (patch_coder.py:566-580): Raises DiffError if the file already exists. Creates parent directories with mkdir(parents=True). Ensures a trailing newline.

DELETE (patch_coder.py:582-589): Warns but continues if the file does not exist (non-fatal). Uses path_obj.unlink().

UPDATE (patch_coder.py:591-627): Reads current content, applies chunks via _apply_update(), writes result to the target path. If move_path is set, writes to the new location first, then deletes the original — ensuring atomicity (the file always exists somewhere during the operation).

Aspectudiff_codereditblock_coderpatch_coder
FormatUnified diff (```diff)SEARCH/REPLACE blocks*** Begin/End Patch
MatchingContext dropping (all combos)Exact, whitespace, dotdotdotsFuzz levels (0, 1, 100)
Failure modeAccumulates errorsReturns NoneRaises DiffError
File creationEmpty before + touchEmpty before + touchExplicit ADD action
File deletionNot supportedNot supportedExplicit DELETE action
RenameNot supportedNot supportedmove_path field
Multi-hunkPer-file dedupSequential blocksSorted chunk list

For details on the LLM prompts that produce each format, see edit formats.

Codex has a full git apply integration in codex-rs/utils/git/src/apply.rs (847 lines including 250 lines of tests). This is the only reference implementation that uses git’s patch application machinery.

// apply.rs:16-23
pub struct ApplyGitRequest {
pub cwd: PathBuf, // Working directory (must be inside a git repo)
pub diff: String, // Unified diff text
pub revert: bool, // If true, apply in reverse (-R flag)
pub preflight: bool, // If true, dry-run only (--check flag)
}
// apply.rs:25-35
pub struct ApplyGitResult {
pub exit_code: i32,
pub applied_paths: Vec<String>,
pub skipped_paths: Vec<String>,
pub conflicted_paths: Vec<String>,
pub stdout: String,
pub stderr: String,
pub cmd_for_log: String,
}

The result classifies every affected file into one of three categories: applied (clean), skipped (failed to apply), or conflicted (applied with merge conflicts).

apply_git_patch() (apply.rs:37-124) is the entry point:

Step 1 — Resolve git root (apply.rs:42): Runs git rev-parse --show-toplevel to find the repository root. Fails early with an io::Error if not in a git repo.

Step 2 — Write temporary patch file (apply.rs:44-47): Writes the diff text to a temporary file (patch.diff) inside a tempfile::TempDir. The TempDir guard is kept alive via let _guard = tmpdir to ensure cleanup on function return (even on early errors).

Step 3 — Pre-revert staging (apply.rs:49-52): When applying a reverse patch (revert: true) and not in preflight mode, Codex stages the affected files first:

if req.revert && !req.preflight {
stage_paths(&git_root, &req.diff)?;
}

Step 4 — Build arguments (apply.rs:54-71):

git apply --3way [patch_file]

The --3way flag enables three-way merge (see below). If revert is true, -R is appended. An optional CODEX_APPLY_GIT_CFG environment variable allows injecting -c key=value git config overrides — the variable is split on commas, each pair is validated to contain =, and injected as separate -c arguments before the apply subcommand.

Step 5 — Preflight mode (apply.rs:76-101): When preflight is true, the command uses --check instead of --3way:

git apply --check [-R] [patch_file]

This validates whether the patch would apply cleanly without modifying the working tree or index. The output is still parsed into applied/skipped/conflicted categories, and the function returns early.

Step 6 — Apply (apply.rs:103-124): The actual git apply --3way command runs. Codex does not treat a non-zero exit code as a hard error — it parses stdout and stderr to classify the result. A patch that partially applies (some hunks clean, some conflicted) returns a non-zero exit code but still has entries in applied_paths. After parsing, each category is sorted and deduplicated.

The --3way flag (apply.rs:55) is the critical differentiator. When git encounters a hunk that does not apply cleanly:

  1. Git examines the patch header for blob references (the index abcdef..123456 line in unified diffs).
  2. If that blob exists in the repository’s object store, git uses it as the merge base.
  3. Git performs a three-way merge between: (a) the base blob from the patch, (b) the current file content, and (c) the patched version.
  4. If the changes do not overlap, the merge succeeds silently. If they do overlap, git writes conflict markers (<<<<<<<, =======, >>>>>>>) into the file and reports the path as conflicted.

Without --3way, git apply is strict: any hunk that does not match the current file exactly is rejected. With --3way, hunks can succeed even when surrounding context has changed, as long as the changes do not conflict with the patch’s intent.

When three-way merge fails entirely (the referenced blob is not in the object store), git falls back to direct application and reports “Failed to perform three-way merge” or “repository lacks the necessary blob.”

parse_git_apply_output() (apply.rs:346-589) is 243 lines of regex-based parsing that classifies git’s output. It uses once_cell::Lazy static regexes with case-insensitive matching (git’s output format varies across versions). The full set of patterns:

Applied patterns — files that were successfully patched:

RegexWhat It Matches
Applied patch(?: to)?\s+<path>\s+cleanlyClean application success

Conflicted patterns — files with merge conflicts or unresolvable issues:

RegexWhat It Matches
Applied patch(?: to)?\s+<path>\s+with conflictsThree-way merge produced conflict markers
Applying patch\s+<path>\s+with\s+\d+\s+rejects?Hunks rejected during application
U\s+<path>Unmerged status marker from git
Warning:\s*Cannot merge binary files:\s+<path>Binary file conflict

Skipped patterns — files that could not be patched at all:

RegexWhat It Matches
error:\s+patch failed:\s+<path>Hunk context mismatch
error:\s+<path>:\s+patch does not applyDirect application failure
error:\s+<path>:\s+does not match indexIndex/worktree mismatch
error:\s+<path>:\s+does not exist in indexFile not tracked
error:\s+<path>\s+already exists in working directoryFile already exists (creation conflict)
error:\s+path\s+<path>\s+has been renamed/deletedRename/delete conflict
error:\s+cannot apply binary patch to\s+<path>Binary patch without full index
error:\s+binary patch does not apply to\s+<path>Binary patch content mismatch
error:\s+binary patch to\s+<path>\s+creates incorrect resultBinary patch verification failure
error:\s+cannot read the current contents of\s+<path>I/O error reading file
Failed to perform three-way mergeThree-way merge fallback failure
repository lacks the necessary blobMissing blob for three-way merge
Skipped patch\s+<path>Explicit skip message

Informational patterns (not classified, but tracked for context):

RegexPurpose
Checking patch\s+<path>\.\.\.Sets last_seen_path for subsequent error attribution
Performing three-way merge\.\.\.Three-way merge in progress
Falling back to three-way merge\.\.\.Direct apply failed, trying merge
Falling back to direct application\.\.\.Three-way merge failed, trying direct

The parser maintains a last_seen_path state variable that tracks which file is currently being processed (set by Checking patch lines). When an error line does not contain an explicit path (like Failed to perform three-way merge), the error is attributed to last_seen_path.

Precedence rules (apply.rs:575-582): After all lines are processed, sets are reconciled:

  1. Conflicted paths are removed from both applied and skipped sets.
  2. Applied paths are removed from the skipped set.
  3. This ensures each file appears in exactly one category.

extract_paths_from_patch() (apply.rs:194-212) parses diff --git a/... b/... headers to find affected file paths. It delegates to parse_diff_git_paths() (apply.rs:214-219) which reads two tokens from the header line.

read_diff_git_token() (apply.rs:221-255) handles three path formats:

  • Unquoted paths: Read until whitespace.
  • Quoted paths: Read between matching quotes (" or '), preserving backslash escapes during parsing.
  • C-style escape sequences: Processed by unescape_c_string() (apply.rs:272-317) which handles \n, \r, \t, \b, \f, \a, \v, \\, \", \', and octal escapes (\0 through \777).

normalize_diff_path() (apply.rs:257-270) strips a/ and b/ prefixes and filters out /dev/null (used for new and deleted files). Paths are collected into a BTreeSet for deduplication and sorted output.

stage_paths() (apply.rs:320-342) handles the pre-staging requirement for reverse patches:

pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
let paths = extract_paths_from_patch(diff);
let mut existing: Vec<String> = Vec::new();
for p in paths {
let joined = git_root.join(&p);
if std::fs::symlink_metadata(&joined).is_ok() {
existing.push(p);
}
}
if existing.is_empty() {
return Ok(());
}
let mut cmd = std::process::Command::new("git");
cmd.arg("add").arg("--");
for p in &existing {
cmd.arg(OsStr::new(p));
}
let _out = cmd.current_dir(git_root).output()?;
Ok(()) // Best-effort: ignores git errors
}

Key details: uses symlink_metadata() instead of exists() for robustness with symlinks. Ignores git add failures (best-effort) because the staging is a precondition optimization, not a correctness requirement — if staging fails, the subsequent git apply -R will produce a more descriptive error.

The apply pipeline integrates with the agent through two layers:

Handler (core/src/tools/handlers/apply_patch.rs)

Section titled “Handler (core/src/tools/handlers/apply_patch.rs)”

The handler (401 lines) parses the LLM’s tool call arguments, verifies the patch via maybe_parse_apply_patch_verified(), and routes to one of two paths:

  • Direct output: For patches that can be applied immediately (returns the result string directly).
  • Delegate to exec sandbox: For patches requiring sandbox execution (most cases). The handler calculates affected file paths (including move destinations), emits ToolEmitter::apply_patch begin/finish events, and delegates to the ApplyPatchRuntime.

File path calculation (apply_patch.rs:39-57) includes both source paths and move destinations from ApplyPatchFileChange::Update { move_path, .. } entries, ensuring the approval system tracks all affected locations.

Runtime (core/src/tools/runtimes/apply_patch.rs)

Section titled “Runtime (core/src/tools/runtimes/apply_patch.rs)”

The runtime (162 lines) builds a sandbox-aware CommandSpec:

CommandSpec {
program: exe_path,
args: vec![CODEX_APPLY_PATCH_ARG1.to_string(), patch_text],
cwd: action_cwd,
env: HashMap::new(), // Empty env for determinism and security
sandbox_permissions: SandboxPermissions::UseDefault,
...
}

The empty environment (HashMap::new()) at line 64 is intentional: it prevents environment variable leaks and ensures deterministic behavior regardless of the user’s shell configuration. The approval flow uses with_cached_approval() for permission caching, and retries are handled by re-requesting approval with a retry reason.

apply.rs:596-847 contains comprehensive tests:

  • extract_paths_handles_quoted_headers (line 637): Verifies parsing of "a/hello world.txt" quoted paths.
  • extract_paths_ignores_dev_null_header (line 644): Confirms /dev/null is filtered out.
  • extract_paths_unescapes_c_style_in_quoted_headers (line 651): Tests \t escape sequence handling.
  • parse_output_unescapes_quoted_paths (line 658): Verifies error message path unescaping.
  • apply_add_success (line 667): Adding a new file via patch.
  • apply_modify_conflict (line 686): Conflicting modifications produce conflict markers.
  • apply_modify_skipped_missing_index (line 709): Patch to a file not in the git index is skipped.
  • apply_then_revert_success (line 726): Forward patch followed by reverse application.
  • revert_preflight_does_not_stage_index (line 762): Preflight with revert does not modify the index.
  • preflight_blocks_partial_changes (line 807): Mixed patch where one hunk is valid and one is not.

OpenCode does not use git apply. Its apply_patch tool (tool/apply_patch.ts, 282 lines) applies edits through a five-phase TypeScript pipeline with LSP integration.

The patch text is parsed via Patch.parsePatch() from patch/index.ts:

let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}

Empty patches are explicitly rejected. The parser (patch/index.ts:190-246) scans for *** Begin Patch and *** End Patch markers, then dispatches on file headers:

  • *** Add File: <path> — parsed as an add hunk with raw content.
  • *** Delete File: <path> — parsed as a delete hunk.
  • *** Update File: <path> — parsed with optional *** Move to: directive, followed by update chunks.

The Hunk type definition:

export type Hunk =
| { type: "add"; path: string; contents: string }
| { type: "delete"; path: string }
| { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] }
export interface UpdateFileChunk {
old_lines: string[]
new_lines: string[]
change_context?: string
is_end_of_file?: boolean
}

Update chunks are parsed by parseUpdateFileChunks() (patch/index.ts:108-160) which processes @@ section markers, classifying lines by prefix: (keep), + (add), - (remove). The *** End of File marker sets the is_end_of_file flag for EOF-anchored matching.

For each hunk, the new file contents are computed:

  • Add: New content is the hunk’s contents field, with a trailing newline ensured.
  • Update: The current file is read, then Patch.deriveNewContentsFromChunks() applies the chunks.
  • Delete: The current file content is read (for the display diff) but the new content is empty.

For updates, deriveNewContentsFromChunks() delegates to computeReplacements() (patch/index.ts:346-400) which finds each chunk’s location in the file:

  1. Context seeking: If the chunk has a change_context string (from the @@ line), seekSequence() searches for that line first to position the search window.
  2. Pattern matching: The chunk’s old_lines are matched against the file using seekSequence().
  3. Retry without trailing empty line: If matching fails and the last old_line is empty, the match is retried without it.
  4. Result collection: Each replacement is recorded as [startIndex, oldLength, newLines].
  5. Sort by index: Replacements are sorted to ensure sequential application.

seekSequence() (patch/index.ts:464-488) implements four matching passes:

PassComparisonTolerance
1a === bExact match only
2a.trimEnd() === b.trimEnd()Trailing whitespace
3a.trim() === b.trim()Leading and trailing whitespace
4normalizeUnicode(a.trim()) === normalizeUnicode(b.trim())Unicode punctuation normalization

The fourth pass handles LLM-generated Unicode artifacts (curly quotes, em dashes) by normalizing to ASCII equivalents before comparison.

tryMatch() (patch/index.ts:433-462) handles EOF anchoring: when eof=true, it first attempts to match at the end of the file before falling back to forward search. This matches Aider’s find_context() EOF behavior.

applyReplacements() (patch/index.ts:402-419) applies replacements in reverse order (highest index first) to avoid index shifting. Each replacement splices out old lines and inserts new lines at the same position.

const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath))
await ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: { filepath: relativePaths.join(", "), diff: totalDiff, files },
})

The permission request includes the computed diff for display in the approval UI. The always: ["*"] parameter allows the user to approve all future edits in one action.

Files are written based on their change type:

  • Add: fs.mkdir() (recursive) + fs.writeFile().
  • Update: fs.writeFile() directly.
  • Move: Write to destination, then fs.unlink() source (destination first for atomicity).
  • Delete: fs.unlink().

After each write, Bus.publish(File.Event.Edited, ...) notifies subscribers. After all writes, file watcher events are published for each affected file.

After writing, OpenCode touches each modified file via LSP.touchFile() to trigger language server re-analysis, then collects diagnostics:

const diagnostics = await LSP.diagnostics()
for (const change of fileChanges) {
const issues = diagnostics[normalized] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
output += `\nLSP errors detected in ${target}, please fix:\n`
output += `<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}\n</diagnostics>`
}
}

Error diagnostics (severity 1 only) are included in the tool output, limited to MAX_DIAGNOSTICS_PER_FILE = 20 per file. This gives the LLM immediate feedback about type errors, import failures, or syntax problems introduced by the patch.

Three-Way Merge Requires Blob Availability

Section titled “Three-Way Merge Requires Blob Availability”

The --3way flag only works when the base blob referenced in the patch header exists in the local object store. Patches generated from commits that have been force-pushed or garbage-collected will fail three-way merge with “repository lacks the necessary blob.” The fallback is direct application, which is stricter and more likely to fail. Shallow clones (--depth=1) are particularly vulnerable to this.

Output Parsing Is Fragile Across Git Versions

Section titled “Output Parsing Is Fragile Across Git Versions”

Git’s apply output messages are not part of a stable API. Codex’s 20+ regex patterns (apply.rs:346-589) are a defensive measure against format variations across git versions. The case-insensitive matching helps, but new git releases can introduce messages that fall through all patterns. Any implementation should test against multiple git versions.

The requirement to git add files before git apply -R --3way is not documented in git’s man page. It is a consequence of how git’s index works: the -R flag reverses the patch expectations, and if the index does not match the working tree (because the files were modified but not staged), the reversed hunks do not match what git expects. Codex discovered this through testing (apply.rs:49-52).

A patch can partially succeed: some files apply cleanly while others conflict or are skipped. Codex handles this by returning all three categories in ApplyGitResult. An agent that treats any non-zero exit code as complete failure will miss the files that did apply correctly and may attempt to re-apply the entire patch unnecessarily.

Git cannot apply binary patches without the full blob data in the patch. The error "cannot apply binary patch without full index" appears when the patch was generated with --binary but the repository lacks the referenced objects. For AI agents, binary file edits should go through direct file writes rather than git apply.

Aider’s fuzzy matching (context dropping, rstrip, strip, indent detection) and OpenCode’s four-pass matching (exact, trimEnd, trim, unicode-normalized) recover from whitespace differences and minor reformatting that would cause git apply to fail. The trade-off: text-level application has no awareness of parallel changes (no merge), while git apply --3way can auto-resolve non-overlapping edits.

Aider’s apply_partial_hunk() tries all combinations of preceding and following context lines. For a hunk with P preceding and F following context lines, this is O((P+F)^2) attempts, each requiring an O(n) scan of the file. On large files with large hunks, this can be slow. The progressive nature (try most context first, drop gradually) means the common case (exact match or near-match) is fast, but pathological cases exist.

LLMs frequently substitute ASCII characters with Unicode equivalents: straight quotes become curly quotes, hyphens become em dashes, ... becomes .... OpenCode’s fourth matching pass (normalizeUnicode()) handles this. Aider does not have this pass, relying instead on its aggressive context dropping to match despite character differences.

Both Aider’s patch_coder.py and OpenCode’s apply_patch.ts write to the move destination before deleting the source. This ordering is critical: if the write fails or the process is interrupted, the original file still exists. Reversing the order (delete first, then write) risks data loss on failure.

OpenOxide should support both git-level and text-level patch application. The choice depends on the patch format:

  • Unified diff (from LLM or external tools): Route through git apply --3way following Codex’s apply_git_patch() pattern.
  • Codex Patch Format / Search-Replace: Route through a Rust text-level applier (see edit formats blueprint).

Both modes should implement the same PatchApplier trait so the agent loop does not need to know which strategy is in use.

pub struct ApplyRequest {
pub repo_root: PathBuf,
pub diff: String,
pub revert: bool,
pub preflight: bool,
}
pub struct ApplyResult {
pub applied: Vec<PathBuf>,
pub skipped: Vec<PathBuf>,
pub conflicted: Vec<PathBuf>,
pub exit_code: i32,
pub stderr: String,
}
pub async fn apply_git_patch(req: &ApplyRequest) -> Result<ApplyResult, PatchError> {
// 1. resolve git root
// 2. write temp patch file (tempfile crate)
// 3. stage if revert (git add --)
// 4. git apply --3way [-R] [patch_file]
// OR git apply --check [-R] [patch_file] for preflight
// 5. parse output into applied/skipped/conflicted
}
pub struct TextApplyRequest {
pub file_path: PathBuf,
pub old_lines: Vec<String>,
pub new_lines: Vec<String>,
pub eof_anchor: bool,
}
pub enum MatchResult {
Exact(usize), // Fuzz level 0
TrailingWhitespace(usize), // Fuzz level 1
FullStrip(usize), // Fuzz level 2
UnicodeNormalized(usize), // Fuzz level 3
NotFound,
}
pub fn seek_sequence(
lines: &[String],
pattern: &[String],
start: usize,
eof: bool,
) -> MatchResult {
// Four-pass matching following OpenCode's seekSequence()
}

Port Codex’s regex-based parser. Use the regex crate with RegexSet for efficient multi-pattern matching against combined stdout+stderr. Maintain the precedence rules: conflicted > applied > skipped. Include the last_seen_path state machine for attributing context-free error messages.

Before any apply_git_patch() call, create a ghost commit via the snapshot system (see ghost commits). The snapshot SHA is stored alongside the ApplyResult so that undo can restore the pre-patch state via apply_git_patch() with revert: true or by restoring the ghost commit directly.

Use preflight mode (--check) to show the user which files will be affected before requesting edit permission. This avoids the pattern of asking permission, applying, and then discovering the patch fails. The preflight result can also provide an early warning about files that will conflict or be skipped.

CratePurpose
openoxide-patchgit apply wrapper, output parsing, revert
openoxide-text-applyText-level context matching and replacement
tempfileTemporary patch files with automatic cleanup
regexOutput classification (20+ patterns)
openoxide-snapshotGhost commit creation before patch application
  1. Always use --3way. Direct git apply without three-way merge fails on any context drift. The three-way mode is strictly more capable and the only overhead is blob lookup.
  2. Stage before revert unconditionally. The staging step has no side effects if files are already staged, and it prevents the index mismatch error that is difficult to diagnose.
  3. Return structured results, not just success/failure. Callers need to know which files applied, which conflicted, and which were skipped to provide useful feedback to the LLM.
  4. Support both git apply and text-level application behind a common trait so the agent loop does not need to know which strategy is in use.
  5. Implement four-pass text matching (exact, trimEnd, trim, unicode-normalized) following OpenCode’s pattern. This covers the most common LLM output artifacts.
  6. Apply replacements in reverse index order to avoid cascading index shifts. This is proven in both Aider’s _apply_update() (sorted + sequential) and OpenCode’s applyReplacements() (reverse order).
  7. Include LSP diagnostic feedback in the tool output so the LLM learns about type errors immediately rather than discovering them on the next compilation.