vig 0.7.0

Git TUI side-by-side diff viewer
use git2::{Delta, DiffDelta, DiffLine, DiffOptions, ObjectType, Patch, Repository};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
    Added,
    Deleted,
    Modified,
    Renamed,
    Untracked,
}

impl FileStatus {
    pub fn from_delta(delta: Delta) -> Self {
        match delta {
            Delta::Added => FileStatus::Added,
            Delta::Deleted => FileStatus::Deleted,
            Delta::Modified => FileStatus::Modified,
            Delta::Renamed => FileStatus::Renamed,
            _ => FileStatus::Modified,
        }
    }

    pub fn icon(&self) -> &'static str {
        match self {
            FileStatus::Added => "A",
            FileStatus::Deleted => "D",
            FileStatus::Modified => "M",
            FileStatus::Renamed => "R",
            FileStatus::Untracked => "?",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum LineType {
    Context,
    Added,
    Deleted,
    HunkHeader,
}

#[derive(Debug, Clone)]
pub struct SideLine {
    pub line_no: u32,
    pub content: String,
}

#[derive(Debug, Clone)]
pub struct SideBySideRow {
    pub left: Option<SideLine>,
    pub right: Option<SideLine>,
    pub line_type: LineType,
}

#[derive(Debug, Clone)]
pub struct DiffHunk {
    pub header: String,
    pub rows: Vec<SideBySideRow>,
}

#[derive(Debug, Clone)]
pub struct FileDiff {
    pub path: String,
    pub status: FileStatus,
    pub hunks: Vec<DiffHunk>,
    pub is_binary: bool,
}

#[derive(Debug, Clone)]
pub struct DiffStats {
    pub additions: usize,
    pub deletions: usize,
}

/// Result of a diff operation, returned by Repo::diff_workdir.
pub struct DiffResult {
    pub files: Vec<FileDiff>,
    pub branch_name: String,
    pub stats: DiffStats,
}

/// Lightweight metadata kept in GitState (no files Vec).
pub struct DiffMeta {
    pub branch_name: String,
    pub stats: DiffStats,
    pub file_count: usize,
}

struct RawHunkLine {
    origin: char,
    old_lineno: Option<u32>,
    new_lineno: Option<u32>,
    content: String,
}

pub fn parse_diff(repo: &Repository, base_ref: Option<&str>) -> anyhow::Result<Vec<FileDiff>> {
    let head = match base_ref {
        Some(r) => {
            let obj = repo
                .revparse_single(r)
                .map_err(|e| anyhow::anyhow!("Cannot resolve '{}': {}", r, e))?;
            let tree_obj = obj
                .peel(ObjectType::Tree)
                .map_err(|e| anyhow::anyhow!("Cannot peel to tree: {}", e))?;
            Some(
                tree_obj
                    .into_tree()
                    .map_err(|_| anyhow::anyhow!("Not a tree"))?,
            )
        }
        None => repo.head().ok().and_then(|r| r.peel_to_tree().ok()),
    };
    let mut opts = DiffOptions::new();
    opts.include_untracked(true);
    opts.recurse_untracked_dirs(true);
    opts.show_untracked_content(true);

    let diff = repo.diff_tree_to_workdir_with_index(head.as_ref(), Some(&mut opts))?;
    files_from_diff(&diff)
}

/// Convert a libgit2 diff (working tree, tree-to-tree, or one parsed from a
/// patch buffer with `git2::Diff::from_buffer`) into side-by-side `FileDiff`s.
pub fn files_from_diff(diff: &git2::Diff) -> anyhow::Result<Vec<FileDiff>> {
    let mut files = Vec::new();

    let num_deltas = diff.deltas().count();
    for idx in 0..num_deltas {
        let delta = diff.get_delta(idx).unwrap();
        let status = delta_status(&delta);
        let path = delta_path(&delta);

        if let Ok(Some(patch)) = Patch::from_diff(diff, idx) {
            let is_binary = patch.delta().flags().is_binary();
            if is_binary {
                files.push(FileDiff {
                    path,
                    status,
                    hunks: Vec::new(),
                    is_binary: true,
                });
                continue;
            }

            let mut hunks = Vec::new();
            for hunk_idx in 0..patch.num_hunks() {
                let (hunk, _) = patch.hunk(hunk_idx)?;
                let header = String::from_utf8_lossy(hunk.header()).trim().to_string();

                let mut raw_lines = Vec::new();
                for line_idx in 0..patch.num_lines_in_hunk(hunk_idx)? {
                    let line = patch.line_in_hunk(hunk_idx, line_idx)?;
                    raw_lines.push(raw_from_diff_line(&line));
                }

                let rows = align_hunk_lines(&raw_lines);
                hunks.push(DiffHunk { header, rows });
            }

            files.push(FileDiff {
                path,
                status,
                hunks,
                is_binary: false,
            });
        }
    }

    Ok(files)
}

fn delta_status(delta: &DiffDelta) -> FileStatus {
    match delta.status() {
        Delta::Added | Delta::Untracked => {
            if delta.status() == Delta::Untracked {
                FileStatus::Untracked
            } else {
                FileStatus::Added
            }
        }
        other => FileStatus::from_delta(other),
    }
}

fn delta_path(delta: &DiffDelta) -> String {
    delta
        .new_file()
        .path()
        .or_else(|| delta.old_file().path())
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|| "<unknown>".to_string())
}

fn raw_from_diff_line(line: &DiffLine) -> RawHunkLine {
    let content = String::from_utf8_lossy(line.content()).to_string();
    // Strip trailing newline for display
    let content = content.trim_end_matches('\n').to_string();
    RawHunkLine {
        origin: line.origin(),
        old_lineno: line.old_lineno(),
        new_lineno: line.new_lineno(),
        content,
    }
}

fn align_hunk_lines(lines: &[RawHunkLine]) -> Vec<SideBySideRow> {
    let mut rows = Vec::new();
    let mut i = 0;

    while i < lines.len() {
        match lines[i].origin {
            ' ' => {
                // Context line
                rows.push(SideBySideRow {
                    left: Some(SideLine {
                        line_no: lines[i].old_lineno.unwrap_or(0),
                        content: lines[i].content.clone(),
                    }),
                    right: Some(SideLine {
                        line_no: lines[i].new_lineno.unwrap_or(0),
                        content: lines[i].content.clone(),
                    }),
                    line_type: LineType::Context,
                });
                i += 1;
            }
            '-' => {
                // Collect consecutive deletions
                let del_start = i;
                while i < lines.len() && lines[i].origin == '-' {
                    i += 1;
                }
                let dels = &lines[del_start..i];

                // Collect consecutive additions
                let add_start = i;
                while i < lines.len() && lines[i].origin == '+' {
                    i += 1;
                }
                let adds = &lines[add_start..i];

                // Pair them up
                let max_len = dels.len().max(adds.len());
                for j in 0..max_len {
                    let left = dels.get(j).map(|l| SideLine {
                        line_no: l.old_lineno.unwrap_or(0),
                        content: l.content.clone(),
                    });
                    let right = adds.get(j).map(|l| SideLine {
                        line_no: l.new_lineno.unwrap_or(0),
                        content: l.content.clone(),
                    });

                    let line_type = match (left.is_some(), right.is_some()) {
                        (true, true) => LineType::Deleted,
                        (true, false) => LineType::Deleted,
                        (false, true) => LineType::Added,
                        (false, false) => continue,
                    };

                    rows.push(SideBySideRow {
                        left,
                        right,
                        line_type,
                    });
                }
            }
            '+' => {
                // Addition without preceding deletion
                rows.push(SideBySideRow {
                    left: None,
                    right: Some(SideLine {
                        line_no: lines[i].new_lineno.unwrap_or(0),
                        content: lines[i].content.clone(),
                    }),
                    line_type: LineType::Added,
                });
                i += 1;
            }
            _ => {
                i += 1;
            }
        }
    }

    rows
}

impl FileDiff {
    /// Extract data needed for syntax highlighting.
    pub fn highlight_data(&self) -> (String, Vec<String>, Vec<String>, Vec<usize>) {
        let mut left_lines = Vec::new();
        let mut right_lines = Vec::new();
        let mut hunk_starts = Vec::new();
        for hunk in &self.hunks {
            hunk_starts.push(left_lines.len());
            left_lines.push(String::new());
            right_lines.push(String::new());
            for row in &hunk.rows {
                left_lines.push(
                    row.left
                        .as_ref()
                        .map(|s| s.content.clone())
                        .unwrap_or_default(),
                );
                right_lines.push(
                    row.right
                        .as_ref()
                        .map(|s| s.content.clone())
                        .unwrap_or_default(),
                );
            }
        }
        (self.path.clone(), left_lines, right_lines, hunk_starts)
    }
}

pub fn compute_stats(files: &[FileDiff]) -> DiffStats {
    let mut additions = 0;
    let mut deletions = 0;
    for file in files {
        for hunk in &file.hunks {
            for row in &hunk.rows {
                match row.line_type {
                    LineType::Added => additions += 1,
                    LineType::Deleted => {
                        // Paired rows count as both a deletion and addition
                        if row.right.is_some() {
                            additions += 1;
                        }
                        deletions += 1;
                    }
                    _ => {}
                }
            }
        }
    }
    DiffStats {
        additions,
        deletions,
    }
}