diff --git a/src/python.rs b/src/python.rs index ceaa0ac..b44c5c8 100644 --- a/src/python.rs +++ b/src/python.rs @@ -52,8 +52,8 @@ fn is_git_repo(path: String) -> PyResult { } #[pyfunction] -fn is_clean(_path: String) -> PyResult { - todo!("repo::is_clean (gix status, empty)") +fn is_clean(path: String) -> PyResult { + Ok(crate::repo::is_clean(std::path::Path::new(&path))?) } #[pyfunction] diff --git a/src/repo/is_clean.rs b/src/repo/is_clean.rs new file mode 100644 index 0000000..52284c1 --- /dev/null +++ b/src/repo/is_clean.rs @@ -0,0 +1,62 @@ +use crate::error::GitxtendError; +use crate::repo::Result; +use std::path::Path; + +/// True iff the working tree is clean — no staged, modified, or untracked +/// entries — i.e. `git status --porcelain` would be empty. +pub fn is_clean(path: &Path) -> Result { + let repo = gix::open(path).map_err(GitxtendError::from_err)?; + let mut iter = repo + .status(gix::progress::Discard) + .map_err(GitxtendError::from_err)? + .untracked_files(gix::status::UntrackedFiles::Files) + .into_iter(Vec::::new()) + .map_err(GitxtendError::from_err)?; + // clean == the status iterator yields no entries (staged, modified, or untracked) + Ok(iter.next().is_none()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repo::fixtures::{self, repo, write}; + + fn porcelain_clean(p: &std::path::Path) -> bool { + fixtures::git(p, &["status", "--porcelain"]).is_empty() + } + + #[test] + fn test_is_clean_fresh_repo() { + let td = repo(); + assert!(is_clean(td.path()).unwrap()); + assert_eq!(is_clean(td.path()).unwrap(), porcelain_clean(td.path())); + } + + #[test] + fn test_is_clean_untracked_file() { + let td = repo(); + write(td.path(), "new.txt", "x"); + assert!(!is_clean(td.path()).unwrap()); + assert_eq!(is_clean(td.path()).unwrap(), porcelain_clean(td.path())); + } + + #[test] + fn test_is_clean_modified_tracked_file() { + let td = repo(); + write(td.path(), "README.md", "orig"); + fixtures::git(td.path(), &["add", "README.md"]); + fixtures::git(td.path(), &["commit", "-m", "Add README"]); + write(td.path(), "README.md", "Modified content"); + assert!(!is_clean(td.path()).unwrap()); + assert_eq!(is_clean(td.path()).unwrap(), porcelain_clean(td.path())); + } + + #[test] + fn test_is_clean_staged_change() { + let td = repo(); + write(td.path(), "new.txt", "x"); + fixtures::git(td.path(), &["add", "new.txt"]); + assert!(!is_clean(td.path()).unwrap()); + assert_eq!(is_clean(td.path()).unwrap(), porcelain_clean(td.path())); + } +} diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 34701cc..a4284ad 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -44,6 +44,9 @@ pub use rev_list_count::rev_list_count; mod log_subjects; pub use log_subjects::log_subjects; +mod is_clean; +pub use is_clean::is_clean; + /// Temp-dir git fixtures shared by the per-method parity tests. /// /// Fixtures are built with the real `git` CLI, so each parity test asserts