From 23949f14c962184609c472b657f2e23e7d426309 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 31 May 2026 12:27:25 -0400 Subject: [PATCH] M1 R7: rev_list_count via gix reachable-set diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/repo/rev_list_count.rs — `rev_list_count(path, range_spec) -> usize` counts commits like `git rev-list --count`: a two-dot "A..B" range (commits in B not A, via reachable-set diff) or a single rev (all ancestors); soft-fails to 0. Registered + PyO3 wrapper. Parity-tested vs `git rev-list --count` for both forms plus the soft-fail case. 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 | 7 ++- src/repo/mod.rs | 3 ++ src/repo/rev_list_count.rs | 99 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 src/repo/rev_list_count.rs diff --git a/src/python.rs b/src/python.rs index c72af9c..e700fec 100644 --- a/src/python.rs +++ b/src/python.rs @@ -88,8 +88,11 @@ fn ahead_behind(path: String, upstream: String) -> PyResult<(usize, usize)> { } #[pyfunction] -fn rev_list_count(_path: String, _range_spec: String) -> PyResult { - todo!("repo::rev_list_count (soft-fail 0)") +fn rev_list_count(path: String, range_spec: String) -> PyResult { + Ok(crate::repo::rev_list_count( + std::path::Path::new(&path), + &range_spec, + )) } #[pyfunction] diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 0c01e35..89836bd 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -38,6 +38,9 @@ pub use remote_head_sha::remote_head_sha; mod ahead_behind; pub use ahead_behind::ahead_behind; +mod rev_list_count; +pub use rev_list_count::rev_list_count; + /// 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/rev_list_count.rs b/src/repo/rev_list_count.rs new file mode 100644 index 0000000..38a93a8 --- /dev/null +++ b/src/repo/rev_list_count.rs @@ -0,0 +1,99 @@ +use std::collections::HashSet; +use std::path::Path; + +/// Count commits in a revision range, like `git rev-list --count `. +/// Supports a two-dot range "A..B" (commits reachable from B but not A) and a +/// single revision "X" (all commits reachable from X). Soft-fails to 0 on any +/// parse/lookup/open error, matching the tool's current behaviour. +pub fn rev_list_count(path: &Path, range_spec: &str) -> usize { + let Ok(repo) = gix::open(path) else { return 0 }; + let reachable = |rev: &str| -> Option> { + let id = repo.rev_parse_single(rev).ok()?.detach(); + Some( + repo.rev_walk([id]) + .all() + .ok()? + .filter_map(|info| info.ok()) + .map(|info| info.id) + .collect(), + ) + }; + if let Some((a, b)) = range_spec.split_once("..") { + match (reachable(a), reachable(b)) { + (Some(sa), Some(sb)) => sb.difference(&sa).count(), + _ => 0, + } + } else { + reachable(range_spec).map(|s| s.len()).unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repo::fixtures; + + #[test] + fn single_rev_count() { + let td = fixtures::repo(); + let p = td.path(); + + // Add 2 more commits + fixtures::write(p, "file1.txt", "content1"); + fixtures::git(p, &["add", "file1.txt"]); + fixtures::git(p, &["commit", "-q", "-m", "second commit"]); + fixtures::write(p, "file2.txt", "content2"); + fixtures::git(p, &["add", "file2.txt"]); + fixtures::git(p, &["commit", "-q", "-m", "third commit"]); + + // Assert count is 3 + assert_eq!(rev_list_count(p, "HEAD"), 3); + + // Parity assertion against git rev-list --count + let expected: usize = fixtures::git(p, &["rev-list", "--count", "HEAD"]) + .parse() + .unwrap(); + assert_eq!(rev_list_count(p, "HEAD"), expected); + } + + #[test] + fn two_dot_range_count() { + let td = fixtures::repo(); + let p = td.path(); + + // Set up a real bare remote and push main so origin/main == HEAD + let remote = tempfile::tempdir().unwrap(); + fixtures::git(remote.path(), &["init", "--bare", "-q", "-b", "main"]); + fixtures::git( + p, + &["remote", "add", "origin", &remote.path().to_string_lossy()], + ); + fixtures::git(p, &["push", "-q", "-u", "origin", "main"]); + + // Add 2 local commits without pushing + fixtures::write(p, "file1.txt", "content1"); + fixtures::git(p, &["add", "file1.txt"]); + fixtures::git(p, &["commit", "-q", "-m", "second commit"]); + fixtures::write(p, "file2.txt", "content2"); + fixtures::git(p, &["add", "file2.txt"]); + fixtures::git(p, &["commit", "-q", "-m", "third commit"]); + + // Assert count is 2 + assert_eq!(rev_list_count(p, "origin/main..HEAD"), 2); + + // Parity assertion against git rev-list --count + let expected: usize = fixtures::git(p, &["rev-list", "--count", "origin/main..HEAD"]) + .parse() + .unwrap(); + assert_eq!(rev_list_count(p, "origin/main..HEAD"), expected); + } + + #[test] + fn soft_fail_on_nonexistent_rev() { + let td = fixtures::repo(); + let p = td.path(); + + // Assert count is 0 for non-existent rev + assert_eq!(rev_list_count(p, "nonexistent-rev"), 0); + } +}