crates/gunnar-pack/src/lib.rs
//! Packfile **writing** and **indexing** for gunnar.
//!
//! This crate is used by *both* halves of gunnar and therefore inherits the server's
//! constraint (`.nornir/TOTAL-PROMPT.md` §0, §2): **plumbing only**. No `gix`
//! porcelain, no worktree/index/status, no filesystem assumptions beyond the one
//! optional output directory that [`index_pack`] is explicitly handed.
//!
//! * the **server** writes packs to serve `git-upload-pack`, and reads them to serve
//! `git-receive-pack`,
//! * the **client** writes packs for `send-pack`, because gix has no send-pack at all.
//!
//! # The two halves
//!
//! ## Writing: [`write_pack`] / [`PackWriter`]
//!
//! Objects are read through gix's own object traits ([`gix_object::Find`] +
//! [`gix_object::Exists`], bundled here as [`ObjectSource`]), so any store that has the
//! adapter described in TOTAL-PROMPT §4 composes without a second seam.
//!
//! **[`write_pack`]'s output is non-delta**, and deflates every object from its
//! inflated content. That is the right shape only for a store that has no packs
//! to copy from **and no encoder to reach for**. It stopped being the client's
//! shape on 2026-08-03; see below.
//!
//! ## Computing: `write_pack_deltified` (the `delta` feature)
//!
//! The third path, and the one that was missing. A **pushing client** writes
//! packs out of *loose* objects: there are no deltas to copy, so `write_pack`
//! was what it called, and it shipped packs at **0.0% deltified** — 407.08 MiB
//! stored on the server against stock git's 88.14 MiB over 19 real repositories,
//! measured 2026-08-03. `write_pack_deltified` composes `gunnar-delta`'s encoder
//! with this crate's writer to close it. See [`delta`](crate::delta) for the
//! whole argument and the memory ceiling. Off by default: the server does not
//! use it and must not link it.
//!
//! ## Copying: [`write_pack_copy`] / [`PackedObjects`]
//!
//! `P-001`, and the reason gunnar lost the serve path to stock git by 18.34× on
//! bytes. A store that can hand back its **pack-resident** view gets its
//! entries copied out verbatim. No inflate, no deflate, and the delta encoding
//! the pushing client's git already computed survives onto the wire. gunnar
//! still contains no delta *encoder*, and needs none: the client paid for the
//! search. See [`copy`](crate::copy) for the whole argument, including `P-020`,
//! which is why the byte emitter here is not gix's.
//!
//! ## Reading: [`index_pack`] / [`index_thin_pack`]
//!
//! A thin wrapper over [`gix_pack::Bundle::write_to_directory`].
//! [`index_thin_pack`] hands it the receiving store as `bases` (anything
//! implementing [`gix_object::Find`], which `gunnar-store`'s adapter is), so a
//! pushed **thin pack** whose deltas reference bases that exist only in that
//! store resolves correctly and the completed bases are written into the
//! resulting pack. [`index_pack`] passes no store and therefore **rejects** a
//! thin pack rather than silently writing one that cannot stand alone.
//!
//! # Hash kinds
//!
//! Both SHA-1 and SHA-256 are supported, and the kind is **never** guessed: every entry
//! point takes it explicitly, because answering with the wrong `object-format` is a
//! documented, session-costing bug (TOTAL-PROMPT §6). Object ids whose kind disagrees
//! with the configured one are rejected rather than silently truncated.
//!
//! # Empty packs
//!
//! A pack with zero objects is legal, is a valid server reply, and must not crash
//! either half. It round-trips through this crate.
//!
//! # Example
//!
//! ```
//! use gix_hash::Kind;
//! use gunnar_pack::{IndexOptions, WriteOptions, index_pack, write_pack_to_vec};
//!
//! # struct OneBlob(gix_hash::ObjectId, Vec<u8>);
//! # impl gix_object::Find for OneBlob {
//! # fn try_find<'a>(&self, id: &gix_hash::oid, buffer: &'a mut Vec<u8>)
//! # -> gix_error::Result<Option<gix_object::Data<'a>>> {
//! # if id != self.0 { return Ok(None) }
//! # buffer.clear();
//! # buffer.extend_from_slice(&self.1);
//! # Ok(Some(gix_object::Data { kind: gix_object::Kind::Blob, object_hash: Kind::Sha256, data: buffer }))
//! # }
//! # }
//! # impl gix_object::Exists for OneBlob {
//! # fn exists(&self, id: &gix_hash::oid) -> bool { id == self.0 }
//! # }
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! // `store` is anything implementing `gix_object::Find + Exists`.
//! # let content = b"hello".to_vec();
//! # let id = gix_object::compute_hash(Kind::Sha256, gix_object::Kind::Blob, &content).map_err(gix_error::Error::from)?;
//! # let store = OneBlob(id, content);
//! let (pack, written) = write_pack_to_vec(&[id], &store, &WriteOptions::new(Kind::Sha256))?;
//! assert_eq!(written.num_objects, 1);
//!
//! // Reading it back is the receive-pack side; `None` means validate, keep nothing.
//! let indexed = index_pack(
//! &mut std::io::Cursor::new(&pack),
//! None,
//! &IndexOptions::new(Kind::Sha256),
//! None,
//! )?;
//! assert_eq!(indexed.num_objects, 1);
//! assert_eq!(indexed.pack_id, written.pack_id);
//! # Ok(()) }
//! ```
#![deny(missing_docs)]
#![forbid(unsafe_code)]
mod checksum;
pub mod copy;
#[cfg(feature = "delta")]
pub mod delta;
mod error;
pub mod oidprobe;
mod read;
mod write;
pub use copy::{extent_lookups, write_pack_copy, write_pack_copy_thin, PackedObjects};
#[cfg(feature = "delta")]
pub use delta::{
write_pack_deltified, write_pack_deltified_ordered, DeltaOptions, NameHashFn, OrderKey,
};
pub use error::{Error, Result};
/// git's path hash functions, re-exported from `gunnar-delta`.
///
/// A caller that computes [`NameHashFn`]'s answers needs these, and reaching
/// them through this crate is what keeps `gunnar-delta` a single edge in the
/// graph instead of two: it is already an optional dependency here, behind the
/// same `delta` feature, and a second manifest naming it would be a second
/// place to get the feature gate wrong.
#[cfg(feature = "delta")]
pub use gunnar_delta::name_hash;
pub use read::{index_pack, index_thin_pack, IndexOptions, IndexedPack, DEFAULT_ALLOC_LIMIT_BYTES};
pub use write::{
empty_pack, missing_objects, write_pack, write_pack_to_vec, PackWriter, WriteOptions,
WriteOutcome,
};
/// The tracing target every line this crate emits is on.
///
/// The same string as `gunnar_core::observe::TARGET_GIT`, stated here rather
/// than linked, exactly as `gunnar_grpc::AUDIT_TARGET` is: this crate is
/// plumbing on the server side of the dependency guard (§0/§2) and has no
/// `gunnar-core` edge to spend on one `&str`. `gunnar-cli` links both and pins
/// them equal, so the two spellings cannot drift apart in silence.
///
/// Without it these lines sit on the module-path default (`gunnar_pack::write`
/// and friends), so `RUST_LOG=gunnar::git=debug`, the filter an operator
/// writes for a transfer, does not select them, and the packfile
/// half of a transfer goes quiet while the rest of it talks.
pub const GIT_TARGET: &str = "gunnar::git";
/// The pack format version this crate writes, and the only one git has used in
/// practice since 2005.
pub const PACK_VERSION: u32 = 2;
/// The 4-byte signature every packfile starts with.
pub const PACK_SIGNATURE: &[u8; 4] = b"PACK";
/// The read seam for the pack writer.
///
/// It is deliberately nothing but gix's own object traits: implementing
/// [`gix_object::Find`] and [`gix_object::Exists`], which `gunnar-store`'s
/// `ObjectStore` adapter does by design (TOTAL-PROMPT §4), is all it takes to
/// serve a pack from any backend, on-disk or not.
///
/// The blanket impl means there is nothing to implement for this trait itself.
pub trait ObjectSource: gix_object::Find + gix_object::Exists {}
impl<T> ObjectSource for T where T: gix_object::Find + gix_object::Exists + ?Sized {}