Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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