git_storage_trait/lib.rs
1//! **The git-storage contract: `GitOps`, the backend-neutral eleven.**
2//!
3//! This is the contract written into `znippy-plugin-git`'s `store.rs`
4//! ("*The twelve functions. Nothing else is permitted here.*"), lifted out so it
5//! is no longer owned by one implementation. It defines *what a git object+ref
6//! store must be able to do*, and names no storage, no index, no durability
7//! mechanism and no codec — so both backends implement the **same** trait:
8//!
9//! | implementer | where | how it stores |
10//! |---|---|---|
11//! | `znippy-plugin-git` | `plugins/native/znippy-plugin-git` (this repo) | Arrow-IPC in a znippy archive (OpenZL) |
12//! | `storage-git-gix` | `edda/crates/storage-git-gix` | `gix-odb` / `gix-ref` on a filesystem |
13//!
14//! # Why this crate is tiny, and must stay that way
15//!
16//! It depends on `anyhow` and nothing else. That is load-bearing: the gix
17//! backend must be able to implement this contract **without** linking Arrow or
18//! OpenZL's C++ toolchain, and the znippy backend must not have to link `gix`.
19//! A dependency added here is a dependency forced on both.
20//!
21//! # "Eleven", not "twelve" — where `seal()` went
22//!
23//! The original `GitOps` in znippy had a twelfth method, `seal() ->
24//! Vec<ReservedSection>`, that folds the live logs into the reserved **Arrow**
25//! sections a znippy archive carries. `ReservedSection` is an Arrow type
26//! (`RecordBatch` payloads); a gix backend has no such sections and no analog.
27//! So `seal()` is an implementation detail of the znippy backend, not part of
28//! the neutral contract — it stays an inherent method on `GitStore`, which is
29//! how gunnar already calls it (on the concrete store, never through this
30//! trait). Keeping it here would have forced Arrow onto the gix backend for a
31//! method it can never implement.
32
33use std::path::PathBuf;
34
35use anyhow::Result;
36
37/// An object id, borrowed.
38pub type Oid<'a> = &'a [u8];
39
40/// A byte range inside the store: `(offset, len)`.
41///
42/// The unit is "wherever this backend keeps the bytes" — an extent in a znippy
43/// archive, or an offset into a packfile for a gix backend. The contract only
44/// promises the pair is stable for the life of the object.
45pub type Extent = (u64, u64);
46
47/// The type of a stored object, as it appears **in the pack** — so it may be a
48/// delta (`OfsDelta`/`RefDelta`) rather than a resolved type.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50#[repr(u8)]
51pub enum ObjType {
52 Commit = 1,
53 Tree = 2,
54 Blob = 3,
55 Tag = 4,
56 OfsDelta = 6,
57 RefDelta = 7,
58}
59
60impl ObjType {
61 pub fn code(self) -> u8 {
62 self as u8
63 }
64
65 /// `None` for 0, 5 and anything above 7 — a wrong type in an index is worse
66 /// than an honest failure, because it is queried and believed.
67 pub fn from_code(c: u8) -> Option<Self> {
68 Some(match c {
69 1 => ObjType::Commit,
70 2 => ObjType::Tree,
71 3 => ObjType::Blob,
72 4 => ObjType::Tag,
73 6 => ObjType::OfsDelta,
74 7 => ObjType::RefDelta,
75 _ => return None,
76 })
77 }
78
79 pub fn as_str(self) -> &'static str {
80 match self {
81 ObjType::Commit => "commit",
82 ObjType::Tree => "tree",
83 ObjType::Blob => "blob",
84 ObjType::Tag => "tag",
85 ObjType::OfsDelta => "ofs-delta",
86 ObjType::RefDelta => "ref-delta",
87 }
88 }
89
90 /// The six codes, for exhaustive tests and for generators.
91 pub const ALL: [ObjType; 6] = [
92 ObjType::Commit,
93 ObjType::Tree,
94 ObjType::Blob,
95 ObjType::Tag,
96 ObjType::OfsDelta,
97 ObjType::RefDelta,
98 ];
99}
100
101/// The receipt for one durable transaction.
102///
103/// Not an opaque counter: a push spans **two** durable logs — the one that
104/// claims the pack's extent and the one whose frame *is* the ref transaction —
105/// and a caller that has to prove a push landed needs a coordinate in each.
106/// Every field is applied output, read back from what was written.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
108pub struct TxId {
109 /// Dense id the push path assigned this pack, and the key the indexed bit is
110 /// kept under. `None` when the transaction carried no pack.
111 pub pack_id: Option<u64>,
112 /// Where the verbatim pack bytes are, as the journal row records them.
113 pub extent: Option<Extent>,
114 /// The ref log's ordering authority for the ref namespace. `None` when the
115 /// transaction carried no ref update.
116 pub push_seq: Option<u64>,
117}
118
119/// One row of the ref namespace: name, oid, peeled.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct RefRow {
122 pub name: String,
123 /// Raw oid. `None` for a purely symbolic ref such as `HEAD`.
124 pub oid: Option<Vec<u8>>,
125 /// For an annotated tag, the commit it peels to.
126 pub peeled: Option<Vec<u8>>,
127 /// For a symbolic ref, what it points at.
128 pub symref_target: Option<String>,
129}
130
131/// One ref update in a push.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct RefUpdate {
134 pub name: String,
135 pub target: Option<String>,
136 pub peeled: Option<String>,
137 pub symref_target: Option<String>,
138}
139
140impl RefUpdate {
141 /// Set `name` to `target`.
142 pub fn set(name: impl Into<String>, target: impl Into<String>) -> Self {
143 Self {
144 name: name.into(),
145 target: Some(target.into()),
146 peeled: None,
147 symref_target: None,
148 }
149 }
150
151 /// Delete `name`.
152 pub fn delete(name: impl Into<String>) -> Self {
153 Self {
154 name: name.into(),
155 target: None,
156 peeled: None,
157 symref_target: None,
158 }
159 }
160
161 /// Point the symbolic ref `name` at `points_to`.
162 pub fn symbolic(name: impl Into<String>, points_to: impl Into<String>) -> Self {
163 Self {
164 name: name.into(),
165 target: None,
166 peeled: None,
167 symref_target: Some(points_to.into()),
168 }
169 }
170
171 /// Attach the peeled target of an annotated tag.
172 pub fn with_peeled(mut self, peeled: impl Into<String>) -> Self {
173 self.peeled = Some(peeled.into());
174 self
175 }
176}
177
178/// One ref edit **with the value the caller expects to find**.
179///
180/// This is the field [`RefUpdate`] lacks, and its absence is the whole reason
181/// `git push --atomic` could not be expressed: [`GitOps::put_refs`] is a batch
182/// *without* compare-and-swap and [`GitOps::update_ref`] is compare-and-swap
183/// *without* a batch, and `--atomic` is defined as both at once. Neither
184/// composes into the other — applying a batch one CAS at a time is exactly the
185/// partial application `--atomic` promises never to happen.
186///
187/// Borrowed, like every other oid in this contract: a receive-pack command line
188/// is `<old> <new> <ref>` and both oids are already slices of the pkt-line the
189/// caller is holding.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct RefCas<'a> {
192 pub name: String,
193 /// What the caller believes is there. **`None` means *must not exist*** — a
194 /// create that must fail if anything is already at `name`.
195 ///
196 /// There is deliberately no third "don't care" state. receive-pack always
197 /// names an old value (all-zeroes for a create), so a caller that genuinely
198 /// does not care wants [`GitOps::put_refs`], which is the batch that makes no
199 /// claim about the previous value.
200 pub old: Option<Oid<'a>>,
201 /// The new value. **`None` deletes.**
202 pub new: Option<Oid<'a>>,
203}
204
205/// One ref target, backend-neutrally: an object, or another ref.
206///
207/// Named separately from [`RefRow`] because this is what was *observed* at one
208/// instant during a failed compare-and-swap, not a row of the namespace.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum RefTarget {
211 /// A raw oid.
212 Object(Vec<u8>),
213 /// A symbolic ref: the full name it points at.
214 Symbolic(String),
215}
216
217/// What was actually there — **including** the case where something was there
218/// and could not be decoded.
219///
220/// Restored from `gunnar-store`'s deleted `Error` type, and the three-state is
221/// the point. The two call sites that produce it in the gix arm used to write
222/// `from_gix_target(actual).ok()`, which turned a reference that existed and
223/// could not be read into `None` — reported to the pushing client as *"nothing
224/// was there"*. A backend that reports no observed value for a rejection
225/// answers [`Observed::Nothing`], which is *"the honest answer rather than a
226/// dropped one"*, and a backend that found bytes it could not parse answers
227/// [`Observed::Unreadable`]. Collapsing those two is the defect this enum
228/// exists to make impossible.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum Observed {
231 /// The ref did not exist.
232 Nothing,
233 /// It existed and held this.
234 Value(RefTarget),
235 /// It existed, and the backend could not decode what it held.
236 Unreadable(String),
237}
238
239impl Observed {
240 /// An expectation of "must not exist", as an [`Observed`].
241 pub fn absent() -> Self {
242 Observed::Nothing
243 }
244
245 /// An oid, as an [`Observed`].
246 pub fn oid(raw: &[u8]) -> Self {
247 Observed::Value(RefTarget::Object(raw.to_vec()))
248 }
249
250 pub fn is_unreadable(&self) -> bool {
251 matches!(self, Observed::Unreadable(_))
252 }
253}
254
255/// Lowercase hex, written out because this crate depends on `anyhow` and
256/// nothing else and a rejection has to be printable.
257fn hex_into(f: &mut std::fmt::Formatter<'_>, raw: &[u8]) -> std::fmt::Result {
258 for b in raw {
259 write!(f, "{b:02x}")?;
260 }
261 Ok(())
262}
263
264impl std::fmt::Display for Observed {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 match self {
267 Observed::Nothing => f.write_str("nothing"),
268 Observed::Value(RefTarget::Object(raw)) => hex_into(f, raw),
269 Observed::Value(RefTarget::Symbolic(name)) => write!(f, "ref: {name}"),
270 Observed::Unreadable(why) => {
271 write!(f, "a value that could not be decoded: {why}")
272 }
273 }
274 }
275}
276
277/// **Why a ref write was refused, typed.**
278///
279/// # Why this exists at all
280///
281/// `Error::is_cas_failure()` / `is_lock_contention()` died with `RefStore`, and
282/// receive-pack has to tell a client *which* ref lost the race and what was
283/// there instead — **never by sniffing an error string**. `gunnar-store`'s
284/// `error.rs` records the loss verbatim: this type *"used to carry
285/// `RefCas { name, expected, actual }` and the `Observed` three-state beside
286/// it"*. This is that, restored, in the one crate both backends already share.
287///
288/// # How it travels, and why the crate is still `anyhow`-only
289///
290/// The trait methods keep returning `anyhow::Result`, so no signature in the
291/// frozen contract changes and no caller is forced to match on a storage error
292/// it does not care about. This type implements [`std::error::Error`] by hand —
293/// no `thiserror`, no new dependency — so a backend raises it with
294/// `anyhow::Error::new(rejection)` and a caller that *does* care recovers it
295/// with [`RefRejection::of`]. The tiny-crate property is about not forcing one
296/// backend's dependencies onto the other; it was never about refusing to name
297/// this crate's own errors.
298///
299/// # The two variants, and why `Locked` is one of them
300///
301/// From the gix arm's classifier, whose comment is the argument: a lock this
302/// writer could not take is *"**TYPED, not a `Backend`**: it is transient and
303/// the one control-document writer retries it. Left as an opaque backend error
304/// it read to a caller as a broken disk."*
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum RefRejection {
307 /// A compare-and-swap lost: `name` did not hold what the caller expected,
308 /// and **nothing was written**.
309 ///
310 /// `expected` is the caller's claim ([`Observed::Nothing`] for a
311 /// must-not-exist create); `actual` is what the backend found.
312 Cas {
313 name: String,
314 expected: Observed,
315 actual: Observed,
316 },
317 /// A lock on `name` this writer could not take. Transient — the caller
318 /// retries. Not a fault, and specifically not a broken disk.
319 Locked { name: String },
320}
321
322impl RefRejection {
323 /// The ref this is about.
324 pub fn name(&self) -> &str {
325 match self {
326 RefRejection::Cas { name, .. } | RefRejection::Locked { name } => name,
327 }
328 }
329
330 /// *"Another push beat you to it"* — a lost race, not a fault.
331 pub fn is_cas_failure(&self) -> bool {
332 matches!(self, RefRejection::Cas { .. })
333 }
334
335 /// *"Try again"* — transient contention on the one writer.
336 pub fn is_lock_contention(&self) -> bool {
337 matches!(self, RefRejection::Locked { .. })
338 }
339
340 /// Recover a rejection from an [`anyhow::Error`] a backend raised.
341 ///
342 /// **This is the only sanctioned way to ask, and it is a named function so
343 /// that no caller ever spells `downcast_ref` — or, worse, matches on the
344 /// message text.** It walks the context chain, so a backend is free to add
345 /// `.context(..)` above the rejection without hiding it.
346 pub fn of(err: &anyhow::Error) -> Option<&RefRejection> {
347 err.chain().find_map(|e| e.downcast_ref::<RefRejection>())
348 }
349}
350
351impl std::fmt::Display for RefRejection {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 match self {
354 RefRejection::Cas {
355 name,
356 expected,
357 actual,
358 } => write!(
359 f,
360 "compare-and-swap on {name} failed: it is {actual}, the caller expected \
361 {expected} — nothing was written"
362 ),
363 RefRejection::Locked { name } => write!(
364 f,
365 "{name} is locked by another writer — transient, retry; this is not a \
366 backend fault"
367 ),
368 }
369 }
370}
371
372impl std::error::Error for RefRejection {}
373
374/// What `get` hands back: the **stored** bytes and enough type information that
375/// they cannot be mistaken for something else.
376///
377/// `obj_type` is the entry's type *in the pack*, so it may be `OfsDelta` or
378/// `RefDelta`. That is deliberate and it is the whole reason this is a struct and
379/// not a `Vec<u8>`: the verbatim bytes are the truth and the resolved object a
380/// derived cache, and a caller handed delta bytes labelled "the object" would
381/// silently store garbage. Labelled a delta, it cannot.
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct Stored {
384 pub obj_type: ObjType,
385 /// Post-resolution size — what the object inflates to once its chain is
386 /// applied, which is not `bytes.len()` for a delta.
387 pub uncompressed_size: u64,
388 /// Where these bytes live.
389 pub extent: Extent,
390 /// The bytes as stored: the pack entry, header and all, byte for byte as the
391 /// client sent them.
392 pub bytes: Vec<u8>,
393}
394
395/// What one GC run did. Every field is measured off the store after the fact —
396/// nothing here is an input echoed back.
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct GcReport {
399 /// The name of the implementation that produced this.
400 pub strategy: &'static str,
401 /// The store that serves after the run.
402 pub archive: PathBuf,
403 /// The generation that was removed once the new one was proven. `None` for
404 /// an in-place compaction that has no distinct old file to remove.
405 pub retired: Option<PathBuf>,
406 pub bytes_before: u64,
407 pub bytes_after: u64,
408 /// Live entries carried across. A GC never changes this.
409 pub rows: u64,
410 /// Delta-map rows carried across. Also never changed.
411 pub delta_rows: u64,
412 /// Whether the result was read back and checked. `false` does not mean it
413 /// failed — an in-place compaction verifies *after* it has committed.
414 pub verified: bool,
415 /// Packs whose **every** object this run found dead, and whose rows it
416 /// therefore tombstoned.
417 pub retired_packs: u64,
418}
419
420/// **The eleven.** Typed, backend-neutral, and the only entry point a git server
421/// needs into storage.
422///
423/// Implemented by znippy's `GitStore` (Arrow-IPC) and by `storage-git-gix`
424/// (gix). No method invents storage, an index, a durability contract or a
425/// concurrency mechanism; if one looks like it does, that is the bug.
426/// `Send + Sync` is a supertrait because every consumer shares one store across
427/// threads and cannot do otherwise. gunnar's `RepoStore` is `Send + Sync` — the
428/// server holds one per repository and serves many connections from it at once —
429/// so a `RepoStore` holding an `Arc<dyn GitOps>` does not compile without this
430/// (`E0277` at every implementor). The traits `GitOps` replaced, `ObjectStore`
431/// and `RefStore`, both carried it; dropping it here was an oversight of the
432/// extraction, not a decision.
433///
434/// It costs the implementers nothing: both already satisfy it. The Arrow arm is
435/// built around a per-account indexer thread and declares `ArchiveWrite: Send +
436/// Sync` and `ObjectAbsorb: Send + Sync` itself; the gix arm shares a pooled odb
437/// handle across workers. The alternative — spelling `dyn GitOps + Send + Sync`
438/// at every use site — is viral, and a consumer who forgets it gets a different
439/// type rather than an error at the definition.
440pub trait GitOps: Send + Sync {
441 // ── STORE ───────────────────────────────────────────────────────────────
442
443 /// One push: pack bytes and ref updates, durable before this returns.
444 ///
445 /// The order inside it is the contract: the pack's bytes durable **first**,
446 /// then the refs that point into them. A crash between the two leaves
447 /// objects nobody points at (a GC reclaims them); the reverse order leaves a
448 /// ref pointing at objects that are not there, which no later pass repairs.
449 fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> Result<TxId>;
450
451 /// The pack half of [`put`](GitOps::put), on its own.
452 fn put_pack(&self, bytes: &[u8]) -> Result<TxId>;
453
454 /// The ref half of [`put`](GitOps::put), on its own.
455 fn put_refs(&self, updates: &[RefUpdate]) -> Result<TxId>;
456
457 // ── READ ────────────────────────────────────────────────────────────────
458
459 /// The stored bytes of one object.
460 fn get(&self, oid: Oid<'_>) -> Result<Option<Stored>>;
461
462 /// Is this object here? The negotiation call, made a thousand at a time.
463 fn has(&self, oid: Oid<'_>) -> Result<bool>;
464
465 /// Post-resolution size.
466 fn size(&self, oid: Oid<'_>) -> Result<Option<u64>>;
467
468 /// Byte extents, in bulk — the wire path.
469 fn extents(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<Extent>>>;
470
471 // ── REFS ────────────────────────────────────────────────────────────────
472
473 /// The current ref namespace.
474 fn refs(&self) -> Result<Vec<RefRow>>;
475
476 /// Compare-and-swap one ref.
477 fn update_ref(&self, name: &str, old: Option<Oid<'_>>, new: Option<Oid<'_>>) -> Result<TxId>;
478
479 /// **Apply every edit or none.** The `git push --atomic` primitive.
480 ///
481 /// The third shape, not a replacement: [`put_refs`](GitOps::put_refs) (batch,
482 /// no CAS) and [`update_ref`](GitOps::update_ref) (CAS, one ref) both stay,
483 /// and both have callers that want exactly what they are.
484 ///
485 /// Returns `Err` naming the **first** edit whose `old` did not match, and
486 /// applies **nothing**. That error carries a [`RefRejection`], recoverable
487 /// with [`RefRejection::of`] — receive-pack must be able to tell a client
488 /// which ref lost the race and what was there instead, and it must never do
489 /// that by reading a message.
490 ///
491 /// # Every expectation is checked BEFORE anything is applied — `S-023`
492 ///
493 /// Not a stylistic preference; it is the one implementation note this method
494 /// carries, and it is a real defect found in a real backend. gix's file ref
495 /// store **short-circuits an edit whose new value equals the value the
496 /// reference already holds**: it rewrites the expectation to
497 /// `MustExistAndMatch(current)` and never evaluates the one the caller
498 /// wrote. For an `old: None` — *must not exist* — that turns a create that
499 /// must fail into a **silent success**, so "exactly one creator wins", the
500 /// property receive-pack arbitrates two racing pushes with, was not true on
501 /// the only backend that survives a restart.
502 ///
503 /// So an implementation compares against **one snapshot of the namespace,
504 /// taken once, before the batch reaches the backend's own writer.**
505 ///
506 /// # There is no third state
507 ///
508 /// An empty batch is a no-op that succeeds, not an error: a deletions-free
509 /// push with nothing to apply calls this, and refusing it would make the
510 /// caller special-case the empty case at every site.
511 fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> Result<TxId>;
512
513 // ── GRAPH ───────────────────────────────────────────────────────────────
514
515 /// `want` minus `have`, the object closure a fetch must send.
516 fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Vec<Vec<u8>>>;
517
518 // ── MAINT ───────────────────────────────────────────────────────────────
519
520 /// Reachability, drop the dead index rows, then compact.
521 fn gc(&self) -> Result<GcReport>;
522}
523
524// ── the reading contract ─────────────────────────────────────────────────────
525
526/// What the client said it can parse, as far as **pack emission** is concerned.
527///
528/// Deliberately not "the capability line": a capability that does not change
529/// which bytes come out of [`GitServe::emit_pack`] does not belong here, and
530/// side-band, progress, agent strings and shallow negotiation all change the
531/// *transport*, which is the caller's.
532///
533/// **There is no `Default`, on purpose.** A caller that forgets a field would
534/// otherwise send a pack the client cannot parse, and it would look like a
535/// working server producing a corrupt clone — the failure nobody notices. Both
536/// fields are stated at every call site or the code does not compile. Use
537/// [`Caps::modern`] in a test that does not care.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub struct Caps {
540 /// The client accepts a **thin** pack: one whose deltas may name bases it
541 /// already holds and this pack does not carry.
542 ///
543 /// It is an *allowance*, never a requirement. A backend that always closes
544 /// its emission over every delta base is correct for `thin: true` as well —
545 /// it costs bytes, never correctness — so ignoring this flag is a
546 /// performance decision an implementation may take and should document.
547 pub thin: bool,
548 /// The client accepts `OFS_DELTA` entries (bases named by backwards
549 /// distance). Every git since 1.5.5 advertises it.
550 ///
551 /// When this is `false` a backend that stores entries verbatim **cannot
552 /// copy** an `OFS_DELTA` out: the distance means nothing to a reader that
553 /// will not parse it. The honest answers are to rewrite the entry as a
554 /// `REF_DELTA` or to refuse. Silently emitting one anyway is not an answer.
555 pub ofs_delta: bool,
556}
557
558impl Caps {
559 /// What every git client made this century advertises. For tests and for a
560 /// caller that has already validated the capability line.
561 pub fn modern() -> Self {
562 Caps {
563 thin: false,
564 ofs_delta: true,
565 }
566 }
567}
568
569/// The receipt for one [`GitServe::emit_pack`].
570///
571/// **`copied` and `recompressed` are the point** — the `P-001` applied-output
572/// assertion. A byte count and a wall clock cannot tell a pack-copy from a
573/// re-deflate: both produce a pack that passes `git index-pack --strict` and
574/// `git fsck`, and the slow one is only visible as a number nobody was
575/// recording. Both engines' `git-store-serve` already emit exactly this
576/// receipt, so a head-to-head stays a comparison rather than two anecdotes.
577#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
578pub struct PackStats {
579 /// Bytes written to `out`, trailer included.
580 pub bytes: u64,
581 /// Entries in the emitted pack.
582 pub objects: u64,
583 /// Entries whose stored payload went out **byte for byte**. Nothing was
584 /// inflated and nothing was deflated for these.
585 pub copied: u64,
586 /// Entries whose payload was re-deflated. On a store that keeps pack
587 /// entries verbatim this must be zero, and it is counted rather than
588 /// assumed for exactly that reason.
589 pub recompressed: u64,
590}
591
592/// **Many raw oids in ONE allocation.**
593///
594/// A `Vec<Vec<u8>>` of oids is one heap allocation *per object*, and the two
595/// things a store does with a repository-sized oid set — hand it across this
596/// contract, and ask it "do you hold this" — need neither. This is the flat
597/// form: `len × oid_len` bytes in a single buffer, iterated as slices.
598///
599/// # Why it is here and not in either engine
600///
601/// Because both engines cross this seam with one. MEASURED on oden 2026-08-15,
602/// the znippy engine serving a 69-object negotiated fetch out of the `nornir`
603/// mirror (12 303 objects, `have` closure 12 112): [`ReachSet::client_has`]
604/// alone was **12 112 `String`s and 12 112 `Vec<u8>`s per request** on the way
605/// through a hex round-trip, for a voucher whose only consumers ask its length
606/// and its membership. The gix engine pays the smaller half of the same bill —
607/// one `Vec<u8>` per `ObjectId` — and it pays it for the same reason: the
608/// contract's type demanded one. Fixing it in one engine and not the other
609/// would be the twinning LAW 5 forbids.
610///
611/// # What it is not
612///
613/// Not a set: [`contains`](OidList::contains) is a linear scan, stated rather
614/// than hidden, because this type's job is *carrying* oids cheaply. A consumer
615/// that asks membership per object builds its own index off
616/// [`iter`](OidList::iter) — a `HashSet` of fixed-size keys costs one
617/// allocation for the table and none per entry, which is the whole point.
618///
619/// Not sorted, and not deduplicated: it preserves exactly what was pushed.
620#[derive(Debug, Clone, PartialEq, Eq, Default)]
621pub struct OidList {
622 /// `len * oid_len` bytes, concatenated in push order.
623 bytes: Vec<u8>,
624 /// The width of one oid — 20 for SHA-1, 32 for SHA-256.
625 ///
626 /// **Zero exactly when the list is empty**, which is what keeps
627 /// [`Default`] equal to an empty list built by any engine at any width. A
628 /// list that has never been pushed to has no width to disagree about.
629 oid_len: usize,
630}
631
632impl OidList {
633 /// An empty list. Its width is decided by the first [`push`](Self::push).
634 pub fn new() -> Self {
635 Self::default()
636 }
637
638 /// An empty list with room for `oids` oids of `oid_len` bytes, in one
639 /// allocation. The width is still decided by the first push — this only
640 /// reserves.
641 pub fn with_capacity(oids: usize, oid_len: usize) -> Self {
642 OidList {
643 bytes: Vec::with_capacity(oids * oid_len),
644 oid_len: 0,
645 }
646 }
647
648 /// Append one raw oid.
649 ///
650 /// The first push fixes the width; a later one of a different width is an
651 /// error rather than a silently reinterpreted buffer, because every
652 /// accessor here is `len`-strided and a mixed list would hand back oids
653 /// that never existed.
654 pub fn push(&mut self, oid: &[u8]) -> Result<()> {
655 if self.bytes.is_empty() {
656 anyhow::ensure!(
657 !oid.is_empty(),
658 "an empty oid has no width, and a list of them would report a length of zero \
659 objects while holding some"
660 );
661 self.oid_len = oid.len();
662 } else if oid.len() != self.oid_len {
663 anyhow::bail!(
664 "this oid list is {}-byte oids and was handed a {}-byte one; a mixed-width list \
665 cannot be read back",
666 self.oid_len,
667 oid.len()
668 );
669 }
670 self.bytes.extend_from_slice(oid);
671 Ok(())
672 }
673
674 /// Every oid, in push order, borrowed out of the one buffer.
675 pub fn iter(&self) -> impl ExactSizeIterator<Item = &[u8]> + '_ {
676 self.bytes.chunks_exact(self.oid_len.max(1))
677 }
678
679 /// How many oids — **not** how many bytes.
680 pub fn len(&self) -> usize {
681 if self.oid_len == 0 {
682 0
683 } else {
684 self.bytes.len() / self.oid_len
685 }
686 }
687
688 /// Whether it names nothing. For a voucher this is the clone case.
689 pub fn is_empty(&self) -> bool {
690 self.bytes.is_empty()
691 }
692
693 /// The width of one oid, or 0 for an empty list.
694 pub fn oid_len(&self) -> usize {
695 self.oid_len
696 }
697
698 /// **A linear scan.** Fine for a guard and for a handful of probes; wrong
699 /// in a loop over a request. See the type's header.
700 pub fn contains(&self, oid: &[u8]) -> bool {
701 oid.len() == self.oid_len && self.iter().any(|o| o == oid)
702 }
703}
704
705impl<T: AsRef<[u8]>> FromIterator<T> for OidList {
706 /// Collect from anything oid-shaped — `Vec<u8>`, `&[u8]`, a gix
707 /// `ObjectId`'s bytes.
708 ///
709 /// A width disagreement **panics** here, because a `FromIterator` cannot
710 /// fail and an engine that mixes hash kinds inside one repository has a
711 /// bigger problem than this list. Use [`push`](OidList::push) where the
712 /// input is not the store's own index.
713 fn from_iter<I: IntoIterator<Item = T>>(items: I) -> Self {
714 let mut out = OidList::new();
715 for item in items {
716 out.push(item.as_ref())
717 .expect("one repository holds one hash kind");
718 }
719 out
720 }
721}
722
723/// One [`GitServe::select`] answer.
724#[derive(Debug, Clone, PartialEq, Eq, Default)]
725pub struct ReachSet {
726 /// Every object to send, as raw oids.
727 ///
728 /// **Order is not part of the contract.** Measured 2026-08-08 on a
729 /// 154-object fixture: sorted, unsorted and deliberately reversed all
730 /// produce `copied=154 recompressed=0` and the identical object graph,
731 /// because the emitter orders its own output.
732 pub objects: Vec<Vec<u8>>,
733 /// Which of `objects` are commits. Not derivable by the caller without a
734 /// header read per object; the store knows it for free.
735 pub commits: Vec<Vec<u8>>,
736 /// The closure of `have`: what the receiver held **before** this transfer,
737 /// never an object this transfer carries. The voucher a thin delta may name
738 /// a base from. Empty for a clone.
739 ///
740 /// **An [`OidList`] and not a `Vec<Vec<u8>>`, because this one is
741 /// repository-sized.** `objects` is what the transfer carries — small for a
742 /// fetch — but the voucher is the closure of what the client already had,
743 /// which on an incremental fetch is very nearly the whole repository: 12 112
744 /// oids to answer a 69-object request, measured on oden 2026-08-15. One
745 /// allocation per oid of that is a per-request cost that buys nothing, in
746 /// both engines. See [`OidList`].
747 pub client_has: OidList,
748}
749
750/// **The READING contract.**
751///
752/// Two consumers, not one: `gunnar-wire` serves from it, and `gunnar-policy`'s
753/// branch-protection ancestry walk and its signature gate read from it.
754/// [`emit_pack`](GitServe::emit_pack) and [`select`](GitServe::select) are only
755/// its *serving* half — this is not a wire trait, which is why it is not called
756/// one.
757///
758/// # Why it is a second trait and not eleven more methods on `GitOps`
759///
760/// [`GitOps`] provably cannot serve the wire: [`Stored::bytes`] is *"the pack
761/// entry, header and all, byte for byte as the client sent them"* — **possibly
762/// a delta** — and no method on the eleven returns an inflated object. Widening
763/// `GitOps` to cover that would stop it being a storage contract and make it a
764/// git-server API, and the second engine would carry serving methods it answers
765/// badly. So `GitOps` stays narrow and this rides above it.
766///
767/// `GitServe: GitOps`, so a `dyn GitServe` is also a `dyn GitOps` and a
768/// repo-resolution seam does not have to fork. `Send + Sync` comes with the
769/// supertrait and is required for the same reason.
770///
771/// # The ordering rule, and it is permanent
772///
773/// This trait is defined by what the **Arrow arm** needs to serve a clone well;
774/// the gix arm then implements it. **Never the reverse.** A method that only
775/// makes sense against a `.idx`, a `.bitmap` or an `objects/pack` directory is
776/// wrong by construction.
777///
778/// # Blocking
779///
780/// Every method blocks. Never call one from an async task without
781/// `tokio::task::spawn_blocking`, and size that pool deliberately.
782pub trait GitServe: GitOps {
783 /// The object, **INFLATED and delta-resolved** — what [`GitOps::get`]
784 /// deliberately is not.
785 ///
786 /// The returned [`ObjType`] is the **resolved** kind and is therefore never
787 /// `OfsDelta` or `RefDelta`. This is what `Graph::load` walks, and what
788 /// `gunnar-policy` parses a commit out of.
789 fn read(&self, oid: Oid<'_>) -> Result<Option<(ObjType, Vec<u8>)>>;
790
791 /// Kind and post-resolution size **without the payload**. The type probe.
792 ///
793 /// Kept apart from [`read`](GitServe::read) because an object ceiling and a
794 /// typed listing need this and nothing else, and a store that keeps the
795 /// header out of line answers it without touching a byte of content. Same
796 /// resolved-kind guarantee as `read`.
797 fn header(&self, oid: Oid<'_>) -> Result<Option<(ObjType, u64)>>;
798
799 /// Bulk post-resolution size. The negotiation path calls it a thousand at a
800 /// time.
801 ///
802 /// Positional: `out[i]` answers `oids[i]`, and `None` at a position means
803 /// **unknown — go ask [`header`](GitServe::header)**, never "fine". A caller
804 /// treating an unknown size as under a ceiling would skip the ceiling for
805 /// exactly the objects a push had just introduced.
806 ///
807 /// There is no `Ok(None)` "I have no bulk path" hatch, because the arm this
808 /// contract is designed for has one: `sizes` is a single index pass, and it
809 /// is what collapses a measured 34 124 per-object header walks on one clone.
810 /// A backend without a bulk path loops over `header` and says so in its own
811 /// docs.
812 fn sizes(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<u64>>>;
813
814 /// `HEAD`.
815 ///
816 /// **`HEAD` is NOT a row in [`GitOps::refs`]**, and that is the contract
817 /// rather than an accident: a ref stream walks `refs/` and *"deliberately
818 /// excludes the pseudo-refs such as `HEAD`"*. The reason is a type
819 /// constraint, not taste — a name type that admits `HEAD` also admits
820 /// `MERGE_HEAD` and `FETCH_HEAD`, so putting pseudo-refs in the row stream
821 /// means either widening the name type or filtering at every consumer.
822 ///
823 /// So it gets an accessor pair instead, and this is half of it. `None` means
824 /// the repository has no `HEAD` — an empty repository does not.
825 fn head(&self) -> Result<Option<RefRow>>;
826
827 /// Point `HEAD`. The other half of the pair; `point_head_at` is the live
828 /// consumer.
829 fn set_head(&self, target: &str) -> Result<TxId>;
830
831 /// **Emit a packfile containing exactly `objects`**, deduplicated,
832 /// honouring `caps`, onto `out`.
833 ///
834 /// # "exactly" means exactly. `stats.objects == objects.len()`, deduplicated
835 ///
836 /// # 🔴 This paragraph used to say the opposite, and both engines obeyed it
837 ///
838 /// It read: *"Do not assert `stats.objects == objects.len()`. Delta-base
839 /// closure is a **format** requirement — an `OFS_DELTA` names its base by
840 /// in-pack distance, so a pack containing a delta must contain its base —
841 /// and the emitter adds those. `stats.objects` is therefore `objects.len()`
842 /// plus whatever bases the format forced, and that is correct, not an
843 /// over-send."*
844 ///
845 /// It is correct about the pack format and wrong about the clone, and every
846 /// engine that implemented it shipped broken clones — the Arrow arm until
847 /// 2026-08-11, the gix arm until 2026-08-14, and gunnar's own in-memory
848 /// control until the same day. Two things go wrong and only the second is
849 /// loud:
850 ///
851 /// * a base pulled in is **an object the client did not ask for**, so a
852 /// `--filter=blob:none` or `--depth=N` fetch is served back precisely what
853 /// it asked to be left out — the same over-send this method's `objects`
854 /// parameter was renamed to prevent, arriving by delta links instead of
855 /// graph links;
856 /// * and if the base is a **tree**, it arrives owing children the pack does
857 /// not contain. `git index-pack --check-self-contained-and-connected` —
858 /// which is what `git clone` runs — walks every received object's links
859 /// and then demands each one exist, so the transfer dies with
860 /// `fatal: did not receive expected object <oid>` while the server logs a
861 /// success.
862 ///
863 /// MEASURED on gunnar's gix arm, 2026-08-14, over its real upload-pack: a
864 /// repository of 208 objects with 202 reachable from its one ref served
865 /// `selected=202 objects=203 copied=203 recompressed=0` and the clone was
866 /// refused. Any store that has ever refused a ref update holds unreachable
867 /// objects — a lost compare-and-swap, a reset branch, a `git fast-import` —
868 /// and `pack-objects` stores the newest version of a path whole and the
869 /// older ones as deltas against it, so after a reset the still-reachable
870 /// object is routinely a delta against one that is not. This is an ordinary
871 /// repository, not a corner.
872 ///
873 /// # The rule, which is stock `pack-objects`'
874 ///
875 /// **Reuse a stored delta only when its base is also being packed.** The
876 /// base decides how an entry is *encoded*; it never decides what the pack
877 /// *contains*:
878 ///
879 /// * base inside `objects` → copy the stored entry, re-heading it into
880 /// whichever spelling `caps` allows;
881 /// * base outside `objects` → **rebuild the object whole** and count it in
882 /// [`PackStats::recompressed`], or — for a *fetch* whose `have` vouches
883 /// for the base — name it in a `REF_DELTA` the client can resolve.
884 ///
885 /// So `recompressed` is not a literal `0`. It is 0 for a whole-repository
886 /// clone of a store with no unreachable history, because such a request
887 /// contains every base, and non-zero exactly on the boundary a narrowed or
888 /// unreachable-adjacent request cuts.
889 ///
890 /// The property this has always asserted, unchanged: **no object is added
891 /// because the engine walked the graph.** Now nothing is added at all.
892 ///
893 /// # `objects` is a SET TO EMIT, not a set of tips to close over
894 ///
895 /// This parameter was called `want` until 2026-08-10 and the rename is the
896 /// contract, not cosmetics. **The engine must not compute a closure here.**
897 ///
898 /// Upload-pack's caller holds `selection.objects`, which is already
899 /// **post-filter, post-shallow and post-`include-tag`**, and is *deliberately
900 /// not closed*. An engine that treats it as tips and closes over it **adds
901 /// back exactly what `--filter=blob:none` or `--depth=N` excluded.**
902 ///
903 /// That bug is invisible to every guard we have: the over-sent pack passes
904 /// `index-pack --strict` **and** `fsck`, the clone succeeds, and the client
905 /// simply receives objects it asked not to have. It is `P-027`'s shape — a
906 /// change no test can see — which is why the parameter is named for what it
907 /// is. `select` owns the close-over-tips half; this does not.
908 ///
909 /// `have` is different in kind: those *are* the negotiated common **tips**,
910 /// used for thin-pack base selection, never to derive membership.
911 ///
912 /// The engine owns this because the engine owns the format. The Arrow arm
913 /// answers it as a **byte-range copy out of the archive**; a naive fallback
914 /// that inflates every object in order to deflate it again produces a pack
915 /// that passes `index-pack --strict` **and** `fsck` while sending a measured
916 /// **18.4x** the wire bytes. That is why [`PackStats`] carries `copied` and
917 /// `recompressed` and why they are counted rather than assumed.
918 ///
919 /// A backend that cannot honour `caps` **refuses**. It does not emit a pack
920 /// the client cannot parse, and it does not silently fall back to a
921 /// whole-object writer.
922 fn emit_pack(
923 &self,
924 objects: &[Oid<'_>],
925 have: &[Oid<'_>],
926 caps: &Caps,
927 out: &mut dyn std::io::Write,
928 ) -> Result<PackStats>;
929
930 /// Negotiation: `want` minus `have`, with the two derived facts a caller
931 /// cannot recompute cheaply.
932 ///
933 /// **`Ok(None)` means *"this engine cannot answer cheaply — walk it
934 /// yourself"*, and it is load-bearing.** It is the escape hatch and it must
935 /// stay: the Arrow arm returns `None` when its reachability projection is
936 /// empty, which a clean restart currently produces. Without it a backend
937 /// whose index does not cover a tip contributes only the tip, and the clone
938 /// is short by everything beneath it **while exiting zero**.
939 ///
940 /// Do not "improve" this into always answering. An engine that always
941 /// answers has to answer wrongly somewhere, and this is the shape of wrong
942 /// that no exit code and no `fsck` can see.
943 fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Option<ReachSet>>;
944}
945
946#[cfg(test)]
947mod tests {
948 use super::OidList;
949
950 /// The three properties the flat form has to have, each asserted on the
951 /// **read-back**: what went in comes out, at the right width, in order.
952 ///
953 /// A length check alone is the hollow version of this test — a list that
954 /// strided by the wrong width reports the same `len()` and hands back oids
955 /// that never existed — so the slices themselves are compared.
956 #[test]
957 fn a_flat_list_reads_back_exactly_what_was_pushed() {
958 let ids: Vec<Vec<u8>> = (0u8..5).map(|i| vec![i; 20]).collect();
959 let list: OidList = ids.iter().collect();
960 assert_eq!(list.len(), 5, "five 20-byte oids");
961 assert_eq!(list.oid_len(), 20);
962 let back: Vec<Vec<u8>> = list.iter().map(<[u8]>::to_vec).collect();
963 assert_eq!(back, ids, "push order and bytes, both");
964 assert!(list.contains(&[3u8; 20]));
965 assert!(!list.contains(&[9u8; 20]), "an oid nobody pushed");
966 // A 32-byte probe against a 20-byte list is not a truncated match.
967 assert!(!list.contains(&[0u8; 32]));
968 }
969
970 /// **The width guard, seen RED.** A mixed-width list cannot be read back at
971 /// all, so the second push is refused rather than accepted.
972 #[test]
973 fn mixing_hash_widths_in_one_list_is_refused() {
974 let mut list = OidList::new();
975 list.push(&[1u8; 20])
976 .expect("the first push sets the width");
977 let err = list
978 .push(&[2u8; 32])
979 .expect_err("a 32-byte oid in a 20-byte list must not be accepted");
980 assert!(
981 err.to_string().contains("mixed-width"),
982 "the refusal must say why: {err}"
983 );
984 assert_eq!(list.len(), 1, "the refused push must not have landed");
985 }
986
987 /// `Default` is an empty list of no width, which is what an engine that
988 /// vouched for nothing (a clone) produces — so the two must compare equal
989 /// whichever built them.
990 #[test]
991 fn an_empty_voucher_equals_the_default_one() {
992 let mut built = OidList::with_capacity(64, 20);
993 assert_eq!(built, OidList::default(), "nothing pushed, no width yet");
994 built.push(&[7u8; 20]).unwrap();
995 assert_ne!(built, OidList::default());
996 assert!(!built.is_empty());
997 }
998}