pub trait GitServe: GitOps {
// Required methods
fn read(&self, oid: &[u8]) -> Result<Option<(ObjType, Vec<u8>)>, Error>;
fn header(&self, oid: &[u8]) -> Result<Option<(ObjType, u64)>, Error>;
fn sizes(&self, oids: &[&[u8]]) -> Result<Vec<Option<u64>>, Error>;
fn head(&self) -> Result<Option<RefRow>, Error>;
fn set_head(&self, target: &str) -> Result<TxId, Error>;
fn emit_pack(
&self,
objects: &[&[u8]],
have: &[&[u8]],
caps: &Caps,
out: &mut dyn Write,
) -> Result<PackStats, Error>;
fn select(
&self,
want: &[&[u8]],
have: &[&[u8]],
) -> Result<Option<ReachSet>, Error>;
}Expand description
The READING contract.
Two consumers, not one: gunnar-wire serves from it, and gunnar-policy’s
branch-protection ancestry walk and its signature gate read from it.
emit_pack and select are only
its serving half — this is not a wire trait, which is why it is not called
one.
§Why it is a second trait and not eleven more methods on GitOps
GitOps provably cannot serve the wire: Stored::bytes is “the pack
entry, header and all, byte for byte as the client sent them” — possibly
a delta — and no method on the eleven returns an inflated object. Widening
GitOps to cover that would stop it being a storage contract and make it a
git-server API, and the second engine would carry serving methods it answers
badly. So GitOps stays narrow and this rides above it.
GitServe: GitOps, so a dyn GitServe is also a dyn GitOps and a
repo-resolution seam does not have to fork. Send + Sync comes with the
supertrait and is required for the same reason.
§The ordering rule, and it is permanent
This trait is defined by what the Arrow arm needs to serve a clone well;
the gix arm then implements it. Never the reverse. A method that only
makes sense against a .idx, a .bitmap or an objects/pack directory is
wrong by construction.
§Blocking
Every method blocks. Never call one from an async task without
tokio::task::spawn_blocking, and size that pool deliberately.
Required Methods§
Sourcefn read(&self, oid: &[u8]) -> Result<Option<(ObjType, Vec<u8>)>, Error>
fn read(&self, oid: &[u8]) -> Result<Option<(ObjType, Vec<u8>)>, Error>
The object, INFLATED and delta-resolved — what GitOps::get
deliberately is not.
The returned ObjType is the resolved kind and is therefore never
OfsDelta or RefDelta. This is what Graph::load walks, and what
gunnar-policy parses a commit out of.
Sourcefn header(&self, oid: &[u8]) -> Result<Option<(ObjType, u64)>, Error>
fn header(&self, oid: &[u8]) -> Result<Option<(ObjType, u64)>, Error>
Kind and post-resolution size without the payload. The type probe.
Kept apart from read because an object ceiling and a
typed listing need this and nothing else, and a store that keeps the
header out of line answers it without touching a byte of content. Same
resolved-kind guarantee as read.
Sourcefn sizes(&self, oids: &[&[u8]]) -> Result<Vec<Option<u64>>, Error>
fn sizes(&self, oids: &[&[u8]]) -> Result<Vec<Option<u64>>, Error>
Bulk post-resolution size. The negotiation path calls it a thousand at a time.
Positional: out[i] answers oids[i], and None at a position means
unknown — go ask header, never “fine”. A caller
treating an unknown size as under a ceiling would skip the ceiling for
exactly the objects a push had just introduced.
There is no Ok(None) “I have no bulk path” hatch, because the arm this
contract is designed for has one: sizes is a single index pass, and it
is what collapses a measured 34 124 per-object header walks on one clone.
A backend without a bulk path loops over header and says so in its own
docs.
Sourcefn head(&self) -> Result<Option<RefRow>, Error>
fn head(&self) -> Result<Option<RefRow>, Error>
HEAD.
HEAD is NOT a row in GitOps::refs, and that is the contract
rather than an accident: a ref stream walks refs/ and “deliberately
excludes the pseudo-refs such as HEAD”. The reason is a type
constraint, not taste — a name type that admits HEAD also admits
MERGE_HEAD and FETCH_HEAD, so putting pseudo-refs in the row stream
means either widening the name type or filtering at every consumer.
So it gets an accessor pair instead, and this is half of it. None means
the repository has no HEAD — an empty repository does not.
Sourcefn set_head(&self, target: &str) -> Result<TxId, Error>
fn set_head(&self, target: &str) -> Result<TxId, Error>
Point HEAD. The other half of the pair; point_head_at is the live
consumer.
Sourcefn emit_pack(
&self,
objects: &[&[u8]],
have: &[&[u8]],
caps: &Caps,
out: &mut dyn Write,
) -> Result<PackStats, Error>
fn emit_pack( &self, objects: &[&[u8]], have: &[&[u8]], caps: &Caps, out: &mut dyn Write, ) -> Result<PackStats, Error>
Emit a packfile containing exactly objects, deduplicated,
honouring caps, onto out.
§“exactly” means exactly. stats.objects == objects.len(), deduplicated
§🔴 This paragraph used to say the opposite, and both engines obeyed it
It read: “Do not assert stats.objects == objects.len(). Delta-base
closure is a format requirement — an OFS_DELTA names its base by
in-pack distance, so a pack containing a delta must contain its base —
and the emitter adds those. stats.objects is therefore objects.len()
plus whatever bases the format forced, and that is correct, not an
over-send.”
It is correct about the pack format and wrong about the clone, and every engine that implemented it shipped broken clones — the Arrow arm until 2026-08-11, the gix arm until 2026-08-14, and gunnar’s own in-memory control until the same day. Two things go wrong and only the second is loud:
- a base pulled in is an object the client did not ask for, so a
--filter=blob:noneor--depth=Nfetch is served back precisely what it asked to be left out — the same over-send this method’sobjectsparameter was renamed to prevent, arriving by delta links instead of graph links; - and if the base is a tree, it arrives owing children the pack does
not contain.
git index-pack --check-self-contained-and-connected— which is whatgit cloneruns — walks every received object’s links and then demands each one exist, so the transfer dies withfatal: did not receive expected object <oid>while the server logs a success.
MEASURED on gunnar’s gix arm, 2026-08-14, over its real upload-pack: a
repository of 208 objects with 202 reachable from its one ref served
selected=202 objects=203 copied=203 recompressed=0 and the clone was
refused. Any store that has ever refused a ref update holds unreachable
objects — a lost compare-and-swap, a reset branch, a git fast-import —
and pack-objects stores the newest version of a path whole and the
older ones as deltas against it, so after a reset the still-reachable
object is routinely a delta against one that is not. This is an ordinary
repository, not a corner.
§The rule, which is stock pack-objects’
Reuse a stored delta only when its base is also being packed. The base decides how an entry is encoded; it never decides what the pack contains:
- base inside
objects→ copy the stored entry, re-heading it into whichever spellingcapsallows; - base outside
objects→ rebuild the object whole and count it inPackStats::recompressed, or — for a fetch whosehavevouches for the base — name it in aREF_DELTAthe client can resolve.
So recompressed is not a literal 0. It is 0 for a whole-repository
clone of a store with no unreachable history, because such a request
contains every base, and non-zero exactly on the boundary a narrowed or
unreachable-adjacent request cuts.
The property this has always asserted, unchanged: no object is added because the engine walked the graph. Now nothing is added at all.
§objects is a SET TO EMIT, not a set of tips to close over
This parameter was called want until 2026-08-10 and the rename is the
contract, not cosmetics. The engine must not compute a closure here.
Upload-pack’s caller holds selection.objects, which is already
post-filter, post-shallow and post-include-tag, and is deliberately
not closed. An engine that treats it as tips and closes over it adds
back exactly what --filter=blob:none or --depth=N excluded.
That bug is invisible to every guard we have: the over-sent pack passes
index-pack --strict and fsck, the clone succeeds, and the client
simply receives objects it asked not to have. It is P-027’s shape — a
change no test can see — which is why the parameter is named for what it
is. select owns the close-over-tips half; this does not.
have is different in kind: those are the negotiated common tips,
used for thin-pack base selection, never to derive membership.
The engine owns this because the engine owns the format. The Arrow arm
answers it as a byte-range copy out of the archive; a naive fallback
that inflates every object in order to deflate it again produces a pack
that passes index-pack --strict and fsck while sending a measured
18.4x the wire bytes. That is why PackStats carries copied and
recompressed and why they are counted rather than assumed.
A backend that cannot honour caps refuses. It does not emit a pack
the client cannot parse, and it does not silently fall back to a
whole-object writer.
Sourcefn select(
&self,
want: &[&[u8]],
have: &[&[u8]],
) -> Result<Option<ReachSet>, Error>
fn select( &self, want: &[&[u8]], have: &[&[u8]], ) -> Result<Option<ReachSet>, Error>
Negotiation: want minus have, with the two derived facts a caller
cannot recompute cheaply.
Ok(None) means “this engine cannot answer cheaply — walk it
yourself”, and it is load-bearing. It is the escape hatch and it must
stay: the Arrow arm returns None when its reachability projection is
empty, which a clean restart currently produces. Without it a backend
whose index does not cover a tip contributes only the tip, and the clone
is short by everything beneath it while exiting zero.
Do not “improve” this into always answering. An engine that always
answers has to answer wrongly somewhere, and this is the shape of wrong
that no exit code and no fsck can see.
Dyn Compatibility§
This trait is dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety".
Implementors§
impl GitServe for SelectedStore
The reading contract, delegated the same way GitOps is.
Every method is one predictable branch over a monomorphised body — the same
arrangement, and the same cost argument, SelectedStore makes for the
eleven. It is here rather than in crate::serve only because the macro
that generates it lives in this module.