Skip to content

Diff Generation

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 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.

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_orig

difflib.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:130
def 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 output

SEARCH/REPLACE and udiff formats do not use this streaming display — they show individual blocks inline, not a file-level diff.

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 diffs

Two 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.

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 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.

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.

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.

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()
}

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:5779
if 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 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.


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.

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.

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.

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.

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.

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.


Two separate subsystems:

  1. Streaming display — shown to the user as the LLM streams whole-file content
  2. 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.

[dependencies]
similar = "2"
sha1 = "0.10"
git2 = "0.19"
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.

Identical semantics to Codex:

  • on_patch_begin(): called before each tool use that modifies files; snapshots baseline on first touch only
  • get_unified_diff(): uses similar::TextDiff::from_lines() with context_radius(3), sorts by path for determinism

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()
}

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.

  • Use similar, not difflib: Rust’s similar crate is the standard and handles binary detection, Unicode normalization, and context radius correctly.
  • Never use git diff subprocess 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.