From 6ef3abb9c63d631409c229856e568e171d19429d Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 31 May 2026 13:39:27 -0400 Subject: [PATCH] M1 R10: status_counts (modified, untracked) via gix status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/repo/status_counts.rs — `status_counts(path) -> (usize, usize)` returns (modified, untracked) like `git status --porcelain` (`??`=untracked, else modified), classifying gix status items (`IndexWorktree(DirectoryContents)`=untracked, else modified); soft-fails to (0,0). Registered + PyO3 wrapper. Parity-tested vs porcelain on clean / untracked / modified / staged+untracked. PROVENANCE: implementation + tests written by the local model qwen2.5-coder:32b, driven headlessly through `newt worker` (newt-agent's ACP worker) by the pilot, which applied the module registration + PyO3 wrapper wiring and any clippy fixups. Co-Authored-By: qwen2.5-coder:32b Model: qwen2.5-coder:32b Piloted-by: newt-agent Co-Authored-By: Claude Opus 4.8 (1M context) --- src/python.rs | 4 +- src/repo/mod.rs | 3 ++ src/repo/status_counts.rs | 87 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/repo/status_counts.rs diff --git a/src/python.rs b/src/python.rs index b44c5c8..c663e26 100644 --- a/src/python.rs +++ b/src/python.rs @@ -116,8 +116,8 @@ fn last_commit_date(_path: String) -> PyResult> { } #[pyfunction] -fn status_counts(_path: String) -> PyResult<(usize, usize)> { - todo!("repo::status_counts (modified, untracked)") +fn status_counts(path: String) -> PyResult<(usize, usize)> { + Ok(crate::repo::status_counts(std::path::Path::new(&path))) } #[pyfunction] diff --git a/src/repo/mod.rs b/src/repo/mod.rs index a4284ad..3fffebe 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -47,6 +47,9 @@ pub use log_subjects::log_subjects; mod is_clean; pub use is_clean::is_clean; +mod status_counts; +pub use status_counts::status_counts; + /// Temp-dir git fixtures shared by the per-method parity tests. /// /// Fixtures are built with the real `git` CLI, so each parity test asserts diff --git a/src/repo/status_counts.rs b/src/repo/status_counts.rs new file mode 100644 index 0000000..ca3a937 --- /dev/null +++ b/src/repo/status_counts.rs @@ -0,0 +1,87 @@ +use std::path::Path; + +/// (modified, untracked) counts, matching `git status --porcelain`: lines +/// starting with `??` are untracked; every other non-empty entry (staged or +/// unstaged change, rename, delete) is modified. Soft-fails to (0, 0). +pub fn status_counts(path: &Path) -> (usize, usize) { + let Ok(repo) = gix::open(path) else { + return (0, 0); + }; + let platform = match repo.status(gix::progress::Discard) { + Ok(p) => p, + Err(_) => return (0, 0), + }; + let iter = match platform + .untracked_files(gix::status::UntrackedFiles::Files) + .into_iter(Vec::::new()) + { + Ok(i) => i, + Err(_) => return (0, 0), + }; + let mut modified = 0usize; + let mut untracked = 0usize; + for item in iter { + let Ok(item) = item else { continue }; + match item { + gix::status::Item::IndexWorktree( + gix::status::index_worktree::Item::DirectoryContents { .. }, + ) => untracked += 1, + _ => modified += 1, + } + } + (modified, untracked) +} + +#[cfg(test)] +mod tests { + use crate::repo::fixtures; + + fn porcelain_counts(p: &std::path::Path) -> (usize, usize) { + let out = fixtures::git(p, &["status", "--porcelain"]); + let mut m = 0usize; + let mut u = 0usize; + for line in out.lines() { + if line.is_empty() { + continue; + } + if line.starts_with("??") { + u += 1; + } else { + m += 1; + } + } + (m, u) + } + + #[test] + fn clean_repo() { + let td = fixtures::repo(); + assert_eq!(super::status_counts(td.path()), porcelain_counts(td.path())); + } + + #[test] + fn one_untracked_file() { + let td = fixtures::repo(); + fixtures::write(td.path(), "a.txt", "x"); + assert_eq!(super::status_counts(td.path()), porcelain_counts(td.path())); + } + + #[test] + fn one_modified_tracked_file() { + let td = fixtures::repo(); + fixtures::write(td.path(), "t.txt", "initial"); + fixtures::git(td.path(), &["add", "t.txt"]); + fixtures::git(td.path(), &["commit", "-q", "-m", "Add t.txt"]); + fixtures::write(td.path(), "t.txt", "modified"); + assert_eq!(super::status_counts(td.path()), porcelain_counts(td.path())); + } + + #[test] + fn one_staged_new_file_and_one_untracked_file() { + let td = fixtures::repo(); + fixtures::write(td.path(), "a.txt", "x"); + fixtures::git(td.path(), &["add", "a.txt"]); + fixtures::write(td.path(), "b.txt", "y"); + assert_eq!(super::status_counts(td.path()), porcelain_counts(td.path())); + } +}