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

nordisk/gunnar

crates/gunnar-crypt/src/diff.rs

//! Line diffs of DECRYPTED content, computed where the key is: in the CLI
//! (`gunnar crypt diff`) and in the browser console's wasm. The server never
//! sees plaintext, so it cannot diff an encrypted repository; there are no
//! pre-made diffs either (they would only cost space). Both sides run this
//! same function, so a diff reads the same in the terminal and in the browser.
//!
//! The shape matches the console's server-side diff (gunnar-ui-server
//! `git/diff.rs`): histogram diff, 3 lines of context, hunks closer than 7
//! lines merged, git's `@@` numbering.

use imara_diff::{Algorithm, Diff, InternedInput};

/// Lines of unchanged context around a change.
pub const CONTEXT: usize = 3;
/// Two changes closer than this share one hunk.
pub const MERGE_GAP: usize = CONTEXT * 2 + 1;

/// What one line of a hunk is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineKind {
    /// Unchanged.
    Context,
    /// Only in the new version.
    Add,
    /// Only in the old version.
    Del,
}

impl LineKind {
    /// `context`, `add` or `del` — the console's names.
    pub fn as_str(self) -> &'static str {
        match self {
            LineKind::Context => "context",
            LineKind::Add => "add",
            LineKind::Del => "del",
        }
    }
}

/// One line of a hunk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Line {
    /// What it is.
    pub kind: LineKind,
    /// 1-based line number in the old version.
    pub old_no: Option<usize>,
    /// 1-based line number in the new version.
    pub new_no: Option<usize>,
    /// The text, without its newline.
    pub text: String,
}

/// A run of changed lines with its context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
    /// git's `-a` (0 when the old side has no lines).
    pub old_start: usize,
    /// git's `,b`.
    pub old_lines: usize,
    /// git's `+c` (0 when the new side has no lines).
    pub new_start: usize,
    /// git's `,d`.
    pub new_lines: usize,
    /// The lines.
    pub lines: Vec<Line>,
}

/// A whole-file comparison.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FileDiff {
    /// The hunks, in order.
    pub hunks: Vec<Hunk>,
    /// Lines added.
    pub insertions: usize,
    /// Lines removed.
    pub deletions: usize,
}

/// Line-level hunks between two texts.
pub fn hunks(before: &str, after: &str) -> FileDiff {
    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);
    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();

    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: LineKind::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: LineKind::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: LineKind::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: LineKind::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 {
            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,
        });
    }
    FileDiff {
        hunks: out,
        insertions,
        deletions,
    }
}

/// The hunks as unified-diff text (`@@ -a,b +c,d @@`, then ` `/`+`/`-` lines).
pub fn unified(diff: &FileDiff) -> String {
    let mut s = String::new();
    for h in &diff.hunks {
        s.push_str(&format!(
            "@@ -{},{} +{},{} @@\n",
            h.old_start, h.old_lines, h.new_start, h.new_lines
        ));
        for l in &h.lines {
            s.push(match l.kind {
                LineKind::Context => ' ',
                LineKind::Add => '+',
                LineKind::Del => '-',
            });
            s.push_str(&l.text);
            s.push('\n');
        }
    }
    s
}