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;
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum Libgit2BackendError {
#[snafu(display("failed while searching for a git repository at or above path: {}", path.display()))]
Discover {
path: PathBuf,
source: git2::Error,
},
#[snafu(display("failed to open git repository at path: {}", path.display()))]
Open {
path: PathBuf,
source: git2::Error,
},
#[snafu(display("failed to query git repository changes for worktree: {}", worktree.display()))]
QueryRepositoryChanges {
worktree: PathBuf,
source: git2::Error,
},
#[snafu(display("failed to query git file change for worktree-relative path: {}", wt_path.display()))]
QueryFileChange {
wt_path: PathBuf,
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| {
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) => {
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,
})
}
}