diff options
-rw-r--r-- | Cargo.lock | 78 | ||||
-rw-r--r-- | lib/libgfold/Cargo.toml | 2 | ||||
-rw-r--r-- | lib/libgfold/src/collector.rs | 22 | ||||
-rw-r--r-- | lib/libgfold/src/collector/target.rs | 4 | ||||
-rw-r--r-- | lib/libgfold/src/lib.rs | 44 | ||||
-rw-r--r-- | lib/libgfold/src/repository_view.rs | 128 | ||||
-rw-r--r-- | lib/libgfold/src/repository_view/submodule_view.rs | 16 | ||||
-rw-r--r-- | lib/libgfold/src/status.rs | 111 |
8 files changed, 246 insertions, 159 deletions
@@ -34,15 +34,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" +checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" [[package]] name = "anstyle-parse" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" dependencies = [ "utf8parse", ] @@ -119,9 +119,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.3.3" +version = "4.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8f255e4b8027970e78db75e78831229c9815fdbfa67eb1a1b777a62e24b4a0" +checksum = "d9394150f5b4273a1763355bd1c2ec54cc5a2593f790587bcd6b2c947cfa9211" dependencies = [ "clap_builder", "clap_derive", @@ -130,9 +130,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.3.3" +version = "4.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acd4f3c17c83b0ba34ffbc4f8bbd74f079413f747f84a6f89292f138057e36ab" +checksum = "9a78fbdd3cc2914ddf37ba444114bc7765bbdcb55ec9cbe6fa054f0137400717" dependencies = [ "anstream", "anstyle", @@ -150,7 +150,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.22", ] [[package]] @@ -262,6 +262,12 @@ dependencies = [ ] [[package]] +name = "equivalent" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1" + +[[package]] name = "errno" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -330,9 +336,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.27.2" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0a93d233ebf96623465aad4046a8d3aa4da22d4f4beba5388838c8a434bbb4" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" [[package]] name = "git2" @@ -349,9 +355,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.12.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" [[package]] name = "heck" @@ -392,11 +398,11 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.9.3" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ - "autocfg", + "equivalent", "hashbrown", ] @@ -449,9 +455,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.146" +version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" [[package]] name = "libgfold" @@ -594,9 +600,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.60" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406" +checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" dependencies = [ "unicode-ident", ] @@ -716,14 +722,14 @@ checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.22", ] [[package]] name = "serde_json" -version = "1.0.96" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3" dependencies = [ "itoa", "ryu", @@ -732,9 +738,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93107647184f6027e3b7dcb2e11034cf95ffa1e3a682c67951963ac69c1c007d" +checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" dependencies = [ "serde", ] @@ -780,9 +786,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.18" +version = "2.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +checksum = "2efbeae7acf4eabd6bcdcbd11c92f45231ddda7539edc7806bd1a04a03b24616" dependencies = [ "proc-macro2", "quote", @@ -829,7 +835,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.18", + "syn 2.0.22", ] [[package]] @@ -849,9 +855,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "toml" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6135d499e69981f9ff0ef2167955a5333c35e36f6937d382974566b3d5b94ec" +checksum = "1ebafdf5ad1220cb59e7d17cf4d2c72015297b75b19a10472f99b89225089240" dependencies = [ "serde", "serde_spanned", @@ -861,18 +867,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a76a9312f5ba4c2dec6b9161fdf25d87ad8a09256ccea5a556fef03c706a10f" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.19.10" +version = "0.19.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380d56e8670370eee6566b0bfd4265f65b3f432e8c6d85623f728d4fa31f739" +checksum = "266f016b7f039eec8a1a80dfe6156b633d208b9fccca5e4db1d6775b0c4e34a7" dependencies = [ "indexmap", "serde", @@ -1030,9 +1036,9 @@ checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" [[package]] name = "winnow" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699" +checksum = "ca0ace3845f0d96209f0375e6d367e3eb87eb65d27d445bdc9f1843a26f39448" dependencies = [ "memchr", ] diff --git a/lib/libgfold/Cargo.toml b/lib/libgfold/Cargo.toml index 7b1c183..ba1a158 100644 --- a/lib/libgfold/Cargo.toml +++ b/lib/libgfold/Cargo.toml @@ -15,7 +15,6 @@ readme = "../../README.md" repository = "https://github.com/nickgerace/gfold/" [dependencies] -anyhow = { workspace = true } log = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } @@ -24,6 +23,7 @@ git2 = { version = "0.17", default_features = false } rayon = "1.7" [dev-dependencies] +anyhow = { workspace = true } env_logger = { version = "0.10", features = ["humantime"], default_features = false } pretty_assertions = "1.3" tempfile = "3.5" diff --git a/lib/libgfold/src/collector.rs b/lib/libgfold/src/collector.rs index b80066e..ca80682 100644 --- a/lib/libgfold/src/collector.rs +++ b/lib/libgfold/src/collector.rs @@ -4,11 +4,24 @@ use rayon::prelude::*; use std::collections::BTreeMap; use std::path::Path; use target::TargetCollector; +use thiserror::Error; -use crate::repository_view::RepositoryView; +use crate::repository_view::{RepositoryView, RepositoryViewError, RepositoryViewResult}; mod target; +#[allow(missing_docs)] +#[derive(Error, Debug)] +pub enum CollectorError { + #[error(transparent)] + FromRepositoryView(#[from] RepositoryViewError), + #[error(transparent)] + FromStdIo(#[from] std::io::Error), +} + +/// The result type used when multiple kinds of errors can be encountered during collection. +pub type CollectorResult<T> = Result<T, CollectorError>; + /// This type represents a [`BTreeMap`] using an optional [`String`] for keys, which represents the /// parent directory for a group of reports ([`Vec<RepositoryView>`]). The values corresponding to those keys /// are the actual groups of reports. @@ -17,9 +30,10 @@ mod target; /// sorted keys. pub type RepositoryCollection = BTreeMap<Option<String>, Vec<RepositoryView>>; -type UnprocessedRepositoryView = anyhow::Result<RepositoryView>; +type UnprocessedRepositoryView = RepositoryViewResult<RepositoryView>; /// A unit struct that provides [`Self::run()`], which is used to generated [`RepositoryCollection`]. +#[derive(Debug)] pub struct RepositoryCollector; impl RepositoryCollector { @@ -28,7 +42,7 @@ impl RepositoryCollector { path: &Path, include_email: bool, include_submodules: bool, - ) -> anyhow::Result<RepositoryCollection> { + ) -> CollectorResult<RepositoryCollection> { let unprocessed = TargetCollector::run(path.to_path_buf())? .par_iter() .map(|path| RepositoryView::new(path, include_email, include_submodules)) @@ -45,7 +59,7 @@ impl RepositoryCollector { processed.insert(view.parent, views); } } - Err(e) => return Err(e), + Err(e) => return Err(e.into()), } } Ok(processed) diff --git a/lib/libgfold/src/collector/target.rs b/lib/libgfold/src/collector/target.rs index f13219d..6c2537d 100644 --- a/lib/libgfold/src/collector/target.rs +++ b/lib/libgfold/src/collector/target.rs @@ -11,12 +11,12 @@ use std::{fs, io}; type UnprocessedTarget = io::Result<MaybeTarget>; /// A unit struct used to centralizing target collection method(s). -pub struct TargetCollector; +pub(crate) struct TargetCollector; impl TargetCollector { /// Generate targets for a given [`PathBuf`] based on its children (recursively). We use /// recursion paired with [`rayon`] since we prioritize speed over memory use. - pub fn run(path: PathBuf) -> io::Result<Vec<PathBuf>> { + pub(crate) fn run(path: PathBuf) -> io::Result<Vec<PathBuf>> { let entries: Vec<DirEntry> = match fs::read_dir(&path) { Ok(read_dir) => read_dir.filter_map(|r| r.ok()).collect(), Err(e) => { diff --git a/lib/libgfold/src/lib.rs b/lib/libgfold/src/lib.rs index dc47736..ac79603 100644 --- a/lib/libgfold/src/lib.rs +++ b/lib/libgfold/src/lib.rs @@ -1,4 +1,31 @@ -// #![warn(missing_docs, clippy::missing_errors_doc, clippy::missing_panics_doc)] +//! **libgfold** provides the ability to find a minimal set of user-relevant information for git +//! repositories on a local filesystem. +//! +//! This library powers [**gfold**](https://github.com/nickgerace/gfold). + +#![warn( + missing_debug_implementations, + missing_docs, + missing_doc_code_examples, + rust_2018_idioms, + unreachable_pub, + bad_style, + dead_code, + improper_ctypes, + non_shorthand_field_patterns, + no_mangle_generic_items, + overflowing_literals, + path_statements, + patterns_in_fns_without_body, + private_in_public, + unconditional_recursion, + unused, + unused_allocation, + unused_comparisons, + unused_parens, + while_true, + clippy::missing_panics_doc +)] pub mod collector; pub mod repository_view; @@ -13,7 +40,6 @@ pub use status::Status; mod tests { use super::*; - use anyhow::anyhow; use git2::ErrorCode; use git2::Oid; use git2::Repository; @@ -105,7 +131,7 @@ mod tests { let expected_views_key = root .path() .to_str() - .ok_or_else(|| anyhow!("could not convert PathBuf to &str"))? + .expect("could not convert PathBuf to &str") .to_string(); let mut expected_views = vec![ RepositoryView::finalize( @@ -139,7 +165,7 @@ mod tests { // Add nested views to the expected collection. let nested_expected_views_key = nested .to_str() - .ok_or_else(|| anyhow!("could not convert PathBuf to &str"))? + .expect("could not convert PathBuf to &str") .to_string(); let mut nested_expected_views_raw = vec![ RepositoryView::finalize( @@ -195,23 +221,19 @@ mod tests { Ok(()) } - fn create_directory<P: AsRef<Path>>(parent: P, name: &str) -> anyhow::Result<PathBuf> { + fn create_directory<P: AsRef<Path>>(parent: P, name: &str) -> io::Result<PathBuf> { let parent = parent.as_ref(); let new_directory = parent.join(name); if let Err(e) = fs::create_dir(&new_directory) { if e.kind() != io::ErrorKind::AlreadyExists { - return Err(anyhow!( - "could not create directory ({:?}) due to error kind: {:?}", - &new_directory, - e.kind() - )); + return Err(e); } } Ok(new_directory) } - fn create_file<P: AsRef<Path>>(parent: P) -> anyhow::Result<()> { + fn create_file<P: AsRef<Path>>(parent: P) -> io::Result<()> { let parent = parent.as_ref(); File::create(parent.join("file"))?; Ok(()) diff --git a/lib/libgfold/src/repository_view.rs b/lib/libgfold/src/repository_view.rs index 64cf3f3..2057725 100644 --- a/lib/libgfold/src/repository_view.rs +++ b/lib/libgfold/src/repository_view.rs @@ -1,16 +1,30 @@ -use git2::{ErrorCode, Reference, Remote, Repository, StatusOptions}; +//! This module contains [`RepositoryView`], which provides the [`Status`](crate::status::Status) +//! and general overview of the state of a given Git repository. + +use git2::Repository; use log::{debug, error, trace}; use serde::{Deserialize, Serialize}; +use std::io; use std::path::{Path, PathBuf}; use submodule_view::SubmoduleView; use thiserror::Error; -use crate::status::Status; +use crate::repository_view::submodule_view::SubmoduleError; +use crate::status::{Status, StatusError}; mod submodule_view; +#[allow(missing_docs)] #[derive(Error, Debug)] pub enum RepositoryViewError { + #[error(transparent)] + FromGit2(#[from] git2::Error), + #[error(transparent)] + FromStdIo(#[from] io::Error), + #[error(transparent)] + FromSubmodule(#[from] SubmoduleError), + #[error(transparent)] + FromStatus(#[from] StatusError), #[error("received None (Option<&OsStr>) for file name: {0}")] FileNameNotFound(PathBuf), #[error("could not convert file name (&OsStr) to &str: {0}")] @@ -21,6 +35,9 @@ pub enum RepositoryViewError { PathToStrConversionFailure(PathBuf), } +#[allow(missing_docs)] +pub type RepositoryViewResult<T> = Result<T, RepositoryViewError>; + /// A collection of results for a Git repository at a given path. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct RepositoryView { @@ -36,7 +53,9 @@ pub struct RepositoryView { /// The remote origin URL. The value will be `None` if the URL cannot be found. pub url: Option<String>, + /// The email used in either the local or global config for the repository. pub email: Option<String>, + /// Views of submodules found within the repository. pub submodules: Vec<SubmoduleView>, } @@ -46,7 +65,7 @@ impl RepositoryView { repo_path: &Path, include_email: bool, include_submodules: bool, - ) -> anyhow::Result<RepositoryView> { + ) -> RepositoryViewResult<RepositoryView> { debug!( "attempting to generate collector for repository_view at path: {:?}", repo_path @@ -68,7 +87,7 @@ impl RepositoryView { } Err(e) => return Err(e.into()), }; - let (status, head, remote) = RepositoryView::find_status(&repo)?; + let (status, head, remote) = Status::find(&repo)?; let submodules = if include_submodules { SubmoduleView::list(&repo)? @@ -97,16 +116,17 @@ impl RepositoryView { "finalized collector collection for repository_view at path: {:?}", repo_path ); - Ok(RepositoryView::finalize( + RepositoryView::finalize( repo_path, Some(branch.to_string()), status, url, email, submodules, - )?) + ) } + /// Assemble a [`RepositoryView`] with metadata for a given repository. pub fn finalize( path: &Path, branch: Option<String>, @@ -121,7 +141,7 @@ impl RepositoryView { None => { return Err(RepositoryViewError::FileNameToStrConversionFailure( path.to_path_buf(), - )) + )); } }, None => return Err(RepositoryViewError::FileNameNotFound(path.to_path_buf())), @@ -132,7 +152,7 @@ impl RepositoryView { None => { return Err(RepositoryViewError::PathToStrConversionFailure( s.to_path_buf(), - )) + )); } }, None => None, @@ -153,98 +173,6 @@ impl RepositoryView { }) } - /// Find the [`Status`] for a given [`Repository`](git2::Repository). The - /// [`head`](Option<git2::Reference>) and [`remote`](Option<git2::Remote>) are also returned. - pub fn find_status( - repo: &Repository, - ) -> anyhow::Result<(Status, Option<Reference>, Option<Remote>)> { - let head = match repo.head() { - Ok(head) => Some(head), - Err(ref e) - if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => - { - None - } - Err(e) => return Err(e.into()), - }; - - // Greedily chooses a remote if "origin" is not found. - let (remote, remote_name) = match repo.find_remote("origin") { - Ok(origin) => (Some(origin), Some("origin".to_string())), - Err(e) if e.code() == ErrorCode::NotFound => Self::choose_remote_greedily(repo)?, - Err(e) => return Err(e.into()), - }; - - // We'll include all untracked files and directories in the status options. - let mut opts = StatusOptions::new(); - opts.include_untracked(true).recurse_untracked_dirs(true); - - // If "head" is "None" and statuses are empty, then the repository_view must be clean because there - // are no commits to push. - let status = match repo.statuses(Some(&mut opts)) { - Ok(v) if v.is_empty() => match &head { - Some(head) => match remote_name { - Some(remote_name) => { - match RepositoryView::is_unpushed(repo, head, &remote_name)? { - true => Status::Unpushed, - false => Status::Clean, - } - } - None => Status::Clean, - }, - None => Status::Clean, - }, - Ok(_) => Status::Unclean, - Err(e) if e.code() == ErrorCode::BareRepo => Status::Bare, - Err(e) => return Err(e.into()), - }; - - Ok((status, head, remote)) - } - - fn choose_remote_greedily( - repository: &Repository, - ) -> Result<(Option<Remote>, Option<String>), git2::Error> { - let remotes = repository.remotes()?; - Ok(match remotes.get(0) { - Some(remote_name) => ( - Some(repository.find_remote(remote_name)?), - Some(remote_name.to_string()), - ), - None => (None, None), - }) - } - - /// Checks if local commit(s) on the current branch have not yet been pushed to the remote. - fn is_unpushed( - repo: &Repository, - head: &Reference, - remote_name: &str, - ) -> Result<bool, git2::Error> { - let local_head = head.peel_to_commit()?; - let remote = format!( - "{}/{}", - remote_name, - match head.shorthand() { - Some(v) => v, - None => { - debug!("assuming unpushed; could not determine shorthand for head"); - return Ok(true); - } - } - ); - let remote_head = match repo.resolve_reference_from_short_name(&remote) { - Ok(reference) => reference.peel_to_commit()?, - Err(e) => { - debug!("assuming unpushed; could not resolve remote reference from short name (ignored error: {})", e); - return Ok(true); - } - }; - Ok( - matches!(repo.graph_ahead_behind(local_head.id(), remote_head.id()), Ok(number_unique_commits) if number_unique_commits.0 > 0), - ) - } - /// Find the "user.email" value in the local or global Git config. The /// [`Repository::config()`] method will look for a local config first and fallback to /// global, as needed. Absorb and log any and all errors as the email field is non-critical to diff --git a/lib/libgfold/src/repository_view/submodule_view.rs b/lib/libgfold/src/repository_view/submodule_view.rs index 54f0502..0ec0a0c 100644 --- a/lib/libgfold/src/repository_view/submodule_view.rs +++ b/lib/libgfold/src/repository_view/submodule_view.rs @@ -4,17 +4,25 @@ use git2::Repository; use log::error; use serde::Deserialize; use serde::Serialize; +use std::io; use thiserror::Error; -use crate::repository_view::RepositoryView; -use crate::status::Status; +use crate::status::{Status, StatusError}; #[derive(Error, Debug)] pub enum SubmoduleError { #[error("submodule name is invalid UTF-8")] SubmoduleNameInvalid, + #[error(transparent)] + FromGit2(#[from] git2::Error), + #[error(transparent)] + FromStdIo(#[from] io::Error), + #[error(transparent)] + FromStatus(#[from] StatusError), } +type SubmoduleResult<T> = Result<T, SubmoduleError>; + /// The view of a submodule with a [`Repository`]. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct SubmoduleView { @@ -24,12 +32,12 @@ pub struct SubmoduleView { impl SubmoduleView { /// Generate a list of [`submodule view(s)`](Self) for a given [`Repository`]. - pub fn list(repo: &Repository) -> anyhow::Result<Vec<Self>> { + pub fn list(repo: &Repository) -> SubmoduleResult<Vec<Self>> { let mut submodules = Vec::new(); for submodule in repo.submodules()? { match submodule.open() { Ok(subrepo) => { - let (status, _, _) = RepositoryView::find_status(&subrepo)?; + let (status, _, _) = Status::find(&subrepo)?; let name = submodule .name() .ok_or(SubmoduleError::SubmoduleNameInvalid)?; diff --git a/lib/libgfold/src/status.rs b/lib/libgfold/src/status.rs index e6e104a..7ffd638 100644 --- a/lib/libgfold/src/status.rs +++ b/lib/libgfold/src/status.rs @@ -1,19 +1,38 @@ //! This module contains the [`crate::status::Status`] type. +use git2::{ErrorCode, Reference, Remote, Repository, StatusOptions}; +use log::debug; use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[allow(missing_docs)] +#[derive(Error, Debug)] +pub enum StatusError { + #[error(transparent)] + FromGit2(#[from] git2::Error), +} + +#[allow(missing_docs)] +pub type StatusResult<T> = Result<T, StatusError>; /// A summarized interpretation of the status of a Git working tree. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum Status { + /// Corresponds to a "bare" working tree. Bare, + /// Corresponds to a "clean" working tree. Clean, + /// Corresponds to an "unclean" working tree. Unclean, + /// Provided if the state of the working tree could neither be found nor determined. Unknown, + /// Indicates that there is at least one commit not pushed to the remote from the working tree. Unpushed, } impl Status { - pub fn as_str(&self) -> &str { + /// Converts the enum into a borrowed, static `str`. + pub fn as_str(&self) -> &'static str { match self { Self::Bare => "bare", Self::Clean => "clean", @@ -22,4 +41,94 @@ impl Status { Self::Unpushed => "unpushed", } } + + /// Find the [`Status`] for a given [`Repository`](git2::Repository). The + /// [`head`](Option<git2::Reference>) and [`remote`](Option<git2::Remote>) are also returned. + pub fn find( + repo: &Repository, + ) -> StatusResult<(Status, Option<Reference<'_>>, Option<Remote<'_>>)> { + let head = match repo.head() { + Ok(head) => Some(head), + Err(ref e) + if e.code() == ErrorCode::UnbornBranch || e.code() == ErrorCode::NotFound => + { + None + } + Err(e) => return Err(e.into()), + }; + + // Greedily chooses a remote if "origin" is not found. + let (remote, remote_name) = match repo.find_remote("origin") { + Ok(origin) => (Some(origin), Some("origin".to_string())), + Err(e) if e.code() == ErrorCode::NotFound => Self::choose_remote_greedily(repo)?, + Err(e) => return Err(e.into()), + }; + + // We'll include all untracked files and directories in the status options. + let mut opts = StatusOptions::new(); + opts.include_untracked(true).recurse_untracked_dirs(true); + + // If "head" is "None" and statuses are empty, then the repository_view must be clean because there + // are no commits to push. + let status = match repo.statuses(Some(&mut opts)) { + Ok(v) if v.is_empty() => match &head { + Some(head) => match remote_name { + Some(remote_name) => match Self::is_unpushed(repo, head, &remote_name)? { + true => Status::Unpushed, + false => Status::Clean, + }, + None => Status::Clean, + }, + None => Status::Clean, + }, + Ok(_) => Status::Unclean, + Err(e) if e.code() == ErrorCode::BareRepo => Status::Bare, + Err(e) => return Err(e.into()), + }; + + Ok((status, head, remote)) + } + + // Checks if local commit(s) on the current branch have not yet been pushed to the remote. + fn is_unpushed( + repo: &Repository, + head: &Reference<'_>, + remote_name: &str, + ) -> Result<bool, git2::Error> { + let local_head = head.peel_to_commit()?; + let remote = format!( + "{}/{}", + remote_name, + match head.shorthand() { + Some(v) => v, + None => { + debug!("assuming unpushed; could not determine shorthand for head"); + return Ok(true); + } + } + ); + let remote_head = match repo.resolve_reference_from_short_name(&remote) { + Ok(reference) => reference.peel_to_commit()?, + Err(e) => { + debug!("assuming unpushed; could not resolve remote reference from short name (ignored error: {})", e); + return Ok(true); + } + }; + Ok( + matches!(repo.graph_ahead_behind(local_head.id(), remote_head.id()), Ok(number_unique_commits) if number_unique_commits.0 > 0), + ) + } + + fn choose_remote_greedily( + repository: &Repository, + ) -> Result<(Option<Remote<'_>>, Option<String>), git2::Error> { + let remotes = repository.remotes()?; + Ok(match remotes.get(0) { + Some(remote_name) => ( + Some(repository.find_remote(remote_name)?), + Some(remote_name.to_string()), + ), + None => (None, None), + }) + } } |