Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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)
        );
    }
}