gitui-view 0.2.0

A terminal-based Git repository browser with inline and side-by-side diff views
use anyhow::{Context, Result};
use git2::{DiffOptions, Repository, Sort};
use std::cell::RefCell;
use std::path::{Path, PathBuf};

#[derive(Clone, Debug)]
pub struct StatusEntry {
    pub path: String,
    pub status: StatusType,
    pub staged: bool,
}

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

#[derive(Clone, Debug)]
pub struct CommitInfo {
    pub id: String,
    pub short_id: String,
    pub author: String,
    pub time: i64,
    pub summary: String,
}

#[derive(Clone, Debug)]
pub struct StashInfo {
    pub index: usize,
    pub message: String,
    pub commit_id: String,
    pub time: i64,
}

#[derive(Clone, Debug)]
pub struct DiffLine {
    pub content: String,
    pub line_type: DiffLineType,
    pub old_lineno: Option<u32>,
    pub new_lineno: Option<u32>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DiffLineType {
    Context,
    Add,
    Delete,
    Header,
}

#[derive(Clone, Debug)]
pub struct Hunk {
    pub header: String,
    pub lines: Vec<DiffLine>,
}

#[derive(Clone, Debug)]
pub struct FileDiff {
    pub old_path: String,
    pub new_path: String,
    pub status: StatusType,
    pub hunks: Vec<Hunk>,
    /// old and new file size in bytes
    pub sizes: (u64, u64),
    /// size delta in bytes
    pub size_delta: i64,
}

pub struct GitRepo {
    repo: Repository,
}

impl GitRepo {
    pub fn open(path: &Path) -> Result<Self> {
        let repo = Repository::open(path)
            .with_context(|| format!("Failed to open git repository at {:?}", path))?;
        Ok(Self { repo })
    }

    pub fn workdir(&self) -> Option<PathBuf> {
        self.repo.workdir().map(|p| p.to_path_buf())
    }

    pub fn get_status(&self) -> Result<(Vec<StatusEntry>, Vec<StatusEntry>)> {
        let mut staged = Vec::new();
        let mut unstaged = Vec::new();

        let mut status_opts = git2::StatusOptions::new();
        status_opts
            .include_untracked(true)
            .recurse_untracked_dirs(true);

        let statuses = self
            .repo
            .statuses(Some(&mut status_opts))
            .context("Failed to get repository status")?;

        for entry in statuses.iter() {
            let path = entry
                .path()
                .map(|p| p.to_string())
                .unwrap_or_default();

            let flags = entry.status();

            if flags.is_empty() {
                continue;
            }

            if flags.intersects(
                git2::Status::INDEX_NEW
                    | git2::Status::INDEX_MODIFIED
                    | git2::Status::INDEX_DELETED
                    | git2::Status::INDEX_RENAMED
                    | git2::Status::INDEX_TYPECHANGE,
            ) {
                let st = if flags.contains(git2::Status::INDEX_NEW) {
                    StatusType::Added
                } else if flags.contains(git2::Status::INDEX_DELETED) {
                    StatusType::Deleted
                } else if flags.contains(git2::Status::INDEX_RENAMED) {
                    StatusType::Renamed
                } else if flags.contains(git2::Status::INDEX_TYPECHANGE) {
                    StatusType::TypeChange
                } else {
                    StatusType::Modified
                };
                staged.push(StatusEntry {
                    path: path.clone(),
                    status: st,
                    staged: true,
                });
            }

            if flags.intersects(
                git2::Status::WT_NEW
                    | git2::Status::WT_MODIFIED
                    | git2::Status::WT_DELETED
                    | git2::Status::WT_RENAMED
                    | git2::Status::WT_TYPECHANGE,
            ) {
                let st = if flags.contains(git2::Status::WT_NEW) {
                    StatusType::Untracked
                } else if flags.contains(git2::Status::WT_DELETED) {
                    StatusType::Deleted
                } else if flags.contains(git2::Status::WT_RENAMED) {
                    StatusType::Renamed
                } else if flags.contains(git2::Status::WT_TYPECHANGE) {
                    StatusType::TypeChange
                } else {
                    StatusType::Modified
                };
                unstaged.push(StatusEntry {
                    path,
                    status: st,
                    staged: false,
                });
            }
        }

        Ok((staged, unstaged))
    }

    pub fn get_commits(&self, count: usize) -> Result<Vec<CommitInfo>> {
        let mut revwalk = self.repo.revwalk().context("Failed to create revwalk")?;
        revwalk.set_sorting(Sort::TIME)?;
        revwalk.push_head()?;

        let mut commits = Vec::new();
        for (_, oid) in revwalk.enumerate().take(count) {
            let oid = oid?;
            let commit = self.repo.find_commit(oid)?;
            let time = commit.time().seconds();
            let author = commit.author().name().unwrap_or("unknown").to_string();
            let summary = commit.summary().unwrap_or("").to_string();

            commits.push(CommitInfo {
                id: oid.to_string(),
                short_id: oid.to_string()[..7].to_string(),
                author,
                time,
                summary,
            });
        }

        Ok(commits)
    }

    pub fn get_commit_diff(&self, commit_id: &str) -> Result<Vec<FileDiff>> {
        let oid = git2::Oid::from_str(commit_id)
            .map_err(|_| anyhow::anyhow!("Invalid commit id: {}", commit_id))?;
        let commit = self.repo.find_commit(oid)?;
        let tree = commit.tree()?;

        let parent_tree = if commit.parent_count() > 0 {
            commit.parent(0)?.tree().ok()
        } else {
            None
        };

        let mut diff_opts = DiffOptions::new();
        let diff = self
            .repo
            .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;

        Self::diff_to_file_diffs(&diff)
    }

    pub fn get_workdir_diff(&self, path: &str, staged: bool) -> Result<FileDiff> {
        let mut diff_opts = DiffOptions::new();
        diff_opts.pathspec(path);

        let diff = if staged {
            let head_tree = self.repo.head().ok().and_then(|h| {
                h.peel_to_tree().ok()
            });
            let index = self.repo.index()?;
            self.repo
                .diff_tree_to_index(head_tree.as_ref(), Some(&index), Some(&mut diff_opts))?
        } else {
            diff_opts.include_untracked(true);
            diff_opts.recurse_untracked_dirs(true);
            let index = self.repo.index()?;
            self.repo
                .diff_index_to_workdir(Some(&index), Some(&mut diff_opts))?
        };

        let mut file_diffs = Self::diff_to_file_diffs(&diff)?;
        Ok(file_diffs.remove(0))
    }

    pub fn get_stashes(&mut self) -> Result<Vec<StashInfo>> {
        let mut stashes = Vec::new();
        self.repo.stash_foreach(|index, message, oid| {
            stashes.push(StashInfo {
                index,
                message: message.to_string(),
                commit_id: oid.to_string(),
                time: 0,
            });
            true
        })?;

        for stash in &mut stashes {
            if let Ok(commit) = self.repo.find_commit(
                git2::Oid::from_str(&stash.commit_id).unwrap(),
            ) {
                stash.time = commit.time().seconds();
            }
        }

        Ok(stashes)
    }

    pub fn get_stash_diff(&mut self, index: usize) -> Result<Vec<FileDiff>> {
        let mut stashes = Vec::new();
        self.repo.stash_foreach(|i, _msg, oid| {
            stashes.push((i, *oid));
            true
        })?;

        let stash_oid = stashes
            .into_iter()
            .find(|(i, _)| *i == index)
            .map(|(_, oid)| oid)
            .ok_or_else(|| anyhow::anyhow!("Stash index {} not found", index))?;

        let commit = self.repo.find_commit(stash_oid)?;
        let tree = commit.tree()?;

        let parent_tree = if commit.parent_count() > 0 {
            commit.parent(0)?.tree().ok()
        } else {
            None
        };

        let mut diff_opts = DiffOptions::new();
        let diff = self
            .repo
            .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;

        Self::diff_to_file_diffs(&diff)
    }

    fn diff_to_file_diffs(diff: &git2::Diff) -> Result<Vec<FileDiff>> {
        let file_diffs = RefCell::new(Vec::new());

        diff.foreach(
            &mut |delta, _| {
                let old_path = delta
                    .old_file()
                    .path()
                    .map(|p| p.to_string_lossy().to_string())
                    .unwrap_or_default();
                let new_path = delta
                    .new_file()
                    .path()
                    .map(|p| p.to_string_lossy().to_string())
                    .unwrap_or_default();

                let status = match delta.status() {
                    git2::Delta::Added => StatusType::Added,
                    git2::Delta::Deleted => StatusType::Deleted,
                    git2::Delta::Modified => StatusType::Modified,
                    git2::Delta::Renamed => StatusType::Renamed,
                    git2::Delta::Copied => StatusType::Copied,
                    git2::Delta::Untracked => StatusType::Untracked,
                    git2::Delta::Typechange => StatusType::TypeChange,
                    _ => StatusType::Modified,
                };

                let old_size = delta.old_file().size();
                let new_size = delta.new_file().size();

                file_diffs.borrow_mut().push(FileDiff {
                    old_path,
                    new_path,
                    status,
                    hunks: Vec::new(),
                    sizes: (old_size, new_size),
                    size_delta: (new_size as i64) - (old_size as i64),
                });

                true
            },
            None,
            Some(&mut |_delta, hunk| {
                let header = String::from_utf8_lossy(hunk.header()).to_string();
                file_diffs.borrow_mut().last_mut().unwrap().hunks.push(Hunk {
                    header,
                    lines: Vec::new(),
                });
                true
            }),
            Some(&mut |_delta, _hunk, line| {
                let content = String::from_utf8_lossy(line.content())
                    .trim_end_matches('\n')
                    .to_string();

                let line_type = match line.origin() {
                    '+' => DiffLineType::Add,
                    '-' => DiffLineType::Delete,
                    ' ' => DiffLineType::Context,
                    _ => DiffLineType::Header,
                };

                if let Some(hunk) = file_diffs.borrow_mut().last_mut().unwrap().hunks.last_mut() {
                    hunk.lines.push(DiffLine {
                        content,
                        line_type,
                        old_lineno: line.old_lineno(),
                        new_lineno: line.new_lineno(),
                    });
                }

                true
            }),
        )?;

        Ok(file_diffs.into_inner())
    }
}