idgit 0.0.1

A TUI for git inspired by magit
Documentation
use std::{fmt, path::Path};

use crate::{
    diff,
    file::{self, File},
    Error, Result,
};
#[allow(unused)]
use tracing::{debug, error, info, instrument, span, warn};

pub struct Repo {
    pub(crate) internal: Internal,
    history: undo::History<Change>,
}

impl Repo {
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
        let internal = Internal::open(path)?;
        let history = undo::History::new();
        Ok(Self { internal, history })
    }

    pub fn can_undo(&self) -> bool {
        self.history.can_undo()
    }

    pub fn can_redo(&self) -> bool {
        self.history.can_redo()
    }

    pub fn undo(&mut self) -> Result<()> {
        self.history
            .undo(&mut self.internal)
            .ok_or(Error::UndoEmpty)
            .flatten()
    }

    pub fn redo(&mut self) -> Result<()> {
        self.history
            .redo(&mut self.internal)
            .ok_or(Error::RedoEmpty)
            .flatten()
    }

    pub fn path(&self) -> &Path {
        self.internal.path()
    }

    pub fn uncommitted_files(&self) -> Result<Vec<diff::Meta>> {
        self.internal.uncommitted_files()
    }

    pub fn diff_details(&self, diff: &diff::Meta) -> Result<diff::Details> {
        self.internal.diff_details(diff)
    }

    pub fn stage_file(&mut self, file: File) -> Result<()> {
        self.apply(Change::StageFile(file))
    }

    pub fn unstage_file(&mut self, file: File) -> Result<()> {
        self.apply(Change::UnstageFile(file))
    }

    pub fn stage_contents(&mut self, file: File, contents: file::Contents) -> Result<()> {
        self.apply(Change::StageContents(file, contents))
    }

    fn apply(&mut self, change: Change) -> Result<()> {
        self.history.apply(&mut self.internal, change)
    }
}

impl fmt::Debug for Repo {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let history = format!("{}", self.history.display());
        f.debug_struct("Repo")
            .field("internal", &self.internal)
            .field("history", &history)
            .finish_non_exhaustive()
    }
}

#[derive(Debug, Clone)]
enum Change {
    StageFile(File),
    UnstageFile(File),
    StageContents(File, file::Contents),
}

impl undo::Action for Change {
    type Target = Internal;
    type Output = ();
    type Error = Error;

    fn apply(&mut self, target: &mut Self::Target) -> undo::Result<Self> {
        match self {
            Change::StageFile(file) => target.stage_file(file),
            Change::UnstageFile(file) => target.unstage_file(file),
            Change::StageContents(file, contents) => target.stage_contents(file, contents),
        }
    }

    fn undo(&mut self, target: &mut Self::Target) -> undo::Result<Self> {
        match self {
            Change::StageFile(file) => target.unstage_file(file),
            Change::UnstageFile(file) => target.stage_file(file),
            Change::StageContents(file, contents) => todo!(),
        }
    }
}

impl fmt::Display for Change {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Debug::fmt(self, f)
    }
}

/// Internal manages everything that doesn't require history. This is so that
/// actions on the history can mutably borrow something that doesn't contain the
/// history itself.
pub(crate) struct Internal {
    git: git2::Repository,
}

impl Internal {
    fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
        let git = git2::Repository::open(path)?;
        Ok(Self { git })
    }

    pub(crate) fn path(&self) -> &Path {
        let path = self.git.path();
        if path.ends_with(".git") {
            path.parent().expect("If non-bare has parent")
        } else {
            path
        }
    }

    fn head_assuming_born(&self) -> std::result::Result<git2::Tree, git2::Error> {
        self.git.head()?.peel_to_commit()?.tree()
    }

    fn head(&self) -> Result<Option<git2::Tree>> {
        match self.head_assuming_born() {
            Ok(head) => Ok(Some(head)),
            Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
            Err(err) => Err(err.into()),
        }
    }

    fn uncommitted_files(&self) -> Result<Vec<diff::Meta>> {
        let head = self.head()?;

        let mut opts = Self::uncommitted_opts();
        opts.include_unmodified(true); // needed for rename/copy tracking
        opts.recurse_untracked_dirs(true);

        let mut diff = self
            .git
            .diff_tree_to_workdir_with_index(head.as_ref(), Some(&mut opts))?;

        // Detect renames
        let mut opts = git2::DiffFindOptions::new();
        opts.renames(true)
            .copies(true)
            .copies_from_unmodified(true)
            .for_untracked(true)
            .ignore_whitespace(true)
            .break_rewrites_for_renames_only(true)
            // Don't keep them, since we only want for copy/rename tracking
            // If we included them we'd have to search them in `diff_details`
            .remove_unmodified(true);
        diff.find_similar(Some(&mut opts))?;

        diff.deltas()
            .map(|delta| diff::Meta::from_git2(&delta))
            .collect()
    }

    fn diff_details(&self, meta: &diff::Meta) -> Result<diff::Details> {
        match meta {
            crate::Meta::Added(f)
            | crate::Meta::Deleted(f)
            | crate::Meta::Modified { new: f, .. }
            | crate::Meta::Renamed { new: f, .. }
            | crate::Meta::Copied { new: f, .. }
            | crate::Meta::Ignored(f)
            | crate::Meta::Untracked(f)
            | crate::Meta::Typechange { new: f, .. }
            | crate::Meta::Unreadable(f)
            | crate::Meta::Conflicted { new: f, .. } => self._diff_details(f),
        }
    }

    fn _diff_details(&self, file: &File) -> Result<diff::Details> {
        let head = self.head()?;

        let mut opts = Self::uncommitted_opts();
        opts.pathspec(file.rel_path());

        let mut meta: Option<Result<diff::Meta>> = None;
        let mut file_cb = |delta: git2::DiffDelta<'_>, _progress| {
            if let Some(delta_path) = Self::delta_path(&delta) {
                if delta_path == file.rel_path() {
                    meta = Some(diff::Meta::from_git2(&delta));
                    return true;
                }
            }

            // NOTE: If we ask to stop once we get the target lines_cb isn't
            // called, so we exit on the first subsequent delta.

            meta.is_none()
        };

        let mut lines = vec![];
        let mut line_cb = |delta: git2::DiffDelta<'_>,
                           _hunk: Option<git2::DiffHunk<'_>>,
                           line: git2::DiffLine<'_>| {
            if let Some(delta_path) = Self::delta_path(&delta) {
                if delta_path == file.rel_path() {
                    let line = diff::Line::from_git2(&line);
                    lines.push(line);
                }
            }

            true
        };

        match self
            .git
            .diff_tree_to_workdir_with_index(head.as_ref(), Some(&mut opts))?
            .foreach(&mut file_cb, None, None, Some(&mut line_cb))
        {
            Ok(()) => (),
            Err(err) if err.code() == git2::ErrorCode::User => (),
            Err(err) => return Err(err.into()),
        }

        let meta = meta
            .ok_or_else(|| Error::PathNotFound(file.clone()))
            .flatten()?;

        Ok(diff::Details::new(meta, lines))
    }

    fn delta_path<'a, 'b>(delta: &'a git2::DiffDelta<'b>) -> Option<&'b Path> {
        delta
            .new_file()
            .path()
            .map_or_else(|| delta.old_file().path(), |delta_path| Some(delta_path))
    }

    fn uncommitted_opts() -> git2::DiffOptions {
        let mut opts = git2::DiffOptions::new();
        opts.include_untracked(true)
            .include_typechange(true)
            .include_unmodified(false)
            .include_unreadable(true)
            .include_untracked(true)
            .include_ignored(true);
        opts
    }

    fn stage_file(&mut self, file: &File) -> Result<()> {
        self.ensure_not_ignored(file)?;
        self.git.index()?.add_path(file.rel_path())?;

        Ok(())
    }

    fn unstage_file(&mut self, file: &File) -> Result<()> {
        self.ensure_not_ignored(file)?;
        self.git.index()?.remove_path(file.rel_path())?;
        Ok(())
    }

    fn stage_contents(&self, file: &File, contents: &file::Contents) -> Result<()> {
        self.ensure_not_ignored(file)?;

        // It appears we only have to fill out the path and mode
        // See <https://github.com/libgit2/libgit2/blob/508361401fbb5d87118045eaeae3356a729131aa/tests/index/filemodes.c#L179>
        let entry = git2::IndexEntry {
            ctime: git2::IndexTime::new(0, 0),
            mtime: git2::IndexTime::new(0, 0),
            dev: 0,
            ino: 0,
            mode: libgit2_sys::GIT_FILEMODE_BLOB,
            uid: 0,
            gid: 0,
            file_size: 0,
            id: git2::Oid::zero(),
            flags: 0,
            flags_extended: 0,
            path: file.rel_path_bytes()?.into_bytes(),
        };

        let mut index = self.git.index()?;
        index.add_frombuffer(&entry, contents.buffer())?;
        index.write()?;

        Ok(())
    }

    fn ensure_not_ignored(&self, file: &File) -> Result<()> {
        if self.git.status_should_ignore(file.rel_path())? {
            return Err(Error::Ignored(file.clone()));
        }
        Ok(())
    }
}

impl fmt::Debug for Internal {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("RepoInternal")
            .field("path", &self.git.path())
            .finish_non_exhaustive()
    }
}