bench/src/packbuild.rs
//! Building a repository the only way an engine that stores packs accepts one.
//!
//! # Why this exists
//!
//! Every fixture in this harness used to build a repository object by object —
//! `write(Blob, ..)`, then a tree naming that blob, then a commit naming that
//! tree — and then call `repack()` to fold the loose objects into a pack so the
//! serve path had something to copy.
//!
//! Neither half survives the storage engine. A store that keeps pushed packs
//! verbatim **refuses a single-object write** and says so:
//!
//! ```text
//! the znippy-git store takes objects as packs, not one at a time:
//! push through `GitStorePackIngest`
//! ```
//!
//! and `repack` is a packfile concept that went with the packfile engine — there
//! is nothing loose to fold.
//!
//! So a fixture builds its objects in memory, seals them into one packfile, and
//! pushes that. Which is also closer to what a real repository is: objects reach
//! a server in a pack, not one at a time.
//!
//! # This writes whole objects, deliberately
//!
//! No deltas. A fixture's job is to put a known object graph in front of an arm,
//! and a delta the fixture invented would be a delta the *fixture* chose rather
//! than one a real client produced. Arms that need deltified input take a real
//! pack from a real repository — that is what `real_pack` and the corpus arms
//! are for.
use anyhow::{Context, Result};
use gix_hash::ObjectId;
use gix_object::Kind;
/// Objects accumulated for one pack.
pub struct PackBuilder {
hash: gix_hash::Kind,
entries: Vec<(Kind, Vec<u8>)>,
}
impl PackBuilder {
pub fn new(hash: gix_hash::Kind) -> Self {
PackBuilder {
hash,
entries: Vec::new(),
}
}
/// Add an object and get the id it will have.
///
/// The id is computed by [`gix_object::compute_hash`] rather than by a second
/// hashing path here, so a fixture that names an object by id and a store that
/// indexes it agree by construction rather than by coincidence.
///
/// It used to say `gunnar_store::object::compute_id`, which was that crate's
/// one-line wrapper over exactly this call. The wrapper died with the
/// packfile engine (2026-08-10); `gunnar-store` now hashes through
/// `gix_object::compute_hash` at every one of its own call sites
/// (`conformance.rs:416`, `mem.rs:277`, `conformance/pack.rs:190`), so this
/// still shares a hashing path with the store — the same one, one layer down.
pub fn add(&mut self, kind: Kind, bytes: impl Into<Vec<u8>>) -> Result<ObjectId> {
let bytes = bytes.into();
let id = gix_object::compute_hash(self.hash, kind, &bytes)
.map_err(|e| anyhow::anyhow!("computing the id of a {kind} object: {e}"))?;
self.entries.push((kind, bytes));
Ok(id)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Seal into a packfile.
///
/// `PACK`, version 2, the count, then one entry per object — type and size
/// as git's varint header, then the object deflated — then the trailer,
/// which is the hash of everything before it and is also the pack's
/// identity.
pub fn finish(self) -> Result<Vec<u8>> {
use std::io::Write as _;
let count = u32::try_from(self.entries.len())
.context("a fixture pack holds at most u32::MAX objects")?;
let mut out = Vec::new();
out.extend_from_slice(b"PACK");
out.extend_from_slice(&2u32.to_be_bytes());
out.extend_from_slice(&count.to_be_bytes());
for (kind, bytes) in &self.entries {
let code: u8 = match kind {
Kind::Commit => 1,
Kind::Tree => 2,
Kind::Blob => 3,
Kind::Tag => 4,
};
// Byte 0 is [continue:1][type:3][size low nibble:4]; each
// continuation carries 7 more size bits, least significant first.
let size = bytes.len() as u64;
let mut byte = (code << 4) | ((size & 0x0f) as u8);
let mut rest = size >> 4;
while rest > 0 {
out.push(byte | 0x80);
byte = (rest & 0x7f) as u8;
rest >>= 7;
}
out.push(byte);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
z.write_all(bytes)?;
out.extend_from_slice(&z.finish()?);
}
let mut hasher = gix_hash::hasher(self.hash);
hasher.update(&out);
let digest = hasher
.try_finalize()
.map_err(|e| anyhow::anyhow!("hashing the fixture pack's trailer: {e}"))?;
out.extend_from_slice(digest.as_slice());
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **Stock git must accept what a fixture builds**, or every arm standing on
/// one is measuring something git would not have served.
///
/// `index-pack --strict` shares no code with this file, which is the point:
/// our own reader agreeing with our own writer would agree even if both were
/// wrong the same way.
#[test]
fn git_accepts_a_fixture_pack() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: no git on PATH");
return;
}
let mut b = PackBuilder::new(gix_hash::Kind::Sha1);
let blob = b.add(Kind::Blob, b"fixture\n".to_vec()).unwrap();
let mut tree = Vec::new();
tree.extend_from_slice(b"100644 a.txt\0");
tree.extend_from_slice(blob.as_bytes());
let tree_id = b.add(Kind::Tree, tree).unwrap();
let commit = format!(
"tree {tree_id}\nauthor A <a@e> 1700000000 +0000\ncommitter A <a@e> 1700000000 +0000\n\nfixture\n"
);
b.add(Kind::Commit, commit.into_bytes()).unwrap();
let pack = b.finish().unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fixture.pack");
std::fs::write(&path, &pack).unwrap();
let out = std::process::Command::new("git")
.args(["index-pack", "--strict"])
.arg(&path)
.current_dir(dir.path())
.output()
.unwrap();
assert!(
out.status.success(),
"git refused a fixture pack: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}