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

nordisk/gunnar

crates/gunnar-client/src/pack_source.rs

//! The one seam where gix's **porcelain** object database is handed to gunnar's
//! **plumbing** pack writer, across a semver boundary that is deliberate.
//!
//! **2026-10-02, gix 0.88 wave: the boundary is gone.** `gix` 0.88 and the
//! plumbing set both resolve `gix-object` 0.65, so `PlumbingObjects` is now a
//! plain forward between two names of the same trait. It is kept, unchanged in
//! shape, so this wave changes no call site; it can be deleted (callers pass the
//! repository straight to `gunnar-pack`) as a separate cleanup. The history
//! below describes the 0.87 era.
//!
//! # Why this file exists
//!
//! `gunnar-client` links `gix` 0.87 — the porcelain half, the one that knows
//! about worktrees, `.git/config` and the blocking git transport. `gunnar-pack`
//! (in the shared `edda` leaf) links `gix-object` **0.63**, because that is what
//! the whole plumbing stack in this tree resolves: `gix-object 0.63`,
//! `gix-pack 0.73`, `gix-odb 0.83`, `gix-ref 0.66`. `gix` 0.87 carries
//! `gix-object` **0.64** inside it.
//!
//! Those two `gix-object` copies are semver-incompatible, so a
//! `&gix::Repository` no longer satisfies `gunnar_pack::ObjectSource`
//! (`gix_object::Find + Exists`, plus `FindHeader` for the deltified writer)
//! even though it implements the identically-shaped traits from its own copy.
//! That is `E0277`, not a missing capability.
//!
//! # Why the bridge, and not a bump
//!
//! Bumping the plumbing pins to match would move the break rather than remove
//! it: `gunnar-store`, `gunnar-wire` and `gunnar-policy` all hand `gix-object`
//! values *to* `gunnar-pack`, and `gunnar-pack` lives in `edda`, a repository
//! this one only consumes. One 3-method shim at the single crossing is smaller
//! and more honest than a version skew pushed one layer down.
//!
//! # Why it cannot silently rot
//!
//! There is exactly ONE crossing (`push::drive_push`), and both trait sets are
//! byte-identical in shape: `Data { kind, object_hash, data }`,
//! `Header { kind, size }`, `Kind` with the same four variants, and
//! `find::Error = Box<dyn Error + Send + Sync>`. `gix-hash` is **not** duplicated
//! — `gix` 0.87 asks for `^0.26.2` and this workspace pins `0.26`, so `oid` and
//! `ObjectId` are one type on both sides and no id is ever re-encoded here.
//! `kind()` below is a total match with no wildcard arm, so a fifth object kind
//! on either side is a compile error rather than a wrong answer.

use gix_object::{Data, Header, Kind};

/// Any gix-0.87 object store — a `Repository`, its `odb::Handle`, or a
/// reference to either — seen through `gix-object` 0.63's object-source traits.
///
/// Generic rather than fixed to `&gix::Repository` for one measured reason:
/// the push path hands over the whole repository, while `tests/mirror_slot_table`
/// hands over `repo.objects` alone. One wrapper covers both; two would be a twin
/// of the same three methods (one implementation).
pub struct PlumbingObjects<T>(pub T);

/// `gix-object` 0.64's `Kind` onto `gix-object` 0.63's.
///
/// No `_ =>` arm on purpose: if either side grows a variant, this stops
/// compiling instead of mapping it onto the wrong one.
fn kind(k: gix::objs::Kind) -> Kind {
    match k {
        gix::objs::Kind::Tree => Kind::Tree,
        gix::objs::Kind::Blob => Kind::Blob,
        gix::objs::Kind::Commit => Kind::Commit,
        gix::objs::Kind::Tag => Kind::Tag,
    }
}

impl<T: gix::objs::Exists> gix_object::Exists for PlumbingObjects<T> {
    fn exists(&self, id: &gix_hash::oid) -> bool {
        gix::objs::Exists::exists(&self.0, id)
    }
}

impl<T: gix::objs::Find> gix_object::Find for PlumbingObjects<T> {
    fn try_find<'a>(
        &self,
        id: &gix_hash::oid,
        buffer: &'a mut Vec<u8>,
    ) -> gix_error::Result<Option<Data<'a>>> {
        // The inner call fills `buffer` and hands back a view into it; the
        // returned `Data` re-wraps that same view, so nothing is copied.
        Ok(
            gix::objs::Find::try_find(&self.0, id, buffer)?.map(|d| Data {
                kind: kind(d.kind),
                object_hash: d.object_hash,
                data: d.data,
            }),
        )
    }
}

impl<T: gix::objs::FindHeader> gix_object::FindHeader for PlumbingObjects<T> {
    fn try_header(&self, id: &gix_hash::oid) -> gix_error::Result<Option<Header>> {
        Ok(
            gix::objs::FindHeader::try_header(&self.0, id)?.map(|h| Header {
                kind: kind(h.kind),
                size: h.size,
            }),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use gix_object::{Exists, Find, FindHeader};

    /// The bridge answers about a REAL object written through the porcelain, and
    /// answers about a made-up id with `None` rather than an error.
    ///
    /// This is the whole point of the file: `repo` is a `gix` 0.87 value and
    /// every assertion below is made through a `gix-object` **0.63** trait.
    #[test]
    fn the_bridge_reads_a_real_object_and_misses_a_fake_one() {
        let dir = tempfile::tempdir().expect("tempdir");
        let repo = gix::init(dir.path()).expect("init");
        let blob = b"the bridge carries bytes, not versions\n";
        let id = repo
            .write_blob(blob.as_slice())
            .expect("write blob")
            .detach();

        let src = PlumbingObjects(&repo);

        assert!(src.exists(&id), "a blob just written must be present");

        let header = src
            .try_header(&id)
            .expect("header lookup")
            .expect("header present");
        assert_eq!(header.kind, Kind::Blob, "a blob must read back as a blob");
        assert_eq!(
            header.size,
            blob.len() as u64,
            "the size must be the object's, not the buffer's"
        );

        let mut buf = Vec::new();
        let data = src
            .try_find(&id, &mut buf)
            .expect("object lookup")
            .expect("object present");
        assert_eq!(data.kind, Kind::Blob);
        assert_eq!(
            data.data, blob,
            "the bytes must survive the version crossing verbatim"
        );

        // The RED half (measure, never assert): an id that was never written
        // must come back as a miss, so a bridge that answered `Some` for
        // everything — or that handed back the previous call's buffer — fails
        // here. Not the null oid, which every store special-cases: a
        // well-formed id of the right hash kind that simply names nothing.
        let mut raw = id.as_bytes().to_vec();
        raw[0] ^= 0xff;
        let absent = gix_hash::ObjectId::from_bytes_or_panic(&raw);
        let mut buf2 = Vec::new();
        assert!(!src.exists(&absent), "an unwritten object must not exist");
        assert!(
            src.try_find(&absent, &mut buf2)
                .expect("miss is not an error")
                .is_none(),
            "a miss must be None, not the last object read"
        );
        assert!(src
            .try_header(&absent)
            .expect("miss is not an error")
            .is_none());
    }
}