crates/gunnar-ui-server/src/git/diff.rs
//! Comparing two revisions: **gunnar's tree diff, the console's hunks.**
//!
//! Which paths changed, how (added, deleted, modified, type changed), both
//! object ids and the line counts are gunnar's answer to `Refs.Diff`, asked as
//! the reader (thin layer, owner 2026-10-04; gunnar computes them exactly,
//! `gunnar-server/src/diff.rs`). Until then this module walked both trees
//! itself, one `ReadObject` per tree, because it believed gunnar answered
//! `Refs.Diff` with `Unimplemented` — stale since gunnar 1.4.0. What stays here
//! is presentation: the line-level hunks a page draws, filled for the first
//! [`MAX_HUNKED_FILES`] files from the two blobs.
use serde::Serialize;
use super::{looks_binary, Repository, MAX_BLOB_BYTES};
use crate::api::ApiError;
/// How many lines of unchanged context to show around a change.
const CONTEXT: usize = 3;
/// Two changes closer than this are shown as one hunk.
///
/// Twice the context plus one: any less and the two hunks would print
/// overlapping context lines.
const MERGE_GAP: usize = CONTEXT * 2 + 1;
/// The most files to compute line-level hunks for in one response.
///
/// The list of changed files is always complete; only the hunks stop, and the
/// response says so.
const MAX_HUNKED_FILES: usize = 60;
/// How many hunk fills run at once. Wide enough to hide the per-call
/// round-trip, narrow enough not to monopolise gunnar's bounded
/// control-plane pool.
const HUNK_CONCURRENCY: usize = 16;
/// What happened to one path between two revisions.
#[derive(Debug, Clone, Serialize)]
pub struct Change {
pub path: String,
/// `added`, `modified`, `deleted` or `type_changed`.
pub kind: &'static str,
pub old_oid: Option<String>,
pub new_oid: Option<String>,
pub insertions: usize,
pub deletions: usize,
/// True when at least one side is not text.
pub binary: bool,
/// A side is a gunnar-crypt envelope (an encrypted repository): there is
/// nothing the console can diff. The browser opens both sides with the
/// reader's own key ring and diffs them there. A format fact; no key here.
pub sealed: bool,
/// True when the path names a submodule.
///
/// A gitlink's object id names a commit in **another** repository, which is
/// not in this one. Fetching it answers NotFound, and before this field
/// existed that refusal aborted the whole comparison — a repository with a
/// submodule could not be diffed at all.
pub submodule: bool,
/// True when a side was too large to fetch and compare.
pub too_large: bool,
/// Empty when the hunks were not computed; `hunks_computed` says which.
pub hunks: Vec<Hunk>,
pub hunks_computed: bool,
}
/// A run of changed lines with its surrounding context.
#[derive(Debug, Clone, Serialize)]
pub struct Hunk {
pub old_start: usize,
pub old_lines: usize,
pub new_start: usize,
pub new_lines: usize,
pub lines: Vec<Line>,
}
/// One line of a hunk.
#[derive(Debug, Clone, Serialize)]
pub struct Line {
/// `context`, `add` or `del`.
pub kind: &'static str,
pub old_no: Option<usize>,
pub new_no: Option<usize>,
pub text: String,
}
/// Everything that changed between two revisions.
#[derive(Debug, Clone, Serialize)]
pub struct Comparison {
pub changes: Vec<Change>,
/// Lines added across the files that were counted.
pub insertions: usize,
/// Lines removed across the files that were counted.
pub deletions: usize,
/// How many of `changes` the totals above cover.
///
/// Less than `changes.len()` when the hunk cap was reached, so the console
/// can qualify the numbers rather than showing a partial sum as a total.
pub counted_files: usize,
/// True when some files have no hunks because the cap was reached.
pub hunks_truncated: bool,
}
/// Compare two trees, optionally limited to a path prefix.
///
/// # Errors
///
/// Fails if an object cannot be read, or if the request's fetch budget runs out.
pub async fn compare(
repo: &Repository,
from_tree: &str,
to_tree: &str,
prefix: &str,
) -> Result<Comparison, ApiError> {
// Validated the same way a browse path is, then handed to gunnar.
let segments = super::split_path(prefix)?;
let prefixes = if segments.is_empty() {
Vec::new()
} else {
vec![segments.join("/")]
};
let mut changes: Vec<Change> = repo
.changes(from_tree, to_tree, prefixes)
.await?
.into_iter()
.map(change_from)
.collect();
changes.sort_by(|a, b| a.path.cmp(&b.path));
// Hunks are filled CONCURRENTLY, bounded. Sequentially this was the diff's
// whole latency: up to MAX_HUNKED_FILES × two blob round-trips to gunnar,
// one after another — MEASURED at 8.4 s for rust-lang/rust
// release-0.6→0.7 on loopback, nearly all of it waiting. Each future
// borrows a DISTINCT element, and the cap keeps a wide diff from dumping
// its whole fetch set on gunnar's bounded control-plane pool at once.
let hunks_truncated = changes.len() > MAX_HUNKED_FILES;
{
use futures_util::stream::FuturesUnordered;
use futures_util::StreamExt as _;
// A sliding window rather than `buffer_unordered` over a closure —
// axum's handler bound trips over the higher-ranked lifetime the
// closure form infers. Same shape: at most HUNK_CONCURRENCY in flight,
// a completed fill admits the next.
let mut remaining = changes.iter_mut().take(MAX_HUNKED_FILES);
let mut fills = FuturesUnordered::new();
for change in remaining.by_ref().take(HUNK_CONCURRENCY) {
fills.push(fill_hunks(repo, change));
}
while let Some(done) = fills.next().await {
done?;
if let Some(change) = remaining.next() {
fills.push(fill_hunks(repo, change));
}
}
}
// gunnar counted every file, so the totals are the whole thing — the hunk
// cap limits only what is drawn line by line.
Ok(Comparison {
insertions: changes.iter().map(|c| c.insertions).sum(),
deletions: changes.iter().map(|c| c.deletions).sum(),
counted_files: changes.len(),
changes,
hunks_truncated,
})
}
/// One of gunnar's `FileChange`s as the page's [`Change`].
fn change_from(change: crate::pb::FileChange) -> Change {
use crate::pb::ChangeKind;
let kind = match ChangeKind::try_from(change.kind) {
Ok(ChangeKind::Added) => "added",
Ok(ChangeKind::Deleted) => "deleted",
Ok(ChangeKind::TypeChanged) => "type_changed",
// gunnar never emits RENAMED or COPIED (no rename heuristic); were it
// to, the path is still one that changed.
_ => "modified",
};
// A gitlink: the mode git gives a submodule.
const GITLINK: u32 = 0o160000;
let oid = |o: String| (!o.is_empty() && o.bytes().any(|b| b != b'0')).then_some(o);
Change {
path: change.path,
kind,
old_oid: oid(change.old_oid),
new_oid: oid(change.new_oid),
insertions: usize::try_from(change.insertions).unwrap_or(usize::MAX),
deletions: usize::try_from(change.deletions).unwrap_or(usize::MAX),
binary: change.binary,
sealed: false,
submodule: change.old_mode == GITLINK || change.new_mode == GITLINK,
too_large: false,
hunks: Vec::new(),
hunks_computed: false,
}
}
/// Fetch both sides of a change and compute its hunks.
async fn fill_hunks(repo: &Repository, change: &mut Change) -> Result<(), ApiError> {
if change.submodule {
// The commit it points at lives in another repository. There is nothing
// here to read and nothing to diff.
change.hunks_computed = true;
return Ok(());
}
// Both sides at once: they are independent objects, and this call sits
// inside the bounded fan-out `compare` drives.
let (before, after) = tokio::try_join!(
load_side(repo, change.old_oid.as_deref()),
load_side(repo, change.new_oid.as_deref()),
)?;
match (before, after) {
(Side::TooLarge, _) | (_, Side::TooLarge) => {
change.too_large = true;
change.hunks_computed = true;
}
(Side::Sealed, _) | (_, Side::Sealed) => {
change.binary = true;
change.sealed = true;
change.hunks_computed = true;
}
(Side::Binary, _) | (_, Side::Binary) => {
change.binary = true;
change.hunks_computed = true;
}
(Side::Text(before), Side::Text(after)) => {
// The counts stay gunnar's; only the lines to draw are made here.
let (hunks, _, _) = hunks(&before, &after);
change.hunks = hunks;
change.hunks_computed = true;
}
}
Ok(())
}
enum Side {
Text(String),
Binary,
/// A gunnar-crypt envelope: ciphertext the browser may open.
Sealed,
TooLarge,
}
async fn load_side(repo: &Repository, oid: Option<&str>) -> Result<Side, ApiError> {
let Some(oid) = oid else {
return Ok(Side::Text(String::new()));
};
let object = repo.read_object(oid, MAX_BLOB_BYTES).await?;
if object.truncated || object.size > MAX_BLOB_BYTES {
return Ok(Side::TooLarge);
}
if gunnar_crypt::envelope::has_magic(&object.data) {
return Ok(Side::Sealed);
}
if looks_binary(&object.data, object.truncated) {
return Ok(Side::Binary);
}
Ok(Side::Text(
String::from_utf8_lossy(&object.data).into_owned(),
))
}
/// Line-level hunks between two texts.
pub fn hunks(before: &str, after: &str) -> (Vec<Hunk>, usize, usize) {
use imara_diff::{Algorithm, Diff, InternedInput};
let before_lines: Vec<&str> = before.lines().collect();
let after_lines: Vec<&str> = after.lines().collect();
let input = InternedInput::new(before, after);
let mut diff = Diff::compute(Algorithm::Histogram, &input);
// Shifts changes to the boundaries a person would have picked, so a moved
// block reads as a moved block rather than as an arbitrary split.
diff.postprocess_lines(&input);
let raw: Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> = diff
.hunks()
.map(|h| {
(
h.before.start as usize..h.before.end as usize,
h.after.start as usize..h.after.end as usize,
)
})
.collect();
let insertions = raw.iter().map(|(_, a)| a.len()).sum();
let deletions = raw.iter().map(|(b, _)| b.len()).sum();
// Group changes that are close enough to share context.
let mut groups: Vec<Vec<usize>> = Vec::new();
for (index, (before_range, _)) in raw.iter().enumerate() {
let joins = groups.last().is_some_and(|group| {
let last = &raw[*group.last().expect("a group is never empty")].0;
before_range.start.saturating_sub(last.end) <= MERGE_GAP
});
if joins {
groups.last_mut().expect("checked just above").push(index);
} else {
groups.push(vec![index]);
}
}
let mut out = Vec::new();
for group in groups {
let first = &raw[group[0]];
let last = &raw[*group.last().expect("a group is never empty")];
let old_from = first.0.start.saturating_sub(CONTEXT);
let old_to = (last.0.end + CONTEXT).min(before_lines.len());
let new_from = first.1.start.saturating_sub(CONTEXT);
let new_to = (last.1.end + CONTEXT).min(after_lines.len());
let mut lines = Vec::new();
let mut old_at = old_from;
let mut new_at = new_from;
for index in &group {
let (before_range, after_range) = &raw[*index];
while old_at < before_range.start {
lines.push(Line {
kind: "context",
old_no: Some(old_at + 1),
new_no: Some(new_at + 1),
text: before_lines[old_at].to_owned(),
});
old_at += 1;
new_at += 1;
}
for line in before_range.clone() {
lines.push(Line {
kind: "del",
old_no: Some(line + 1),
new_no: None,
text: before_lines[line].to_owned(),
});
}
for line in after_range.clone() {
lines.push(Line {
kind: "add",
old_no: None,
new_no: Some(line + 1),
text: after_lines[line].to_owned(),
});
}
old_at = before_range.end;
new_at = after_range.end;
}
while old_at < old_to && new_at < new_to {
lines.push(Line {
kind: "context",
old_no: Some(old_at + 1),
new_no: Some(new_at + 1),
text: before_lines[old_at].to_owned(),
});
old_at += 1;
new_at += 1;
}
let old_lines = old_at - old_from;
let new_lines = new_at - new_from;
out.push(Hunk {
// git writes 0 for a side with no lines, and the header is shown
// verbatim, so anyone comparing against `git diff` would see the
// difference.
old_start: if old_lines == 0 { 0 } else { old_from + 1 },
old_lines,
new_start: if new_lines == 0 { 0 } else { new_from + 1 },
new_lines,
lines,
});
}
(out, insertions, deletions)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unchanged_file_produces_no_hunks() {
let text = "one\ntwo\nthree\n";
let (hunks, insertions, deletions) = hunks(text, text);
assert!(hunks.is_empty());
assert_eq!((insertions, deletions), (0, 0));
}
#[test]
fn a_changed_line_is_a_removal_and_an_addition() {
let (hunks, insertions, deletions) = hunks("one\ntwo\nthree\n", "one\nTWO\nthree\n");
assert_eq!(hunks.len(), 1);
assert_eq!((insertions, deletions), (1, 1));
let kinds: Vec<&str> = hunks[0].lines.iter().map(|l| l.kind).collect();
assert_eq!(kinds, vec!["context", "del", "add", "context"]);
}
#[test]
fn line_numbers_count_from_one_on_both_sides() {
let (hunks, _, _) = hunks("a\nb\nc\n", "a\nB\nc\n");
let removed = hunks[0]
.lines
.iter()
.find(|l| l.kind == "del")
.expect("a removal");
let added = hunks[0]
.lines
.iter()
.find(|l| l.kind == "add")
.expect("an addition");
assert_eq!(removed.old_no, Some(2));
assert_eq!(removed.new_no, None);
assert_eq!(added.new_no, Some(2));
assert_eq!(added.old_no, None);
}
#[test]
fn a_new_file_is_all_additions() {
let (hunks, insertions, deletions) = hunks("", "one\ntwo\n");
assert_eq!((insertions, deletions), (2, 0));
assert!(hunks[0].lines.iter().all(|l| l.kind == "add"));
}
#[test]
fn a_deleted_file_is_all_removals() {
let (hunks, insertions, deletions) = hunks("one\ntwo\n", "");
assert_eq!((insertions, deletions), (0, 2));
assert!(hunks[0].lines.iter().all(|l| l.kind == "del"));
}
#[test]
fn distant_changes_stay_separate_hunks() {
let before: String = (0..60).map(|i| format!("line {i}\n")).collect();
let after = before
.replace("line 2\n", "LINE 2\n")
.replace("line 50\n", "LINE 50\n");
let (hunks, _, _) = hunks(&before, &after);
assert_eq!(hunks.len(), 2, "changes 48 lines apart are not one hunk");
}
#[test]
fn nearby_changes_merge_into_one_hunk() {
let before: String = (0..30).map(|i| format!("line {i}\n")).collect();
let after = before
.replace("line 10\n", "LINE 10\n")
.replace("line 12\n", "LINE 12\n");
let (hunks, _, _) = hunks(&before, &after);
assert_eq!(
hunks.len(),
1,
"two lines apart share context, so printing them separately would repeat it"
);
}
#[test]
fn context_does_not_run_past_the_end_of_the_file() {
// The off-by-one that turns into a panic on the last line of a file.
let (hunks, _, _) = hunks("a\nb\n", "a\nB\n");
let last = hunks[0].lines.last().expect("some lines");
assert!(last.new_no.unwrap_or(0) <= 2);
}
#[test]
fn a_file_without_a_trailing_newline_still_diffs() {
let (hunks, insertions, _) = hunks("a\nb", "a\nc");
assert_eq!(insertions, 1);
assert!(!hunks.is_empty());
}
}