Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
todo!("repo::rev_list_count (soft-fail 0)")
fn rev_list_count(path: String, range_spec: String) -> PyResult<usize> {
Ok(crate::repo::rev_list_count(
std::path::Path::new(&path),
&range_spec,
))
}

#[pyfunction]
Expand Down
3 changes: 3 additions & 0 deletions src/repo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions src/repo/rev_list_count.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use std::collections::HashSet;
use std::path::Path;

/// Count commits in a revision range, like `git rev-list --count <range_spec>`.
/// 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<HashSet<gix::ObjectId>> {
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);
}
}
Loading