gunnar-store 1.6.0

The gunnar server's storage seam: RepoStore (which repository), SecretStore (what has no git equivalent), and the one conformance suite both GitOps backends run
Documentation
//! The conformance suite, driven from **outside** the crate through nothing but
//! the public API.
//!
//! The suite also runs as a unit test, which is where the deliberately broken
//! backends live. This file exists for a different reason: `znippy-plugin-git`
//! and `storage-git-gix` consume the suite exactly like this — from another
//! repository, over the public surface — and if that surface stops being enough
//! to run it, that breaks here first.

use std::path::Path;
use std::sync::Arc;

use gunnar_store::conformance::{self, Harness, HASH_KINDS};
use gunnar_store::git_storage_trait::{Caps, GitOps, GitServe};
use gunnar_store::RepoStore;

fn run<H: Harness>(harness: &H) -> usize {
    let outcomes = conformance::run_all(harness);
    let expected = conformance::check_names::<H>().len() * HASH_KINDS.len();
    assert_eq!(
        outcomes.len(),
        expected,
        "[{}] the runner produced {} outcomes for {expected} expected check/hash-kind pairs",
        harness.name(),
        outcomes.len()
    );
    // Both hash kinds must genuinely have run: sha256 green is never inferred
    // from sha1 green.
    for hash_kind in HASH_KINDS {
        let ran = outcomes.iter().filter(|o| o.hash_kind == hash_kind).count();
        assert_eq!(
            ran,
            expected / HASH_KINDS.len(),
            "[{}] only {ran} checks ran against {hash_kind}",
            harness.name()
        );
    }
    conformance::assert_all_pass(&outcomes)
}

// 62 = 31 checks x 2 hash kinds. Asserted rather than counted at runtime, so
// that losing a check is a failure and not a quieter suite. It was 46 until
// 2026-08-10, when the suite was retargeted from `GitOps` at `GitServe` and
// gained six: atomic CAS, HEAD, `read`, `header`/`sizes`, `emit_pack` and
// `select`; 58 until 2026-08-31, when `reachable` gained the annotated-tag
// edge that had been costing six repositories their second copy; and 60 until
// 2026-09-08, when `put_pack_observed` gained a live object count and the
// suite gained the check that it MOVES.
const EXPECTED: usize = 62;

#[test]
fn the_in_memory_control_conforms() {
    assert_eq!(run(&conformance::MemHarness::default()), EXPECTED);
}

/// **The outside oracle for the fixture packfile.**
///
/// `conformance::pack` writes the packs the suite pushes, and the in-memory
/// control parses them back. Both were written together, so their agreeing is
/// worth nothing on its own (one implementation, no twin). `gunnar_pack::index_pack` is an
/// independent implementation of git's format with no stake in either: it walks
/// the same bytes, resolves the same `OFS_DELTA`, and produces the same object
/// ids — or this module has stopped emitting git's format, whatever the rest of
/// the suite says.
///
/// This is the same pattern `znippy-plugin-git` uses with its gix oracle in
/// `pack_walk.rs`, and it is a dev-dependency for the same reason: the oracle
/// proves the parser and never ships.
#[test]
fn the_fixture_pack_is_a_real_packfile() {
    for hash_kind in HASH_KINDS {
        let repo = conformance::repo(hash_kind).expect("build the fixture");
        let dir = tempfile::tempdir().expect("temporary directory");
        let mut bytes = std::io::Cursor::new(repo.fixture.bytes.clone());

        // `thread_limit(1)`: measured, not assumed — never nest a fan-out. This runs inside a
        // test harness that is already running tests in parallel.
        let indexed = gunnar_pack::index_pack(
            &mut bytes,
            Some(dir.path()),
            &gunnar_pack::IndexOptions::new(hash_kind).thread_limit(1),
            None,
        )
        .unwrap_or_else(|err| {
            panic!("gitoxide refused a pack the conformance fixture calls valid: {err:#}")
        });

        assert_eq!(
            indexed.num_objects as usize,
            repo.fixture.entries.len(),
            "[{hash_kind}] the oracle found {} objects and the fixture claims {}",
            indexed.num_objects,
            repo.fixture.entries.len()
        );
    }
}

/// The seam is usable as a trait object, which is how the server holds it.
///
/// `RepoStore::git()` hands back `Arc<dyn GitServe>` and the push path clones
/// that handle into an ingest that outlives the borrow. A compile-and-run
/// guarantee, not a stylistic preference.
///
/// It also pins the other half of the 2026-08-10 decision: the handle is usable
/// as **both** halves of the contract family through one method — the twelve by
/// the supertrait, `HEAD` by the accessor — which is what makes a second
/// accessor unnecessary.
#[test]
fn the_seam_is_usable_as_a_trait_object() {
    struct OneRepo {
        git_dir: std::path::PathBuf,
        git: Arc<dyn GitServe>,
    }

    impl RepoStore for OneRepo {
        fn git_dir(&self) -> &Path {
            &self.git_dir
        }
        fn hash_kind(&self) -> gunnar_store::gix_hash::Kind {
            gunnar_store::gix_hash::Kind::Sha256
        }
        fn git(&self) -> Arc<dyn GitServe> {
            Arc::clone(&self.git)
        }
    }

    let hash_kind = gunnar_store::gix_hash::Kind::Sha256;
    let repo = conformance::repo(hash_kind).expect("build the fixture");
    let store: Arc<dyn RepoStore> = Arc::new(OneRepo {
        git_dir: std::path::PathBuf::from("/does/not/have/to/exist"),
        git: Arc::new(gunnar_store::mem::MemGitServe::new(hash_kind)),
    });

    // The push path: clone the handle out and use it after the borrow is gone.
    let git = store.git();
    drop(store);
    git.put_pack(&repo.fixture.bytes).expect("put_pack");
    assert!(git.has(repo.commit1.as_slice()).expect("has"));
    // The reading half, through the same handle: no second accessor, no
    // downcast, and `HEAD` reachable where `refs()` deliberately does not carry
    // it.
    git.set_head("refs/heads/main").expect("set_head");
    assert!(
        git.refs().expect("refs").iter().all(|r| r.name != "HEAD"),
        "HEAD is a pseudo-ref and must not be advertised as a row"
    );
    assert_eq!(
        git.head()
            .expect("head")
            .and_then(|r| r.symref_target)
            .as_deref(),
        Some("refs/heads/main")
    );
}

/// **The outside oracle for an EMITTED pack.**
///
/// `emit_pack_emits_the_set_it_was_given` reads the emitted pack back with the
/// suite's own parser, which can only prove membership: that parser and the
/// fixture writer were written together (one implementation, no twin). This drives
/// `gunnar_pack::index_pack` — an implementation of git's format with no stake
/// in gunnar's — over the same bytes, so *"the emitter produces a packfile"* is
/// checked by something that would have refused one.
///
/// A dev-dependency, so a downstream backend running the suite never links it.
#[test]
fn an_emitted_pack_is_a_real_packfile() {
    for hash_kind in HASH_KINDS {
        let repo = conformance::repo(hash_kind).expect("build the fixture");
        let git = gunnar_store::mem::MemGitServe::new(hash_kind);
        git.put_pack(&repo.fixture.bytes).expect("put_pack");

        // The delta object **and the base it names**, so the emitted pack
        // carries an OFS_DELTA whose base distance the emitter had to REWRITE —
        // the one part of a pack copy that cannot be a memcpy, and the part a
        // whole-object writer would paper over.
        //
        // The base is named explicitly, where this used to rely on the emitter
        // dragging it in. Since 2026-08-14 no emitter does: a stored delta is
        // reused only when its base is also being packed, and a request holding
        // the delta alone is answered with the object rebuilt whole. That would
        // leave this test asserting `recompressed == 0` over a pack with no
        // delta in it at all — green, and measuring nothing.
        let mut out = Vec::new();
        let stats = git
            .emit_pack(
                &[
                    repo.delta_blob.as_slice(),
                    repo.blob.as_slice(),
                    repo.commit2.as_slice(),
                ],
                &[],
                &Caps::modern(),
                &mut out,
            )
            .expect("emit_pack");

        let dir = tempfile::tempdir().expect("temporary directory");
        let mut bytes = std::io::Cursor::new(out.clone());
        let indexed = gunnar_pack::index_pack(
            &mut bytes,
            Some(dir.path()),
            &gunnar_pack::IndexOptions::new(hash_kind).thread_limit(1),
            None,
        )
        .unwrap_or_else(|err| {
            panic!("gitoxide refused a pack the in-memory emitter produced: {err:#}")
        });
        assert_eq!(
            indexed.num_objects as u64, stats.objects,
            "[{hash_kind}] the oracle found {} objects and PackStats claims {}",
            indexed.num_objects, stats.objects
        );
        // The premise, or `recompressed == 0` is satisfied by a pack with no
        // delta in it at all: the subject really is stored as an OFS_DELTA, so
        // the copy arm — the one that rewrites the distance — is the arm that
        // ran.
        let stored = git
            .get(repo.delta_blob.as_slice())
            .expect("get")
            .expect("the store holds the fixture's delta object");
        assert_eq!(
            stored.obj_type,
            gunnar_store::git_storage_trait::ObjType::OfsDelta,
            "[{hash_kind}] premise: the fixture's delta object is not stored as an OFS_DELTA, so \
             this pack carries no delta and asserts nothing about rewriting a base distance"
        );
        assert_eq!(
            stats.recompressed, 0,
            "[{hash_kind}] the in-memory emitter re-deflated {} entries; every base this request \
             names is in the request, so every entry must be a copy",
            stats.recompressed
        );
        assert_eq!(
            stats.copied, stats.objects,
            "[{hash_kind}] {} of {} entries were copied",
            stats.copied, stats.objects
        );
    }
}