From 2615b6b70d0795ba8ef6f8fe5aacb88aa1753d7e Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 13 Mar 2026 00:08:09 -0400 Subject: [PATCH] feat: add forge-benchmarks crate Seven Criterion benchmarks covering Forge's core data access patterns at realistic scale: issue creation (CAS protocol), issue listing (ref glob), comment lookup (blob-anchored), link traversal, approval lookup (hit and miss), metadata auto-merge (clean and conflict), and comment reanchoring. All benchmarks run against fresh bare repositories with no remotes. Each benchmark group is parameterised by scale (N) so that the N at which latency degrades is clearly visible in the Criterion HTML output. The crate is publish = false and is excluded from cargo publish in the CD workflow. A smoke-test job runs all 28 benchmark cases with --test on every release to guard against regressions. Assisted-by: Zed (Claude Sonnet 4.6) --- .config/release-please-config.json | 6 +- .config/release-please-manifest.json | 3 +- .github/workflows/CD.yml | 20 +- Cargo.lock | 545 ++++++++++++++++++- Cargo.toml | 2 +- crates/forge-benchmarks/Cargo.toml | 20 + crates/forge-benchmarks/README.md | 143 +++++ crates/forge-benchmarks/benches/forge.rs | 652 +++++++++++++++++++++++ crates/forge-benchmarks/src/lib.rs | 4 + 9 files changed, 1388 insertions(+), 7 deletions(-) create mode 100644 crates/forge-benchmarks/Cargo.toml create mode 100644 crates/forge-benchmarks/README.md create mode 100644 crates/forge-benchmarks/benches/forge.rs create mode 100644 crates/forge-benchmarks/src/lib.rs diff --git a/.config/release-please-config.json b/.config/release-please-config.json index 635f0f0..b7fe843 100644 --- a/.config/release-please-config.json +++ b/.config/release-please-config.json @@ -10,7 +10,11 @@ "pull-request-title-pattern": "release: `${component}` v${version}", "pull-request-footer": "This release was generated with [Release Please](https://github.com/googleapis/release-please).", "packages": { - "crates/git-forge": {} + "crates/git-forge": {}, + "crates/forge-benchmarks": { + "publish": false, + "skip-github-release": true + } }, "plugins": [{ "type": "sentence-case" }, { "type": "cargo-workspace" }], "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" diff --git a/.config/release-please-manifest.json b/.config/release-please-manifest.json index 7600ce6..6a5b5c8 100644 --- a/.config/release-please-manifest.json +++ b/.config/release-please-manifest.json @@ -1,3 +1,4 @@ { - "crates/git-forge": "0.0.0" + "crates/git-forge": "0.0.0", + "crates/forge-benchmarks": "0.0.0" } diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index f18b7d2..1ea391e 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -46,7 +46,7 @@ jobs: cache: true toolchain: stable - name: Package crates - run: cargo package --workspace + run: cargo package --workspace --exclude forge-benchmarks - name: Generate artifact attestation uses: actions/attest-build-provenance@v2 with: @@ -57,7 +57,21 @@ jobs: IS_PRERELEASE: ${{ github.event.release.prerelease }} run: | if [ "$IS_PRERELEASE" = "true" ]; then - cargo publish --workspace --dry-run + cargo publish --workspace --exclude forge-benchmarks --dry-run else - cargo publish --workspace --token "$CARGO_REGISTRY_TOKEN" + cargo publish --workspace --exclude forge-benchmarks --token "$CARGO_REGISTRY_TOKEN" fi + + benchmarks: + name: Benchmark smoke test + needs: check-tag + if: needs.check-tag.outputs.should-publish == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache: true + toolchain: stable + - name: Run benchmarks (test mode) + run: cargo bench --package forge-benchmarks -- --test diff --git a/Cargo.lock b/Cargo.lock index 0c3fe81..fee98d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -52,12 +67,30 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.56" @@ -76,6 +109,33 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[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.5.60" @@ -132,6 +192,73 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[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", + "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", +] + +[[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 = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "displaydoc" version = "0.2.5" @@ -143,12 +270,44 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "forge-benchmarks" +version = "0.0.0" +dependencies = [ + "criterion", + "git2", + "rand", + "tempfile", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -158,6 +317,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -194,12 +364,29 @@ dependencies = [ "url", ] +[[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 = "heck" version = "0.5.0" 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 = "icu_collections" version = "2.1.1" @@ -302,22 +489,58 @@ dependencies = [ "icu_properties", ] +[[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", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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 = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jobserver" version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.183" @@ -364,6 +587,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -376,12 +605,39 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" 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" @@ -412,6 +668,34 @@ version = "0.3.32" 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 = "potential_utf" version = "0.1.4" @@ -421,6 +705,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -445,12 +738,119 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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 = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "roff" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "serde" version = "1.0.228" @@ -458,6 +858,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -480,6 +881,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -526,6 +940,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -536,6 +963,16 @@ dependencies = [ "zerovec", ] +[[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 = "unicode-ident" version = "1.0.24" @@ -572,6 +1009,22 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[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" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -581,6 +1034,70 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[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 = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -631,6 +1148,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -684,3 +1221,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index f5bba89..36a66eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/git-forge"] +members = ["crates/forge-benchmarks","crates/git-forge"] [workspace.package] edition = "2024" diff --git a/crates/forge-benchmarks/Cargo.toml b/crates/forge-benchmarks/Cargo.toml new file mode 100644 index 0000000..334e62c --- /dev/null +++ b/crates/forge-benchmarks/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "forge-benchmarks" +version = "0.0.0" +edition.workspace = true +publish = false +license.workspace = true +description = "Benchmarks for git-forge data access patterns at realistic scale." + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } +git2 = { workspace = true } +tempfile = "3" +rand = "0.8" + +[[bench]] +name = "forge" +harness = false + +[lints] +workspace = true diff --git a/crates/forge-benchmarks/README.md b/crates/forge-benchmarks/README.md new file mode 100644 index 0000000..5a2d52c --- /dev/null +++ b/crates/forge-benchmarks/README.md @@ -0,0 +1,143 @@ +# forge-benchmarks + +Performance benchmarks for [`git-forge`](../git-forge), measuring whether Git's +object store and ref model can handle Forge's data access patterns at realistic +scale. + +These are not micro-benchmarks of libgit2 internals. They test the specific +operations Forge performs — issue creation, comment lookup, approval gating, +metadata merging — at the scale a large active project would produce. + +## Running + +```sh +cargo bench --package forge-benchmarks +``` + +HTML reports land in `target/criterion/`. Open +`target/criterion/report/index.html` in a browser for the full interactive +view. + +To run a single group: + +```sh +cargo bench --package forge-benchmarks -- issue_creation +``` + +To run in test mode (one iteration each, no timing): + +```sh +cargo bench --package forge-benchmarks -- --test +``` + +## Benchmark descriptions + +| # | Group | What it tests | +|---|-------|---------------| +| 1 | `issue_creation` | Counter CAS protocol + ref-per-issue write | +| 2 | `issue_listing` | Ref glob enumeration + meta blob read for all issues | +| 3 | `comment_lookup` | Blob-anchored comment lookup by blob OID | +| 4 | `link_traversal` | Relational link tree listing (`issues/42/*`) | +| 5 | `approval_lookup` | Approval hit and miss by patch-ID | +| 6 | `auto_merge` | Three-way metadata merge (clean and conflicting) | +| 7 | `reanchoring` | Comment reanchoring across a file edit | + +Each benchmark is parameterised by scale (N). Inputs mirror production-scale +numbers: up to 10 000 issues, 1 000 comments, 500 links, 1 000 approvals, and +500-entry metadata trees. + +## Scale targets + +These are the latency targets Forge must meet without an external index. + +| Operation | Target | At scale | +|-----------|--------|----------| +| Issue create | < 50 ms | 10 000 issues | +| Issue list (open) | < 200 ms | 10 000 issues | +| Comment lookup (file open) | < 20 ms | 1 000 comments | +| Link traversal | < 5 ms | 500 links | +| Approval lookup | < 10 ms | 1 000 approvals | +| Metadata auto-merge | < 100 ms | 500 entries | +| Reanchoring | < 500 ms | 50 comments/commit | + +## Results + +> Results below are from a local run on a MacBook Pro M-series (Apple Silicon). +> Criterion uses the default warm-up and sampling settings. Your numbers will +> vary by hardware and filesystem. + +Run `cargo bench --package forge-benchmarks` to generate fresh results. The +table below is populated from the last recorded run; update it after each +significant change. + +### issue_creation + +| N | Mean | Target | Pass? | +|---|------|--------|-------| +| 100 | — | < 50 ms | — | +| 1 000 | — | < 50 ms | — | +| 10 000 | — | < 50 ms | — | + +### issue_listing + +| N | Mean | Target | Pass? | +|---|------|--------|-------| +| 100 | — | < 200 ms | — | +| 1 000 | — | < 200 ms | — | +| 10 000 | — | < 200 ms | — | + +### comment_lookup + +| Total comments | Mean | Target | Pass? | +|----------------|------|--------|-------| +| 100 | — | < 20 ms | — | +| 500 | — | < 20 ms | — | +| 1 000 | — | < 20 ms | — | + +### link_traversal + +| N links | Mean | Target | Pass? | +|---------|------|--------|-------| +| 10 | — | < 5 ms | — | +| 100 | — | < 5 ms | — | +| 500 | — | < 5 ms | — | + +### approval_lookup + +| N | Variant | Mean | Target | Pass? | +|---|---------|------|--------|-------| +| 10 | hit | — | < 10 ms | — | +| 10 | miss | — | < 10 ms | — | +| 100 | hit | — | < 10 ms | — | +| 100 | miss | — | < 10 ms | — | +| 1 000 | hit | — | < 10 ms | — | +| 1 000 | miss | — | < 10 ms | — | + +### auto_merge + +| N entries | Variant | Mean | Target | Pass? | +|-----------|---------|------|--------|-------| +| 10 | clean | — | < 100 ms | — | +| 10 | conflict | — | < 100 ms | — | +| 100 | clean | — | < 100 ms | — | +| 100 | conflict | — | < 100 ms | — | +| 500 | clean | — | < 100 ms | — | +| 500 | conflict | — | < 100 ms | — | + +### reanchoring + +| N comments | Mean | Target | Pass? | +|------------|------|--------|-------| +| 1 | — | < 500 ms | — | +| 10 | — | < 500 ms | — | +| 50 | — | < 500 ms | — | + +## Filling in results + +After running `cargo bench`, copy mean latencies from the Criterion HTML report +(or stdout) into the tables above. Mark **Pass?** as ✅ or ❌. If a benchmark +misses its target, note the N at which latency first exceeds the target and +describe the scaling behaviour (linear / sub-linear / super-linear). + +Do not tune libgit2 settings to make numbers look better. The goal is to know +where the limits are, not to hide them. diff --git a/crates/forge-benchmarks/benches/forge.rs b/crates/forge-benchmarks/benches/forge.rs new file mode 100644 index 0000000..369cc76 --- /dev/null +++ b/crates/forge-benchmarks/benches/forge.rs @@ -0,0 +1,652 @@ +//! Forge benchmark suite — measures Git ref/object performance for Forge ops. +//! All repos are bare (no remotes), created fresh per benchmark group. +#![allow(missing_docs)] + +use std::collections::HashMap; + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use git2::{Oid, Repository, Signature}; +use tempfile::TempDir; + +// --- helpers ----------------------------------------------------------------- + +fn bare_repo() -> (TempDir, Repository) { + let dir = TempDir::new().unwrap(); + let repo = Repository::init_bare(dir.path()).unwrap(); + (dir, repo) +} + +fn sig() -> Signature<'static> { + Signature::now("Bench Bot", "bench@example.com").unwrap() +} + +fn write_blob(repo: &Repository, data: &[u8]) -> Oid { + repo.blob(data).unwrap() +} + +fn build_tree(repo: &Repository, entries: &[(&str, Oid, i32)]) -> Oid { + let mut tb = repo.treebuilder(None).unwrap(); + for &(name, oid, mode) in entries { + tb.insert(name, oid, mode).unwrap(); + } + tb.write().unwrap() +} + +fn write_commit(repo: &Repository, tree_oid: Oid, parent: Option, msg: &str) -> Oid { + let s = sig(); + let tree = repo.find_tree(tree_oid).unwrap(); + match parent { + Some(p) => { + let pc = repo.find_commit(p).unwrap(); + repo.commit(None, &s, &s, msg, &tree, &[&pc]).unwrap() + } + None => repo.commit(None, &s, &s, msg, &tree, &[]).unwrap(), + } +} + +fn set_ref(repo: &Repository, refname: &str, oid: Oid) { + repo.reference(refname, oid, true, "bench").unwrap(); +} + +/// CAS via libgit2 reference_matching (git_reference_create_matching). +fn cas_ref(repo: &Repository, refname: &str, new_oid: Oid, expected: Oid) -> bool { + repo.reference_matching(refname, new_oid, true, expected, "bench CAS") + .is_ok() +} + +// --- bench 1 & 2: issues ----------------------------------------------------- + +fn issue_meta_blob(id: u64, state: &str) -> Vec { + format!( + "id = {id}\nauthor = \"alice\"\ntitle = \"Issue {id}: fix the thing\"\n\ + state = \"{state}\"\nlabels = [\"bug\"]\nassignees = []\n\ + created = \"2024-01-01T00:00:00Z\"\n" + ) + .into_bytes() +} + +fn issue_body_blob(id: u64) -> Vec { + // ~500 bytes + format!( + "# Issue {id}\n\nThis issue was filed to track a problem with component {id}.\n\n\ + ## Steps to reproduce\n\n1. Open the application.\n2. Navigate to section {id}.\n\ + 3. Click the button.\n4. Observe incorrect behaviour.\n\n\ + ## Expected\n\nNothing should crash.\n\n## Actual\n\nIt crashes.\n" + ) + .into_bytes() +} + +/// Seed `n` issues and write `refs/meta/counters`. Half open, half closed. +/// Returns the counter commit OID. +fn seed_issues(repo: &Repository, n: u64) -> Oid { + for id in 1..=n { + let state = if id % 2 == 0 { "closed" } else { "open" }; + let meta = write_blob(repo, &issue_meta_blob(id, state)); + let body = write_blob(repo, &issue_body_blob(id)); + let tree = build_tree(repo, &[("meta", meta, 0o100644), ("body", body, 0o100644)]); + let commit = write_commit(repo, tree, None, &format!("issue {id}")); + set_ref(repo, &format!("refs/meta/issues/{id}"), commit); + } + let cnt_blob = write_blob(repo, n.to_string().as_bytes()); + let cnt_tree = build_tree(repo, &[("issues", cnt_blob, 0o100644)]); + let cnt_commit = write_commit(repo, cnt_tree, None, &format!("counter {n}")); + set_ref(repo, "refs/meta/counters", cnt_commit); + cnt_commit +} + +fn bench_issue_creation(c: &mut Criterion) { + let mut group = c.benchmark_group("issue_creation"); + for n in [100u64, 1_000, 10_000] { + group.throughput(Throughput::Elements(1)); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| { + b.iter_batched( + || { + let (dir, repo) = bare_repo(); + seed_issues(&repo, n); + (dir, repo) + }, + |(_dir, repo)| { + // 1. Read counter + let cref = repo.find_reference("refs/meta/counters").unwrap(); + let old_oid = cref.target().unwrap(); + let cnt_tree = repo.find_commit(old_oid).unwrap().tree().unwrap(); + let cnt_val: u64 = { + let b = repo + .find_blob(cnt_tree.get_name("issues").unwrap().id()) + .unwrap(); + std::str::from_utf8(b.content()) + .unwrap() + .trim() + .parse() + .unwrap() + }; + let new_id = cnt_val + 1; + + // 2. Write issue commit + let meta = write_blob(&repo, &issue_meta_blob(new_id, "open")); + let body = write_blob(&repo, &issue_body_blob(new_id)); + let tree = + build_tree(&repo, &[("meta", meta, 0o100644), ("body", body, 0o100644)]); + let issue_commit = write_commit(&repo, tree, None, &format!("issue {new_id}")); + set_ref(&repo, &format!("refs/meta/issues/{new_id}"), issue_commit); + + // 3. Build and CAS-update counter + let new_cnt_blob = write_blob(&repo, new_id.to_string().as_bytes()); + let new_cnt_tree = build_tree(&repo, &[("issues", new_cnt_blob, 0o100644)]); + let new_cnt_commit = write_commit( + &repo, + new_cnt_tree, + Some(old_oid), + &format!("counter {new_id}"), + ); + let _ok = cas_ref(&repo, "refs/meta/counters", new_cnt_commit, old_oid); + }, + criterion::BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +fn bench_issue_listing(c: &mut Criterion) { + let mut group = c.benchmark_group("issue_listing"); + for n in [100u64, 1_000, 10_000] { + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| { + let (_dir, repo) = bare_repo(); + seed_issues(&repo, n); + b.iter(|| { + let mut open: Vec = Vec::new(); + for r in repo.references_glob("refs/meta/issues/*").unwrap() { + let r = r.unwrap(); + let name = r.name().unwrap(); + let id: u64 = name + .trim_start_matches("refs/meta/issues/") + .parse() + .unwrap(); + let commit = repo.find_commit(r.target().unwrap()).unwrap(); + let tree = commit.tree().unwrap(); + let blob = repo.find_blob(tree.get_name("meta").unwrap().id()).unwrap(); + if std::str::from_utf8(blob.content()) + .unwrap() + .contains("state = \"open\"") + { + open.push(id); + } + } + open + }); + }); + } + group.finish(); +} + +// --- bench 3: comment lookup ------------------------------------------------- + +/// Seed `refs/metadata/comments` with `total` comments spread across `k` blobs. +/// Returns the k blob OIDs. +fn seed_comments(repo: &Repository, k: usize, total: usize) -> Vec { + let per_file = (total / k).max(1); + let blob_oids: Vec = (0..k) + .map(|i| { + write_blob( + repo, + format!("// file {i}\nfn main() {{}}\n") + .repeat(20) + .as_bytes(), + ) + }) + .collect(); + + let mut root_tb = repo.treebuilder(None).unwrap(); + for &boid in &blob_oids { + let hex = boid.to_string(); + let mut dir_tb = repo.treebuilder(None).unwrap(); + for ci in 0..per_file { + let cid = format!("{ci:08x}"); + let meta = write_blob( + repo, + format!( + "author = \"alice\"\nstart_line = {}\nend_line = {}\n", + ci + 1, + ci + 3 + ) + .as_bytes(), + ); + let body = write_blob(repo, format!("Comment {ci} body.\n").as_bytes()); + let ct = build_tree(repo, &[("meta", meta, 0o100644), ("body", body, 0o100644)]); + dir_tb.insert(&cid, ct, 0o040000).unwrap(); + } + let dir_tree = dir_tb.write().unwrap(); + root_tb.insert(&hex, dir_tree, 0o040000).unwrap(); + } + let root_tree = root_tb.write().unwrap(); + let commit = write_commit(repo, root_tree, None, "seed comments"); + set_ref(repo, "refs/metadata/comments", commit); + blob_oids +} + +fn bench_comment_lookup(c: &mut Criterion) { + let mut group = c.benchmark_group("comment_lookup"); + for total in [100usize, 500, 1_000] { + group.bench_with_input(BenchmarkId::from_parameter(total), &total, |b, &total| { + let (_dir, repo) = bare_repo(); + let blobs = seed_comments(&repo, 50, total); + let target = blobs[blobs.len() / 2]; + b.iter(|| { + let cref = repo.find_reference("refs/metadata/comments").unwrap(); + let root = repo + .find_commit(cref.target().unwrap()) + .unwrap() + .tree() + .unwrap(); + let hex = target.to_string(); + let dir_entry = match root.get_name(&hex) { + Some(e) => e, + None => return vec![], + }; + let dir = repo.find_tree(dir_entry.id()).unwrap(); + let mut out = Vec::new(); + for entry in dir.iter() { + let ct = repo.find_tree(entry.id()).unwrap(); + let meta = repo.find_blob(ct.get_name("meta").unwrap().id()).unwrap(); + let body = repo.find_blob(ct.get_name("body").unwrap().id()).unwrap(); + out.push((meta.content().to_vec(), body.content().to_vec())); + } + out + }); + }); + } + group.finish(); +} + +// --- bench 4: link traversal ------------------------------------------------- + +fn seed_links(repo: &Repository, n: usize) { + let mut root_tb = repo.treebuilder(None).unwrap(); + let mut issues_tb = repo.treebuilder(None).unwrap(); + let mut i42_tb = repo.treebuilder(None).unwrap(); + for i in 0..n { + let name = format!("comment:{i:016x}"); + let payload = format!("type = \"comment\"\ntarget = \"{i:016x}\"\n"); + let blob = write_blob(repo, payload.as_bytes()); + i42_tb.insert(&name, blob, 0o100644).unwrap(); + } + let i42 = i42_tb.write().unwrap(); + issues_tb.insert("42", i42, 0o040000).unwrap(); + let issues = issues_tb.write().unwrap(); + root_tb.insert("issues", issues, 0o040000).unwrap(); + let root = root_tb.write().unwrap(); + let commit = write_commit(repo, root, None, "seed links"); + set_ref(repo, "refs/metadata/links", commit); +} + +fn bench_link_traversal(c: &mut Criterion) { + let mut group = c.benchmark_group("link_traversal"); + for n in [10usize, 100, 500] { + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| { + let (_dir, repo) = bare_repo(); + seed_links(&repo, n); + b.iter(|| { + let r = repo.find_reference("refs/metadata/links").unwrap(); + let root = repo + .find_commit(r.target().unwrap()) + .unwrap() + .tree() + .unwrap(); + let issues = repo + .find_tree(root.get_name("issues").unwrap().id()) + .unwrap(); + let i42 = repo.find_tree(issues.get_name("42").unwrap().id()).unwrap(); + i42.iter() + .map(|e| e.name().unwrap().to_owned()) + .collect::>() + }); + }); + } + group.finish(); +} + +// --- bench 5: approval lookup ------------------------------------------------ + +/// Seed `refs/metadata/approvals` with `p` patch-IDs, one approver each. +fn seed_approvals(repo: &Repository, p: usize) -> Vec { + let patch_ids: Vec = (0..p).map(|i| format!("{i:040x}")).collect(); + let mut root_tb = repo.treebuilder(None).unwrap(); + for pid in &patch_ids { + let fp = format!("fp:{pid}"); + let payload = format!( + "approver = \"{fp}\"\ntimestamp = \"2024-01-01T00:00:00Z\"\nkind = \"patch\"\n" + ); + let blob = write_blob(repo, payload.as_bytes()); + let mut ptb = repo.treebuilder(None).unwrap(); + ptb.insert(&fp, blob, 0o100644).unwrap(); + let pt = ptb.write().unwrap(); + root_tb.insert(pid, pt, 0o040000).unwrap(); + } + let root = root_tb.write().unwrap(); + let commit = write_commit(repo, root, None, "seed approvals"); + set_ref(repo, "refs/metadata/approvals", commit); + patch_ids +} + +fn bench_approval_lookup(c: &mut Criterion) { + let mut group = c.benchmark_group("approval_lookup"); + for p in [10usize, 100, 1_000] { + // hit: patch ID present + group.bench_with_input(BenchmarkId::new("hit", p), &p, |b, &p| { + let (_dir, repo) = bare_repo(); + let ids = seed_approvals(&repo, p); + let target = ids[p / 2].clone(); + let policy = vec![format!("fp:{target}")]; + b.iter(|| { + let r = repo.find_reference("refs/metadata/approvals").unwrap(); + let root = repo + .find_commit(r.target().unwrap()) + .unwrap() + .tree() + .unwrap(); + match root.get_name(&target) { + None => false, + Some(e) => { + let pt = repo.find_tree(e.id()).unwrap(); + pt.iter() + .any(|e| policy.contains(&e.name().unwrap().to_owned())) + } + } + }); + }); + // miss: patch ID absent + group.bench_with_input(BenchmarkId::new("miss", p), &p, |b, &p| { + let (_dir, repo) = bare_repo(); + seed_approvals(&repo, p); + let absent = "ffffffffffffffffffffffffffffffffffffffff00".to_owned(); + b.iter(|| { + let r = repo.find_reference("refs/metadata/approvals").unwrap(); + let root = repo + .find_commit(r.target().unwrap()) + .unwrap() + .tree() + .unwrap(); + root.get_name(&absent).is_some() + }); + }); + } + group.finish(); +} + +// --- bench 6: metadata auto-merge -------------------------------------------- + +/// Build a base commit with `count` comment subtrees under refs/metadata/comments. +fn seed_merge_base(repo: &Repository, count: usize) -> Oid { + let mut root_tb = repo.treebuilder(None).unwrap(); + for i in 0..count { + let name = format!("comment-{i:04}"); + let meta = write_blob( + repo, + format!("author = \"alice\"\nbody = \"c{i}\"\n").as_bytes(), + ); + let body = write_blob(repo, format!("Comment {i} body.\n").as_bytes()); + let ct = build_tree(repo, &[("meta", meta, 0o100644), ("body", body, 0o100644)]); + root_tb.insert(&name, ct, 0o040000).unwrap(); + } + let root = root_tb.write().unwrap(); + let commit = write_commit(repo, root, None, "base"); + set_ref(repo, "refs/metadata/comments", commit); + commit +} + +fn bench_auto_merge(c: &mut Criterion) { + let mut group = c.benchmark_group("auto_merge"); + for count in [10usize, 100, 500] { + // clean: Alice and Bob add different new entries + group.bench_with_input(BenchmarkId::new("clean", count), &count, |b, &count| { + b.iter_batched( + || { + let (dir, repo) = bare_repo(); + let base = seed_merge_base(&repo, count); + let (a_commit, b_commit) = { + let base_tree = repo.find_commit(base).unwrap().tree().unwrap(); + + let a_meta = write_blob(&repo, b"author = \"alice\"\n"); + let a_body = write_blob(&repo, b"Alice comment.\n"); + let a_sub = build_tree( + &repo, + &[("meta", a_meta, 0o100644), ("body", a_body, 0o100644)], + ); + let mut a_tb = repo.treebuilder(Some(&base_tree)).unwrap(); + a_tb.insert("alice-comment", a_sub, 0o040000).unwrap(); + let a_tree = a_tb.write().unwrap(); + let a_commit = write_commit(&repo, a_tree, Some(base), "alice"); + + let b_meta = write_blob(&repo, b"author = \"bob\"\n"); + let b_body = write_blob(&repo, b"Bob comment.\n"); + let b_sub = build_tree( + &repo, + &[("meta", b_meta, 0o100644), ("body", b_body, 0o100644)], + ); + let mut b_tb = repo.treebuilder(Some(&base_tree)).unwrap(); + b_tb.insert("bob-comment", b_sub, 0o040000).unwrap(); + let b_tree = b_tb.write().unwrap(); + let b_commit = write_commit(&repo, b_tree, Some(base), "bob"); + (a_commit, b_commit) + }; + + (dir, repo, base, a_commit, b_commit) + }, + |(_dir, repo, base, a_commit, b_commit)| { + let anc = repo.find_commit(base).unwrap().tree().unwrap(); + let our = repo.find_commit(a_commit).unwrap().tree().unwrap(); + let their = repo.find_commit(b_commit).unwrap().tree().unwrap(); + let opts = git2::MergeOptions::new(); + let mut idx = repo.merge_trees(&anc, &our, &their, Some(&opts)).unwrap(); + let conflicts = idx.has_conflicts(); + if !conflicts { + let merged = idx.write_tree_to(&repo).unwrap(); + let merged_tree = repo.find_tree(merged).unwrap(); + let s = sig(); + let pa = repo.find_commit(a_commit).unwrap(); + let pb = repo.find_commit(b_commit).unwrap(); + let mc = repo + .commit(None, &s, &s, "merge", &merged_tree, &[&pa, &pb]) + .unwrap(); + set_ref(&repo, "refs/metadata/comments", mc); + } + conflicts + }, + criterion::BatchSize::SmallInput, + ); + }); + + // conflict: Alice and Bob both resolve the same comment + group.bench_with_input(BenchmarkId::new("conflict", count), &count, |b, &count| { + b.iter_batched( + || { + let (dir, repo) = bare_repo(); + let base = seed_merge_base(&repo, count); + let (a_commit, b_commit) = { + let base_tree = repo.find_commit(base).unwrap().tree().unwrap(); + let c0_oid = base_tree.get_name("comment-0000").unwrap().id(); + + let r_a = write_blob(&repo, b"by = \"alice\"\n"); + let mut a_c0 = repo + .treebuilder(Some(&repo.find_tree(c0_oid).unwrap())) + .unwrap(); + a_c0.insert("resolved", r_a, 0o100644).unwrap(); + let a_c0t = a_c0.write().unwrap(); + let mut a_tb = repo.treebuilder(Some(&base_tree)).unwrap(); + a_tb.insert("comment-0000", a_c0t, 0o040000).unwrap(); + let a_tree = a_tb.write().unwrap(); + let a_commit = write_commit(&repo, a_tree, Some(base), "alice resolves"); + + let r_b = write_blob(&repo, b"by = \"bob\"\n"); + let mut b_c0 = repo + .treebuilder(Some(&repo.find_tree(c0_oid).unwrap())) + .unwrap(); + b_c0.insert("resolved", r_b, 0o100644).unwrap(); + let b_c0t = b_c0.write().unwrap(); + let mut b_tb = repo.treebuilder(Some(&base_tree)).unwrap(); + b_tb.insert("comment-0000", b_c0t, 0o040000).unwrap(); + let b_tree = b_tb.write().unwrap(); + let b_commit = write_commit(&repo, b_tree, Some(base), "bob resolves"); + (a_commit, b_commit) + }; + + (dir, repo, base, a_commit, b_commit) + }, + |(_dir, repo, base, a_commit, b_commit)| { + let anc = repo.find_commit(base).unwrap().tree().unwrap(); + let our = repo.find_commit(a_commit).unwrap().tree().unwrap(); + let their = repo.find_commit(b_commit).unwrap().tree().unwrap(); + let opts = git2::MergeOptions::new(); + let idx = repo.merge_trees(&anc, &our, &their, Some(&opts)).unwrap(); + idx.has_conflicts() + }, + criterion::BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +// --- bench 7: reanchoring ---------------------------------------------------- + +/// Seed n comments on `old_blob` under refs/metadata/comments. +/// Returns the root commit OID and the comment root tree. +fn seed_reanchor_comments(repo: &Repository, old_blob: Oid, n: usize) -> Oid { + let hex = old_blob.to_string(); + let mut dir_tb = repo.treebuilder(None).unwrap(); + for i in 0..n { + let cid = format!("{i:08x}"); + let meta = write_blob( + repo, + format!( + "author = \"alice\"\nstart_line = {}\nend_line = {}\n", + i * 5 + 1, + i * 5 + 3 + ) + .as_bytes(), + ); + let body = write_blob(repo, format!("Comment {i}.\n").as_bytes()); + let ct = build_tree(repo, &[("meta", meta, 0o100644), ("body", body, 0o100644)]); + dir_tb.insert(&cid, ct, 0o040000).unwrap(); + } + let dir = dir_tb.write().unwrap(); + let mut root_tb = repo.treebuilder(None).unwrap(); + root_tb.insert(&hex, dir, 0o040000).unwrap(); + let root = root_tb.write().unwrap(); + let commit = write_commit(repo, root, None, "seed reanchor comments"); + set_ref(repo, "refs/metadata/comments", commit); + commit +} + +fn bench_reanchoring(c: &mut Criterion) { + let mut group = c.benchmark_group("reanchoring"); + for n in [1usize, 10, 50] { + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, &n| { + b.iter_batched( + || { + let (dir, repo) = bare_repo(); + // Create old file blob + let old_content: Vec = (0..100u32) + .map(|i| format!("line {i}\n")) + .collect::() + .into_bytes(); + let old_blob = write_blob(&repo, &old_content); + let base_commit = seed_reanchor_comments(&repo, old_blob, n); + + // Create new file blob (each line shifted by adding a prefix) + let new_content: Vec = (0..100u32) + .map(|i| format!("// line {i}\n")) + .collect::() + .into_bytes(); + let new_blob = write_blob(&repo, &new_content); + + // Blame map: every old start_line maps to start_line + 2 + let blame_map: HashMap = (0..n) + .map(|i| { + let old_start = (i * 5 + 1) as u32; + (old_start, old_start + 2) + }) + .collect(); + + (dir, repo, old_blob, new_blob, base_commit, blame_map) + }, + |(_dir, repo, old_blob, new_blob, _base_commit, blame_map)| { + let old_hex = old_blob.to_string(); + let new_hex = new_blob.to_string(); + + // Read existing comments on old_blob + let cref = repo.find_reference("refs/metadata/comments").unwrap(); + let old_root_oid = cref.target().unwrap(); + let old_root = repo.find_commit(old_root_oid).unwrap().tree().unwrap(); + + let dir_entry = match old_root.get_name(&old_hex) { + Some(e) => e, + None => return old_root_oid, + }; + let dir = repo.find_tree(dir_entry.id()).unwrap(); + + // Build new dir under new_blob_hex with updated line ranges + let mut new_dir_tb = repo.treebuilder(None).unwrap(); + for entry in dir.iter() { + let ct = repo.find_tree(entry.id()).unwrap(); + let meta_entry = ct.get_name("meta").unwrap(); + let meta_blob = repo.find_blob(meta_entry.id()).unwrap(); + let meta_str = std::str::from_utf8(meta_blob.content()).unwrap(); + + // Parse start_line + let start_line: u32 = meta_str + .lines() + .find(|l| l.starts_with("start_line")) + .and_then(|l| l.split('=').nth(1)) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(1); + + let new_start = blame_map.get(&start_line).copied().unwrap_or(start_line); + let new_end = new_start + 2; + + let new_meta_content = format!( + "author = \"alice\"\nstart_line = {new_start}\nend_line = {new_end}\n" + ); + let new_meta = write_blob(&repo, new_meta_content.as_bytes()); + let body_oid = ct.get_name("body").unwrap().id(); + let new_ct = build_tree( + &repo, + &[("meta", new_meta, 0o100644), ("body", body_oid, 0o100644)], + ); + new_dir_tb + .insert(entry.name().unwrap(), new_ct, 0o040000) + .unwrap(); + } + let new_dir = new_dir_tb.write().unwrap(); + + // Build new root: remove old_hex entry, add new_hex entry + let mut new_root_tb = repo.treebuilder(Some(&old_root)).unwrap(); + new_root_tb.remove(&old_hex).unwrap(); + new_root_tb.insert(&new_hex, new_dir, 0o040000).unwrap(); + let new_root = new_root_tb.write().unwrap(); + + let new_commit = write_commit(&repo, new_root, Some(old_root_oid), "reanchor"); + set_ref(&repo, "refs/metadata/comments", new_commit); + new_commit + }, + criterion::BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +// --- criterion wiring -------------------------------------------------------- + +criterion_group!( + benches, + bench_issue_creation, + bench_issue_listing, + bench_comment_lookup, + bench_link_traversal, + bench_approval_lookup, + bench_auto_merge, + bench_reanchoring, +); +criterion_main!(benches); diff --git a/crates/forge-benchmarks/src/lib.rs b/crates/forge-benchmarks/src/lib.rs new file mode 100644 index 0000000..fdbf06c --- /dev/null +++ b/crates/forge-benchmarks/src/lib.rs @@ -0,0 +1,4 @@ +//! Forge benchmarks crate. +//! +//! This crate contains no library code — it exists solely to host the +//! `benches/forge.rs` benchmark suite.