vcs-modify-guard 0.1.0

A library for checking whether it is safe to modify files in a VCS worktree.
Documentation
use std::{
    fmt,
    path::{Path, PathBuf},
};

use snafu::{IntoError as _, ResultExt as _, Snafu};

use super::VcsRepository;
use crate::{
    error::{self, ModifyGuardError},
    repository::{FileChange, RepositoryChanges},
    util::{self, WorktreeRelativePath},
    vcs::VcsBackend,
};

pub(super) const BACKEND: Libgit2Backend = Libgit2Backend;

#[derive(Debug)]
pub(super) struct Libgit2Backend;

/// Errors returned by `libgit2` backend operations.
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum Libgit2BackendError {
    /// Searching for a Git repository failed.
    #[snafu(display("failed while searching for a git repository at or above path: {}", path.display()))]
    Discover {
        /// The path that was being searched for a Git repository.
        path: PathBuf,
        /// The underlying error from `libgit2`.
        source: git2::Error,
    },
    /// Opening a Git repository failed.
    #[snafu(display("failed to open git repository at path: {}", path.display()))]
    Open {
        /// The path that was being opened as a Git repository.
        path: PathBuf,
        /// The underlying error from `libgit2`.
        source: git2::Error,
    },
    /// Querying repository changes failed.
    #[snafu(display("failed to query git repository changes for worktree: {}", worktree.display()))]
    QueryRepositoryChanges {
        /// The worktree of the Git repository.
        worktree: PathBuf,
        /// The underlying error from `libgit2`.
        source: git2::Error,
    },
    /// Querying file change failed.
    #[snafu(display("failed to query git file change for worktree-relative path: {}", wt_path.display()))]
    QueryFileChange {
        /// The worktree-relative path of the file whose change was being retrieved.
        wt_path: PathBuf,
        /// The underlying error from `libgit2`.
        source: git2::Error,
    },
}

impl From<Libgit2BackendError> for ModifyGuardError {
    #[inline]
    fn from(source: Libgit2BackendError) -> Self {
        Self::Backend {
            source: source.into(),
        }
    }
}

impl VcsBackend for Libgit2Backend {
    fn discover(&self, path: &Path) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
        util::ensure_path_exists(path)?;
        let repo = match git2::Repository::discover(path) {
            Ok(repo) => repo,
            Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(None),
            Err(source) => {
                return Err(DiscoverSnafu { path }.into_error(source).into());
            }
        };
        let Some(worktree) = repo.workdir() else {
            return Err(error::RepositoryWithoutWorktreeSnafu { path: repo.path() }.build());
        };
        let worktree = worktree.to_owned();
        Ok(Some(Box::new(Libgit2Repository { repo, worktree })))
    }

    fn open(&self, path: &Path) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
        util::ensure_path_is_directory(path)?;
        let repo = match git2::Repository::open(path) {
            Ok(repo) => repo,
            Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(None),
            Err(source) => {
                return Err(OpenSnafu { path }.into_error(source).into());
            }
        };
        let Some(worktree) = repo.workdir() else {
            return Err(error::RepositoryWithoutWorktreeSnafu { path: repo.path() }.build());
        };
        let worktree = worktree.to_owned();
        Ok(Some(Box::new(Libgit2Repository { repo, worktree })))
    }
}

struct Libgit2Repository {
    repo: git2::Repository,
    worktree: PathBuf,
}

impl fmt::Debug for Libgit2Repository {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Libgit2Repository")
            .field("repo", &"<git2::Repository>")
            .field("worktree", &self.worktree)
            .finish()
    }
}

impl VcsRepository for Libgit2Repository {
    fn worktree(&self) -> &Path {
        &self.worktree
    }

    fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
        self.collect_dir_changes(None)
    }

    fn path_changes(&self, wt_path: &Path) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
        let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
        let is_dir = match &wt_path {
            WorktreeRelativePath::Existing(wt_path) => {
                let fs_path = self.worktree.join(wt_path);
                let metadata = util::read_path_metadata(&fs_path)?;
                metadata.is_dir()
            }
            WorktreeRelativePath::Missing(_) => true,
        };
        if wt_path.as_path().as_os_str().is_empty() {
            return self.collect_dir_changes(None);
        }
        if is_dir {
            return self.collect_dir_changes(Some(&wt_path));
        }
        let change = self.query_file_change(wt_path)?;
        Ok(change.and_then(|change| RepositoryChanges::new([change])))
    }

    fn file_change(&self, wt_path: &Path) -> Result<Option<FileChange>, ModifyGuardError> {
        let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
        match &wt_path {
            WorktreeRelativePath::Existing(wt_path) => {
                let fs_path = self.worktree.join(wt_path);
                util::ensure_path_is_file(&fs_path)?;
            }
            WorktreeRelativePath::Missing(_) => {}
        }
        self.query_file_change(wt_path)
    }
}

impl Libgit2Repository {
    fn collect_dir_changes(
        &self,
        wt_path: Option<&WorktreeRelativePath>,
    ) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
        let mut repo_opts = git2::StatusOptions::new();
        if let Some(wt_path) = wt_path {
            repo_opts.pathspec(wt_path.as_path());
            repo_opts.disable_pathspec_match(true);
        }
        repo_opts.include_untracked(true);
        repo_opts.recurse_untracked_dirs(true);
        let entries =
            self.repo
                .statuses(Some(&mut repo_opts))
                .context(QueryRepositoryChangesSnafu {
                    worktree: &self.worktree,
                })?;
        let mut file_entries = entries
            .iter()
            .filter_map(|entry| {
                // Like `cargo fix`, aggregate queries ignore paths that cannot
                // be represented on this platform instead of failing the whole
                // query.
                let wt_path = util::bytes_to_os_str(entry.path_bytes()).ok()?;
                StatusFlags::from(entry.status()).build(wt_path)
            })
            .peekable();

        if file_entries.peek().is_none()
            && let Some(WorktreeRelativePath::Missing(wt_path)) = &wt_path
        {
            return Err(error::PathNotFoundSnafu { path: wt_path }.build());
        }

        Ok(RepositoryChanges::new(file_entries))
    }

    fn query_file_change(
        &self,
        wt_path: WorktreeRelativePath,
    ) -> Result<Option<FileChange>, ModifyGuardError> {
        let status = match self.repo.status_file(wt_path.as_path()) {
            Ok(status) => status,
            Err(source) if source.code() == git2::ErrorCode::Ambiguous => {
                return Err(error::AmbiguousFilePathSnafu { wt_path }.build());
            }
            Err(source) if source.code() == git2::ErrorCode::NotFound => {
                match &wt_path {
                    WorktreeRelativePath::Existing(wt_path) => {
                        // At this point `wt_path` has already been resolved to an
                        // existing file within the worktree, so `NotFound` means the
                        // file is untracked by Git rather than missing from disk.
                        return Ok(StatusFlags {
                            dirty: true,
                            staged: false,
                        }
                        .build(wt_path));
                    }
                    WorktreeRelativePath::Missing(wt_path) => {
                        return Err(error::PathNotFoundSnafu { path: wt_path }.build());
                    }
                }
            }
            Err(source) => {
                return Err(QueryFileChangeSnafu { wt_path }.into_error(source).into());
            }
        };
        Ok(StatusFlags::from(status).build(wt_path))
    }
}

#[derive(Debug, Clone, Copy)]
struct StatusFlags {
    dirty: bool,
    staged: bool,
}

impl From<git2::Status> for StatusFlags {
    fn from(status: git2::Status) -> Self {
        let dirty = status.is_conflicted()
            || status.is_wt_new()
            || status.is_wt_modified()
            || status.is_wt_deleted()
            || status.is_wt_renamed()
            || status.is_wt_typechange();
        let staged = status.is_conflicted()
            || status.is_index_new()
            || status.is_index_modified()
            || status.is_index_deleted()
            || status.is_index_renamed()
            || status.is_index_typechange();
        Self { dirty, staged }
    }
}

impl StatusFlags {
    fn build<P>(self, wt_path: P) -> Option<FileChange>
    where
        P: Into<PathBuf>,
    {
        let Self { dirty, staged } = self;
        if !dirty && !staged {
            return None;
        }

        let wt_path = wt_path.into();
        Some(FileChange {
            wt_path,
            dirty,
            staged,
        })
    }
}