Diff Generation
Feature Definition
Section titled “Feature Definition”Diff generation in an AI coding agent serves three distinct purposes. The first is streaming display: showing the user a live diff of what is being written to a file as the LLM streams tokens. The second is turn summary: computing the aggregate “what changed this turn” diff at the end of a turn for display or logging. The third is commit context: feeding git diffs into the commit message generation prompt so the LLM can write a meaningful commit message.
These three uses pull in different directions. Streaming display needs to work on partial content without waiting for the LLM to finish a file. Turn summary needs to be accurate even across renames, mode changes, and multi-file edits. Commit context needs to handle the edge case of a brand new repo with no commits yet.
The scope of this page is diff generation only — producing and displaying diffs. Applying diffs as edit instructions is covered in editing/edit-formats.md and editing/streaming-edits.md.
Aider Implementation
Section titled “Aider Implementation”Aider reference: commit b9050e1d5faf8096eae7a46a9ecc05a86231384b
Aider has two separate diff mechanisms: a streaming display diff that runs during whole-file writes, and a git diff that runs after commits for commit message generation and the /diff command.
Streaming Display: diff_partial_update()
Section titled “Streaming Display: diff_partial_update()”File: aider/diffs.py:43
The diff_partial_update() function solves a specific problem: when a whole-file coder is streaming a new version of a file, the LLM has only emitted part of the new content. If you naively diffed the original file against the partial new content, all lines past the end of the partial would appear as deletions — noise that makes the live diff unusable.
The fix is find_last_non_deleted():
def find_last_non_deleted(lines_orig, lines_updated): diff = list(difflib.ndiff(lines_orig, lines_updated)) num_orig = 0 last_non_deleted_orig = None for line in diff: code = line[0] if code == " ": num_orig += 1 last_non_deleted_orig = num_orig elif code == "-": num_orig += 1 elif code == "+": pass return last_non_deleted_origdifflib.ndiff() returns a sequence of opcodes (" " for equal, "-" for only in original, "+" for only in updated). The function walks the opcodes and tracks the last original line that is not purely deleted — meaning the last original line that either survived into the updated content or has new lines after it. This gives a cursor position: how far into the original file the streaming update has reached.
diff_partial_update() then truncates the original to last_non_deleted lines before computing the final unified diff with difflib.unified_diff():
def diff_partial_update(lines_orig, lines_updated, final=False, fname=None): if final: last_non_deleted = num_orig_lines else: last_non_deleted = find_last_non_deleted(lines_orig, lines_updated)
lines_orig = lines_orig[:last_non_deleted] if not final: lines_updated = lines_updated[:-1] + [bar] # Replace last line with progress bar
diff = difflib.unified_diff(lines_orig, lines_updated, n=5) diff = list(diff)[2:] # Strip the --- +++ header lines diff = "".join(diff)When final=False, the last line of the updated content is replaced with a progress bar: " 42 / 110 lines [████████████░░░░░░░░░░░░░░░░░░] 38%\n". This gives the user a visual indicator of how far the LLM has progressed.
The function picks the minimum number of backticks needed to avoid conflicts with the diff content, then wraps the output in a fenced code block with the diff language tag.
Call sites: wholefile_coder.py:do_live_diff() calls diff_partial_update() per streaming chunk for whole-file edits. wholefile_func_coder.py and single_wholefile_func_coder.py have identical call sites.
# aider/coders/wholefile_coder.py:130def do_live_diff(self, full_path, new_lines, final): if Path(full_path).exists(): orig_lines = self.io.read_text(full_path) if orig_lines is not None: orig_lines = orig_lines.splitlines(keepends=True) show_diff = diffs.diff_partial_update( orig_lines, new_lines, final=final, ).splitlines() return show_diff output = ["```"] + new_lines + ["```"] return outputSEARCH/REPLACE and udiff formats do not use this streaming display — they show individual blocks inline, not a file-level diff.
Git Diff: get_diffs() and diff_commits()
Section titled “Git Diff: get_diffs() and diff_commits()”File: aider/repo.py:375
After a turn, Aider commits the modified files. Before committing it calls get_diffs() to get the current diff, which is then passed to the commit message prompt:
def get_diffs(self, fnames=None): current_branch_has_commits = False try: active_branch = self.repo.active_branch commits = self.repo.iter_commits(active_branch) current_branch_has_commits = any(commits) except ...
if current_branch_has_commits: args = ["HEAD", "--"] + list(fnames) diffs += self.repo.git.diff(*args, ...) return diffs
# New repo with no commits yet index_args = ["--cached", "--"] + list(fnames) diffs += self.repo.git.diff(*index_args, ...) # staged wd_args = ["--"] + list(fnames) diffs += self.repo.git.diff(*wd_args, ...) # unstaged return diffsTwo code paths handle the edge case of a brand-new repository with no HEAD commit: on a fresh repo, git diff HEAD fails, so Aider falls back to diffing staged and unstaged changes separately.
The diff is passed into get_commit_message() prefixed with "# Diffs:\n" and used as context for the commit message LLM prompt.
diff_commits() (repo.py:419) is simpler — just git diff <from_commit> <to_commit> with optional --color for human display. It is used by the /diff command.
Post-Commit Display
Section titled “Post-Commit Display”show_diffs is a boolean init parameter (base_coder.py:307) stored as self.show_diffs. After every auto-commit, show_auto_commit_outcome() (base_coder.py:2397) calls self.commands.cmd_diff() if the flag is set. This runs git diff HEAD~1 HEAD and prints it to the terminal.
Codex Implementation
Section titled “Codex Implementation”Codex reference: commit 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476
Codex has a dedicated TurnDiffTracker struct that generates a per-turn unified diff entirely in-memory, without running git diff. The diff is emitted as a TurnDiffEvent at the end of each agent turn.
TurnDiffTracker Struct
Section titled “TurnDiffTracker Struct”File: codex-rs/core/src/turn_diff_tracker.rs
pub struct TurnDiffTracker { /// Map external path -> internal UUID filename. external_to_temp_name: HashMap<PathBuf, String>, /// Internal UUID -> baseline file info (content at first touch). baseline_file_info: HashMap<String, BaselineFileInfo>, /// Internal UUID -> current external path (tracks renames). temp_name_to_current_path: HashMap<String, PathBuf>, /// Cache of known git worktree roots. git_root_cache: Vec<PathBuf>,}
struct BaselineFileInfo { path: PathBuf, content: Vec<u8>, mode: FileMode, oid: String, // Git blob SHA-1}The UUID indirection (external_to_temp_name and temp_name_to_current_path) is the rename tracking mechanism. When a file is moved from src.txt to dst.txt, the UUID stays stable while both external path maps are updated. This means get_unified_diff() can emit a single diff --git a/src.txt b/dst.txt entry rather than a spurious add+delete pair.
Baseline Snapshotting: on_patch_begin()
Section titled “Baseline Snapshotting: on_patch_begin()”Before every apply_patch call, on_patch_begin() is called with the set of files about to be modified:
pub fn on_patch_begin(&mut self, changes: &HashMap<PathBuf, FileChange>) { for (path, change) in changes.iter() { if !self.external_to_temp_name.contains_key(path) { let internal = Uuid::new_v4().to_string(); // ... register UUID ...
// Snapshot the file's content now, before the patch is applied let baseline_file_info = if path.exists() { let mode = file_mode_for_path(path); let content = blob_bytes(path, mode).unwrap_or_default(); let oid = self.git_blob_oid_for_path(path).unwrap_or_else(|| ...); Some(BaselineFileInfo { path, content, mode, oid }) } else { // File doesn't exist yet — will be a new file addition (/dev/null baseline) Some(BaselineFileInfo { path, content: vec![], mode: Regular, oid: ZERO_OID }) }; } // Track renames from Update { move_path: Some(dest) } }}The critical design: the baseline is only captured once per external path, on first touch. Subsequent patches to the same file do not overwrite the baseline. This means get_unified_diff() always shows the aggregate diff from the state of the file before the agent’s first edit, not just the last edit.
Diff Generation: get_unified_diff()
Section titled “Diff Generation: get_unified_diff()”pub fn get_unified_diff(&mut self) -> Result<Option<String>> { let mut aggregated = String::new();
// Sort by repo-relative path for deterministic output let mut baseline_file_names = self.baseline_file_info.keys().cloned().collect::<Vec<_>>(); baseline_file_names.sort_by_key(|internal| { self.get_path_for_internal(internal) .map(|p| self.relative_to_git_root_str(&p)) .unwrap_or_default() });
for internal in baseline_file_names { aggregated.push_str(self.get_file_diff(&internal).as_str()); }
Ok(if aggregated.trim().is_empty() { None } else { Some(aggregated) })}For each tracked file, get_file_diff() reads the current on-disk content and diffs it against the baseline snapshot using the similar crate:
let diff = similar::TextDiff::from_lines(l, r);let unified = diff .unified_diff() .context_radius(3) .header(&old_header, &new_header) .to_string();The header is formatted as diff --git a/<path> b/<path> to match git’s unified diff format. For binary files (non-UTF-8 content), it emits Binary files differ instead.
Git blob OIDs are computed for the index <left_oid>..<right_oid> line. For files in a git repo, git hash-object is run via subprocess. For files outside a repo, or when git is unavailable, the SHA-1 is computed in-memory:
fn git_blob_sha1_hex_bytes(data: &[u8]) -> Output<sha1::Sha1> { // Git blob hash is sha1 of: "blob <len>\0<data>" let header = format!("blob {}\0", data.len()); let mut hasher = sha1::Sha1::new(); hasher.update(header.as_bytes()); hasher.update(data); hasher.finalize()}Turn Lifecycle
Section titled “Turn Lifecycle”File: codex-rs/core/src/codex.rs:4466
TurnDiffTracker is created fresh at the start of each agent turn, wrapped in Arc<tokio::sync::Mutex<TurnDiffTracker>> for async access:
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));At the end of the turn, after all tool calls are drained:
// codex.rs:5779if should_emit_turn_diff { let unified_diff = { let mut tracker = turn_diff_tracker.lock().await; tracker.get_unified_diff() }; if let Ok(Some(unified_diff)) = unified_diff { let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); sess.send_event(&turn_context, msg).await; }}The TUI receives TurnDiffEvent and renders the diff in a collapsed panel after the turn completes.
OpenCode Implementation
Section titled “OpenCode Implementation”OpenCode reference: commit 7ed449974864361bad2c1f1405769fd2c2fcdf42
OpenCode has no custom diff logic. All diff-related operations go through the thin util/git.ts wrapper:
export async function git( args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult>This spawns git as a subprocess and returns its stdout. For regular mode it uses Bun’s $ shell interpolation. For ACP client mode (where the process is launched with an inherited stdin pipe carrying protocol data), it falls back to Bun.spawn with stdin: "ignore" and concurrent buffer reads to prevent deadlock:
const [exitCode, stdout, stderr] = await Promise.all([ proc.exited, new Response(proc.stdout).arrayBuffer(), new Response(proc.stderr).arrayBuffer(),])OpenCode uses git diffs primarily for two purposes: computing the diff to include in the ghost commit snapshot (see git/ghost-commits.md), and displaying the current diff in the TUI sidebar. All diff parsing is left to the client — OpenCode returns raw unified diff text from git.
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”The Streaming Display Problem Is Not Obvious
Section titled “The Streaming Display Problem Is Not Obvious”The find_last_non_deleted() approach is subtle. It only works because difflib.ndiff() computes a character-level alignment, not a line-level one, which means it can find the “cursor” position even when the partial content is structurally different from the original. A naive approach of truncating the diff at the last "+" line fails badly with multi-block edits.
In-Memory Diffing vs. Git Diffing
Section titled “In-Memory Diffing vs. Git Diffing”Codex’s TurnDiffTracker deliberately does not use git diff to generate the turn summary. This is correct: at the time get_unified_diff() is called, the files have been modified but not committed. Running git diff HEAD would work, but only for tracked files in a git repo. The in-memory baseline approach works even for new files, deleted files, and files outside a git repo.
OID Accuracy Requires git hash-object
Section titled “OID Accuracy Requires git hash-object”Computing blob OIDs in-memory (as the SHA-1 of "blob <len>\0<content>") is accurate. Codex tries git hash-object first and falls back to the in-memory computation, ensuring the index line in the diff header is always present and correct even without git.
Binary File Handling
Section titled “Binary File Handling”Unified diff tools emit Binary files differ for non-UTF-8 content. Codex handles this explicitly by checking std::str::from_utf8() on both sides and falling back to the binary notice. A common mistake is to skip this check and let the UTF-8 conversion panic or produce garbled output.
New Repo Edge Case (Aider)
Section titled “New Repo Edge Case (Aider)”get_diffs() in Aider has special handling for repos with no commits: git diff HEAD would fail with exit code 128. The fallback to git diff --cached + git diff (working directory) is non-obvious but necessary. Missing this causes the agent to silently skip commit message generation on brand-new repos.
Sorting for Determinism
Section titled “Sorting for Determinism”Codex sorts the file list by repo-relative path before generating diffs. This makes turn summaries deterministic across runs, which matters for tests and for human readability. Without sorting, the file order is HashMap iteration order — random.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Scope Split
Section titled “Scope Split”Two separate subsystems:
- Streaming display — shown to the user as the LLM streams whole-file content
- Turn diff summary — emitted at turn end, includes all files touched during the turn
These should not share implementation. The streaming display is a rendering concern; the turn summary is a tracking concern.
Crate: openoxide-diff
Section titled “Crate: openoxide-diff”[dependencies]similar = "2"sha1 = "0.10"git2 = "0.19"TurnDiffTracker (Codex-Compatible Design)
Section titled “TurnDiffTracker (Codex-Compatible Design)”pub struct TurnDiffTracker { /// External path → stable UUID. path_to_uuid: HashMap<PathBuf, Uuid>, /// UUID → baseline snapshot (taken before first modification). baselines: HashMap<Uuid, Baseline>, /// UUID → current external path (updated on rename). uuid_to_current_path: HashMap<Uuid, PathBuf>, /// git2 Repository handle for accurate blob OIDs. repo: Option<git2::Repository>,}
pub struct Baseline { pub original_path: PathBuf, pub content: Vec<u8>, pub mode: FileMode, pub oid: String,}Using git2 instead of subprocess for blob OIDs:
fn git2_blob_oid(repo: &git2::Repository, path: &Path) -> Option<String> { let content = std::fs::read(path).ok()?; let oid = repo.blob(&content).ok()?; Some(oid.to_string())}This avoids spawning a git hash-object subprocess per file.
on_patch_begin() and get_unified_diff()
Section titled “on_patch_begin() and get_unified_diff()”Identical semantics to Codex:
on_patch_begin(): called before each tool use that modifies files; snapshots baseline on first touch onlyget_unified_diff(): usessimilar::TextDiff::from_lines()withcontext_radius(3), sorts by path for determinism
Streaming Display: diff_partial_update()
Section titled “Streaming Display: diff_partial_update()”Port of Aider’s approach to Rust using similar::capture_diff_slices() in ndiff mode to find the cursor position:
pub fn diff_partial_update( orig_lines: &[&str], updated_lines: &[&str], final_update: bool,) -> String { let cursor = if final_update { orig_lines.len() } else { find_last_non_deleted(orig_lines, updated_lines) }; let truncated_orig = &orig_lines[..cursor]; let diff = similar::TextDiff::from_lines( truncated_orig.join(""), updated_lines.join(""), ); diff.unified_diff().context_radius(5).to_string()}Event Emission
Section titled “Event Emission”At the end of each turn, if any files were modified:
pub enum AgentEvent { // ... other events ... TurnDiff { unified_diff: String },}The TUI receives TurnDiff and renders a collapsible diff panel beneath the turn’s tool call log.
Key Design Decisions
Section titled “Key Design Decisions”- Use
similar, notdifflib: Rust’ssimilarcrate is the standard and handles binary detection, Unicode normalization, and context radius correctly. - Never use
git diffsubprocess for turn summary: In-memory diffing works everywhere. Git subprocess diffing requires a committed HEAD and a repo. - Baseline snapshotted once: Aggregate diff across multiple patches to the same file is more useful than just the last patch.
- Separate streaming display from turn summary: The streaming display is throwaway; the turn summary is persisted in the session record.