a73x

faece054

Add criterion benchmarks for core operations at scale

a73x   2026-03-21 16:19

Benchmarks list_issues, list_patches, walk_events, IssueState::from_ref,
and PatchState::from_ref at various scales (10, 100, 500/1000 items).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/Cargo.lock b/Cargo.lock
index a518e92..736f9df 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -27,6 +27,12 @@ dependencies = [
]

[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"

[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -173,6 +179,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"

[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"

[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -220,6 +232,33 @@ dependencies = [
]

[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
 "ciborium-io",
 "ciborium-ll",
 "serde",
]

[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"

[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
 "ciborium-io",
 "half",
]

[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -329,6 +368,67 @@ dependencies = [
]

[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
 "anes",
 "cast",
 "ciborium",
 "clap",
 "criterion-plot",
 "is-terminal",
 "itertools 0.10.5",
 "num-traits",
 "once_cell",
 "oorandom",
 "plotters",
 "rayon",
 "regex",
 "serde",
 "serde_derive",
 "serde_json",
 "tinytemplate",
 "walkdir",
]

[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
 "cast",
 "itertools 0.10.5",
]

[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
 "crossbeam-epoch",
 "crossbeam-utils",
]

[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
 "crossbeam-utils",
]

[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"

[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -356,6 +456,12 @@ dependencies = [
]

[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"

[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -723,6 +829,7 @@ dependencies = [
 "clap",
 "clap_complete",
 "clap_mangen",
 "criterion",
 "crossterm",
 "dirs",
 "ed25519-dalek",
@@ -752,6 +859,17 @@ dependencies = [
]

[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
 "cfg-if",
 "crunchy",
 "zerocopy",
]

[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -778,6 +896,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"

[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"

[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -956,6 +1080,17 @@ dependencies = [
]

[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
 "hermit-abi",
 "libc",
 "windows-sys 0.61.2",
]

[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -963,6 +1098,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"

[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
 "either",
]

[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
@@ -1251,6 +1395,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"

[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"

[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1424,6 +1574,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"

[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
 "num-traits",
 "plotters-backend",
 "plotters-svg",
 "wasm-bindgen",
 "web-sys",
]

[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"

[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
 "plotters-backend",
]

[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1598,7 +1776,7 @@ dependencies = [
 "compact_str",
 "hashbrown 0.16.1",
 "indoc",
 "itertools",
 "itertools 0.14.0",
 "kasuari",
 "lru",
 "strum",
@@ -1650,7 +1828,7 @@ dependencies = [
 "hashbrown 0.16.1",
 "indoc",
 "instability",
 "itertools",
 "itertools 0.14.0",
 "line-clipping",
 "ratatui-core",
 "strum",
@@ -1660,6 +1838,26 @@ dependencies = [
]

[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
 "either",
 "rayon-core",
]

[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
 "crossbeam-deque",
 "crossbeam-utils",
]

[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1761,6 +1959,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"

[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
 "winapi-util",
]

[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2120,6 +2327,16 @@ dependencies = [
]

[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
 "serde",
 "serde_json",
]

[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2155,7 +2372,7 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
 "itertools",
 "itertools 0.14.0",
 "unicode-segmentation",
 "unicode-width",
]
@@ -2239,6 +2456,16 @@ dependencies = [
]

[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
 "same-file",
 "winapi-util",
]

[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2342,6 +2569,16 @@ dependencies = [
]

[[package]]
name = "web-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
 "js-sys",
 "wasm-bindgen",
]

[[package]]
name = "wezterm-bidi"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2430,6 +2667,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"

[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
 "windows-sys 0.61.2",
]

[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 89855b3..f5a21b4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,3 +26,8 @@ clap_mangen = "0.2"
[dev-dependencies]
tempfile = "3"
proptest = "1"
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "core_ops"
harness = false
diff --git a/benches/core_ops.rs b/benches/core_ops.rs
new file mode 100644
index 0000000..29f5f6d
--- /dev/null
+++ b/benches/core_ops.rs
@@ -0,0 +1,258 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use ed25519_dalek::SigningKey;
use git2::Repository;
use rand_core::OsRng;
use tempfile::TempDir;

use git_collab::dag;
use git_collab::event::{Action, Author, Event};
use git_collab::state::{IssueState, PatchState};

fn test_author() -> Author {
    Author {
        name: "Bench User".to_string(),
        email: "bench@example.com".to_string(),
    }
}

fn test_key() -> SigningKey {
    SigningKey::generate(&mut OsRng)
}

fn now() -> String {
    chrono::Utc::now().to_rfc3339()
}

fn init_repo(dir: &std::path::Path) -> Repository {
    let repo = Repository::init(dir).expect("init repo");
    {
        let mut config = repo.config().unwrap();
        config.set_str("user.name", "Bench User").unwrap();
        config.set_str("user.email", "bench@example.com").unwrap();
    }
    repo
}

/// Create N issues in a repo, each with no comments. Returns the repo and temp dir.
fn setup_issues(n: usize) -> (Repository, TempDir) {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_key();
    let author = test_author();

    for i in 0..n {
        let event = Event {
            timestamp: now(),
            author: author.clone(),
            action: Action::IssueOpen {
                title: format!("Issue {}", i),
                body: format!("Body for issue {}", i),
            },
            clock: 0,
        };
        let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
        let ref_name = format!("refs/collab/issues/{}", oid);
        repo.reference(&ref_name, oid, false, "bench").unwrap();
    }

    (repo, dir)
}

/// Create N patches in a repo. Returns the repo and temp dir.
fn setup_patches(n: usize) -> (Repository, TempDir) {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_key();
    let author = test_author();

    for i in 0..n {
        let event = Event {
            timestamp: now(),
            author: author.clone(),
            action: Action::PatchCreate {
                title: format!("Patch {}", i),
                body: format!("Body for patch {}", i),
                base_ref: "main".to_string(),
                branch: format!("feature-{}", i),
                fixes: None,
            },
            clock: 0,
        };
        let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
        let ref_name = format!("refs/collab/patches/{}", oid);
        repo.reference(&ref_name, oid, false, "bench").unwrap();
    }

    (repo, dir)
}

/// Create a single issue with N comment events appended. Returns (repo, ref_name, id, tempdir).
fn setup_issue_with_comments(n: usize) -> (Repository, String, String, TempDir) {
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_key();
    let author = test_author();

    let open_event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueOpen {
            title: "Big issue".to_string(),
            body: "An issue with many comments".to_string(),
        },
        clock: 0,
    };
    let oid = dag::create_root_event(&repo, &open_event, &sk).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "bench").unwrap();

    for i in 0..n {
        let event = Event {
            timestamp: now(),
            author: author.clone(),
            action: Action::IssueComment {
                body: format!("Comment number {}", i),
            },
            clock: 0,
        };
        dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
    }

    (repo, ref_name, id, dir)
}

fn bench_list_issues(c: &mut Criterion) {
    let mut group = c.benchmark_group("list_issues");
    for count in [10, 100, 1000] {
        let (repo, _dir) = setup_issues(count);
        group.bench_with_input(
            BenchmarkId::from_parameter(count),
            &count,
            |b, _| {
                b.iter(|| {
                    let issues = git_collab::state::list_issues(&repo).unwrap();
                    assert_eq!(issues.len(), count);
                });
            },
        );
    }
    group.finish();
}

fn bench_list_patches(c: &mut Criterion) {
    let mut group = c.benchmark_group("list_patches");
    for count in [10, 100, 1000] {
        let (repo, _dir) = setup_patches(count);
        group.bench_with_input(
            BenchmarkId::from_parameter(count),
            &count,
            |b, _| {
                b.iter(|| {
                    let patches = git_collab::state::list_patches(&repo).unwrap();
                    assert_eq!(patches.len(), count);
                });
            },
        );
    }
    group.finish();
}

fn bench_walk_events(c: &mut Criterion) {
    let mut group = c.benchmark_group("walk_events");
    for count in [10, 100, 500] {
        let (repo, ref_name, _id, _dir) = setup_issue_with_comments(count);
        group.bench_with_input(
            BenchmarkId::from_parameter(count),
            &count,
            |b, _| {
                b.iter(|| {
                    let events = dag::walk_events(&repo, &ref_name).unwrap();
                    // 1 open + N comments
                    assert_eq!(events.len(), count + 1);
                });
            },
        );
    }
    group.finish();
}

fn bench_issue_from_ref(c: &mut Criterion) {
    let mut group = c.benchmark_group("issue_from_ref");
    for count in [10, 100, 500] {
        let (repo, ref_name, id, _dir) = setup_issue_with_comments(count);
        group.bench_with_input(
            BenchmarkId::from_parameter(count),
            &count,
            |b, _| {
                b.iter(|| {
                    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
                    assert_eq!(state.comments.len(), count);
                });
            },
        );
    }
    group.finish();
}

fn bench_patch_from_ref(c: &mut Criterion) {
    let mut group = c.benchmark_group("patch_from_ref");
    let dir = TempDir::new().unwrap();
    let repo = init_repo(dir.path());
    let sk = test_key();
    let author = test_author();

    // Create a patch with many comments
    for count in [10, 100, 500] {
        let create_event = Event {
            timestamp: now(),
            author: author.clone(),
            action: Action::PatchCreate {
                title: format!("Patch with {} comments", count),
                body: "A patch".to_string(),
                base_ref: "main".to_string(),
                branch: format!("feature-bench-{}", count),
                fixes: None,
            },
            clock: 0,
        };
        let oid = dag::create_root_event(&repo, &create_event, &sk).unwrap();
        let id = oid.to_string();
        let ref_name = format!("refs/collab/patches/{}", id);
        repo.reference(&ref_name, oid, false, "bench").unwrap();

        for i in 0..count {
            let event = Event {
                timestamp: now(),
                author: author.clone(),
                action: Action::PatchComment {
                    body: format!("Patch comment {}", i),
                },
                clock: 0,
            };
            dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
        }

        group.bench_with_input(
            BenchmarkId::from_parameter(count),
            &count,
            |b, _| {
                b.iter(|| {
                    let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
                    assert_eq!(state.comments.len(), count);
                });
            },
        );
    }
    group.finish();
}

criterion_group!(
    benches,
    bench_list_issues,
    bench_list_patches,
    bench_walk_events,
    bench_issue_from_ref,
    bench_patch_from_ref,
);
criterion_main!(benches);