znippy_plugin_git/sections.rs
1//! `GitIndexBuilder` — turns a set of git objects into the three reserved
2//! sections a `git`-format archive carries.
3//!
4//! The caller (gunnar's `repack()`, or a test) hands over the canonical bytes of
5//! every object it is sealing. The builder derives each object's oid **by
6//! hashing those bytes**, so the oid it indexes and the `relative_path` the
7//! archive stores cannot drift apart — there is no second place where an oid is
8//! spelled.
9//!
10//! Payloads are retained only for commits and trees. Blobs are leaves in the
11//! reachability graph and contribute nothing but their oid, so their bytes are
12//! dropped as soon as they are hashed. On a real repository that is the
13//! difference between holding the metadata and holding the repository.
14//!
15//! The oid index needs lookup **row numbers**, which only exist once the sink has
16//! sorted the lookup — hence [`GitIndexBuilder::into_reserved_builder`], which
17//! defers exactly that step to `ArrowIpcSink::finish`.
18//!
19//! ## Two tiers, one seal path
20//!
21//! A `git` archive holds a repository one of two ways, and the builder has a
22//! constructor for each:
23//!
24//! | constructor | data entries | derived sections |
25//! |---|---|---|
26//! | [`GitIndexBuilder::new`] | one per object, named by its oid | `__gunnar_oid__`, `__gunnar_graph__`, `__gunnar_reach__` |
27//! | [`GitIndexBuilder::pack_tier`] | `pack-<id>.pack` / `.idx` | none — the pack's own `.idx` is the index |
28//!
29//! [`with_section`](GitIndexBuilder::with_section) — the refs and secrets push
30//! logs — works identically on both, because it is the *server's* log and has
31//! nothing to do with how the objects are laid out. That is the reason both
32//! tiers route through this one type instead of growing a second writer.
33
34use std::collections::HashMap;
35
36use anyhow::{Result, anyhow, bail};
37use znippy_common::{
38 GUNNAR_GRAPH_MODULE, GUNNAR_OID_MODULE, GUNNAR_REACH_MODULE, ReservedSection,
39 ReservedSectionBuilder,
40};
41
42use crate::graph::{CommitNode, assign_generations, build_graph_batch, graph_schema};
43use crate::object::{GitHashKind, GitObjectKind, parse_canonical};
44use crate::oid_index::{OidEntry, build_section};
45use crate::reach::{ObjectFacts, ReachPolicy, build_reach, build_reach_batch, reach_schema};
46
47/// Accumulates objects and emits the reserved sections.
48pub struct GitIndexBuilder {
49 hash: GitHashKind,
50 /// oid hex → object kind, in insertion order via `order`.
51 kinds: HashMap<String, GitObjectKind>,
52 order: Vec<String>,
53 /// Tree payloads, kept for the reachability closure.
54 trees: HashMap<String, Vec<u8>>,
55 /// Commit rows, before generation numbers are assigned.
56 commits: Vec<CommitNode>,
57 reach: ReachPolicy,
58 /// When false, `__gunnar_reach__` is not emitted at all — an archive that
59 /// declares no bitmaps is a different thing from one that declares empty
60 /// bitmaps, and a consumer must be able to tell them apart.
61 emit_reach: bool,
62 /// This archive's objects live in **packfiles**, not as one entry per oid.
63 ///
64 /// See [`GitIndexBuilder::pack_tier`]. The three derived sections are then
65 /// not emitted at all, because the pack's own `.idx` already is the oid
66 /// index and emitting an empty `__gunnar_oid__` beside it would state, in a
67 /// section consumers trust, that the archive holds no objects.
68 objects_in_packs: bool,
69 /// Already-built sections handed in by the caller — the sealed
70 /// `__gunnar_refs__` and `__gunnar_secrets__` push logs.
71 ///
72 /// They come in ready-made rather than being built here because their
73 /// contents are a *log*, owned and appended to by the running server, not
74 /// something derivable from the object set. Routing them through this one
75 /// builder is what keeps a single seal path (LAW 5): gunnar's `repack()`
76 /// hands over refs and secrets, and every reserved section the archive ends
77 /// up carrying is emitted by the same code.
78 extra: Vec<ReservedSection>,
79}
80
81impl GitIndexBuilder {
82 pub fn new(hash: GitHashKind) -> Self {
83 Self {
84 hash,
85 kinds: HashMap::new(),
86 order: Vec::new(),
87 trees: HashMap::new(),
88 commits: Vec::new(),
89 reach: ReachPolicy::default(),
90 emit_reach: true,
91 objects_in_packs: false,
92 extra: Vec::new(),
93 }
94 }
95
96 /// A builder for a **pack-tier** archive: one whose data entries are
97 /// `pack-<id>.pack` / `pack-<id>.idx` rather than one entry per object.
98 ///
99 /// This is what gunnar's `repack()` seals. The reasoning is not znippy's to
100 /// re-argue but is worth stating, because it decides what this builder must
101 /// *not* emit: a git object inside a pack is already deflated and often a
102 /// delta, so a tier that stores inflated content per oid pays a full
103 /// inflate-plus-deflate round trip on every fetch, for ever, and can never
104 /// be pack-copied. Measured on gunnar's own fixture, a znippy archive of
105 /// 60000 small git objects came out **3.4× larger than its input** — the
106 /// tier inflated rather than compressed.
107 ///
108 /// So the three derived sections are not emitted:
109 ///
110 /// * `__gunnar_oid__` — the pack's `.idx` **is** the oid index, and a far
111 /// better one: it addresses pack offsets, which is what a reader needs.
112 /// * `__gunnar_graph__` / `__gunnar_reach__` — derived from object payloads
113 /// this builder never sees.
114 ///
115 /// They are omitted rather than emitted empty. An empty `__gunnar_oid__`
116 /// beside a pack holding a million objects is not a smaller truth, it is a
117 /// false one, and [`GitOidIndex::open`](crate::GitOidIndex::open) returning
118 /// `None` is the state a consumer can act on.
119 ///
120 /// [`with_section`](Self::with_section) still works and is the point: refs
121 /// and secrets are sealed the same way, through this one composition point,
122 /// whichever tier the archive holds (LAW 5).
123 pub fn pack_tier(hash: GitHashKind) -> Self {
124 Self {
125 objects_in_packs: true,
126 ..Self::new(hash)
127 }
128 }
129
130 /// True when this builder seals a pack tier rather than loose objects.
131 pub fn is_pack_tier(&self) -> bool {
132 self.objects_in_packs
133 }
134
135 /// Seal an already-built reserved section — the `__gunnar_refs__` or
136 /// `__gunnar_secrets__` push log — alongside the object sections.
137 ///
138 /// Refused unless the module is reserved: a section the manifest readers do
139 /// not classify as reserved is merged into the *data* index, which silently
140 /// corrupts `list`, `decompress` and the iceberg sink. Catching it here
141 /// names the offending module, instead of surfacing later as a wrong file
142 /// count.
143 pub fn with_section(mut self, section: ReservedSection) -> Result<Self> {
144 if !znippy_common::is_reserved_module(§ion.module_name) {
145 bail!(
146 "'{}' is not a reserved module — sealing it would merge it into the data index",
147 section.module_name
148 );
149 }
150 self.extra.push(section);
151 Ok(self)
152 }
153
154 pub fn with_reach_policy(mut self, policy: ReachPolicy) -> Self {
155 self.reach = policy;
156 self
157 }
158
159 /// Do not emit `__gunnar_reach__`. Use when the bitmaps' build cost is not
160 /// wanted for this repack.
161 pub fn without_reach(mut self) -> Self {
162 self.emit_reach = false;
163 self
164 }
165
166 pub fn hash_kind(&self) -> GitHashKind {
167 self.hash
168 }
169
170 pub fn len(&self) -> usize {
171 self.order.len()
172 }
173
174 pub fn is_empty(&self) -> bool {
175 self.order.is_empty()
176 }
177
178 /// Add one object from its canonical bytes. Returns the oid hex — which is
179 /// also the `relative_path` the archive must store the object under.
180 pub fn push_canonical(&mut self, canonical_bytes: &[u8]) -> Result<String> {
181 // Refused rather than accepted-and-ignored. A pack-tier builder emits no
182 // oid index, so an object handed to it would be silently absent from
183 // every section — and the archive would still seal, still verify, and
184 // still be wrong. The two tiers are alternatives, not a mixture.
185 if self.objects_in_packs {
186 bail!(
187 "this is a pack-tier archive: its objects live in `pack-<id>.pack`, and the \
188 pack's own `.idx` is the oid index. Use `GitIndexBuilder::new` to seal loose \
189 objects instead."
190 );
191 }
192 let obj = parse_canonical(canonical_bytes)
193 .ok_or_else(|| anyhow!("not a canonical git object (`<type> <size>\\0<content>`)"))?;
194 let oid = self.hash.oid_hex_of(canonical_bytes);
195 if self.kinds.insert(oid.clone(), obj.kind).is_none() {
196 self.order.push(oid.clone());
197 }
198 match obj.kind {
199 GitObjectKind::Tree => {
200 self.trees.insert(oid.clone(), obj.payload.to_vec());
201 }
202 GitObjectKind::Commit => {
203 let h = crate::object::parse_commit(obj.payload);
204 self.commits.push(CommitNode {
205 oid: oid.clone(),
206 parents: h.parents,
207 tree: h.tree,
208 committer_time: h.committer_time,
209 generation: 0,
210 });
211 }
212 _ => {}
213 }
214 Ok(oid)
215 }
216
217 /// Add many objects at once.
218 pub fn extend_canonical<'a, I: IntoIterator<Item = &'a [u8]>>(
219 &mut self,
220 objects: I,
221 ) -> Result<Vec<String>> {
222 objects.into_iter().map(|b| self.push_canonical(b)).collect()
223 }
224
225 /// Object ordinals: the oid-lexicographic ordering of the distinct objects.
226 /// This is the space `__gunnar_reach__` bitmaps address and the `ordinal`
227 /// column of `__gunnar_oid__` records.
228 fn ordinals(&self) -> (Vec<&str>, HashMap<String, u32>) {
229 let mut sorted: Vec<&str> = self.order.iter().map(|s| s.as_str()).collect();
230 sorted.sort_unstable();
231 let map = sorted
232 .iter()
233 .enumerate()
234 .map(|(i, o)| ((*o).to_string(), i as u32))
235 .collect();
236 (sorted, map)
237 }
238
239 /// Build the sections against an explicit path → first-lookup-row mapping.
240 ///
241 /// Separated from [`into_reserved_builder`](Self::into_reserved_builder) so
242 /// it is testable without sealing an archive, and so the sink path and the
243 /// direct path are the same code (LAW 5).
244 pub fn build_sections(&self, first_row: &HashMap<&str, u64>) -> Result<Vec<ReservedSection>> {
245 // A pack tier derives nothing from object payloads it never saw. Not an
246 // early return over an empty vector by accident: see
247 // [`GitIndexBuilder::pack_tier`] for why absent beats empty here.
248 if self.objects_in_packs {
249 return Ok(Vec::new());
250 }
251 let (sorted, ordinal) = self.ordinals();
252
253 let mut entries = Vec::with_capacity(sorted.len());
254 for (i, oid_hex) in sorted.iter().enumerate() {
255 let raw = hex::decode(oid_hex)
256 .map_err(|e| anyhow!("oid {oid_hex} is not hex: {e}"))?;
257 if raw.len() != self.hash.oid_len() {
258 bail!(
259 "oid {oid_hex} is {} bytes, expected {} for {:?}",
260 raw.len(),
261 self.hash.oid_len(),
262 self.hash
263 );
264 }
265 let row = *first_row.get(*oid_hex).ok_or_else(|| {
266 anyhow!(
267 "object {oid_hex} was indexed but no archive entry has it as its \
268 relative_path — the archive and the git index disagree"
269 )
270 })?;
271 entries.push(OidEntry { oid: raw, lookup_row: row, ordinal: i as u32 });
272 }
273
274 let mut sections = vec![ReservedSection::raw(
275 GUNNAR_OID_MODULE,
276 build_section(&entries, self.hash)?,
277 )];
278
279 let commits = assign_generations(self.commits.clone());
280 sections.push(ReservedSection::arrow(
281 GUNNAR_GRAPH_MODULE,
282 graph_schema(),
283 vec![build_graph_batch(&commits)?],
284 ));
285
286 if self.emit_reach {
287 let facts = ObjectFacts {
288 ordinal: &ordinal,
289 trees: &self.trees,
290 oid_len: self.hash.oid_len(),
291 };
292 let reach = build_reach(&commits, &facts, self.reach);
293 sections.push(ReservedSection::arrow(
294 GUNNAR_REACH_MODULE,
295 reach_schema(),
296 vec![build_reach_batch(&reach)?],
297 ));
298 }
299 Ok(sections)
300 }
301
302 /// Every reserved section this archive will carry: the object sections from
303 /// [`build_sections`](Self::build_sections), then the caller's push logs.
304 ///
305 /// The one composition point. Both the direct path and the sink path go
306 /// through it, so a section added here cannot reach one and miss the other.
307 pub fn finish_sections(mut self, first_row: &HashMap<&str, u64>) -> Result<Vec<ReservedSection>> {
308 let mut sections = self.build_sections(first_row)?;
309 sections.append(&mut self.extra);
310 Ok(sections)
311 }
312
313 /// Hand the builder to `ArrowIpcSink::with_reserved_builder`.
314 ///
315 /// The closure runs at seal time with the sink's final sorted lookup, so
316 /// `__gunnar_oid__` points at the rows the archive actually has. It fails
317 /// loudly — rather than emitting a silently wrong index — if an object it
318 /// was given is not present in the archive as a `relative_path`.
319 pub fn into_reserved_builder(self) -> ReservedSectionBuilder {
320 Box::new(move |view| {
321 let rows = view.first_rows();
322 let first_row: HashMap<&str, u64> = rows.into_iter().collect();
323 self.finish_sections(&first_row)
324 })
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::object::{GitObjectKind, canonical};
332 use crate::oid_index::GitOidIndex;
333 use znippy_common::ReservedPayload;
334
335 #[test]
336 fn a_missing_archive_entry_is_a_loud_error_not_a_wrong_index() {
337 let mut b = GitIndexBuilder::new(GitHashKind::Sha256);
338 let oid = b.push_canonical(&canonical(GitObjectKind::Blob, b"hi")).unwrap();
339 let empty: HashMap<&str, u64> = HashMap::new();
340 let err = b.build_sections(&empty).unwrap_err().to_string();
341 assert!(err.contains(&oid), "error should name the missing oid: {err}");
342 }
343
344 /// A pack-tier archive emits the push logs and **nothing derived**. The
345 /// distinction that matters is absent-versus-empty: an empty
346 /// `__gunnar_oid__` beside a pack of a million objects would read, to every
347 /// consumer, as "this archive holds nothing".
348 #[test]
349 fn a_pack_tier_emits_the_push_logs_and_no_derived_section() {
350 let dir = tempfile::tempdir().unwrap();
351 let refs = crate::refs::RefLog::new(dir.path().join("refs.log"));
352 refs.push(&[crate::refs::RefUpdate::set("refs/heads/main", &"a".repeat(64))])
353 .unwrap();
354
355 let b = GitIndexBuilder::pack_tier(GitHashKind::Sha256)
356 .with_section(refs.seal_section().unwrap())
357 .unwrap();
358 assert!(b.is_pack_tier());
359
360 let empty: HashMap<&str, u64> = HashMap::new();
361 let derived = b.build_sections(&empty).unwrap();
362 assert!(
363 derived.is_empty(),
364 "a pack tier must derive no section from objects it never saw, got {:?}",
365 derived.iter().map(|s| s.module_name.clone()).collect::<Vec<_>>()
366 );
367
368 let all = b.finish_sections(&empty).unwrap();
369 let names: Vec<&str> = all.iter().map(|s| s.module_name.as_str()).collect();
370 assert_eq!(
371 names,
372 vec![znippy_common::GUNNAR_REFS_MODULE],
373 "the push log is the only section a pack tier carries"
374 );
375 }
376
377 /// The mirror, and the reason the test above is not vacuous: the SAME calls
378 /// on a loose-object builder do emit the derived sections. Without this a
379 /// builder that had stopped emitting them entirely would still pass.
380 #[test]
381 fn a_loose_object_builder_still_emits_all_three_derived_sections() {
382 let mut b = GitIndexBuilder::new(GitHashKind::Sha256);
383 let oid = b.push_canonical(&canonical(GitObjectKind::Blob, b"hi")).unwrap();
384 assert!(!b.is_pack_tier());
385 let rows: HashMap<&str, u64> = [(oid.as_str(), 0u64)].into_iter().collect();
386 let names: Vec<String> = b
387 .build_sections(&rows)
388 .unwrap()
389 .iter()
390 .map(|s| s.module_name.clone())
391 .collect();
392 assert_eq!(
393 names,
394 vec![
395 znippy_common::GUNNAR_OID_MODULE,
396 znippy_common::GUNNAR_GRAPH_MODULE,
397 znippy_common::GUNNAR_REACH_MODULE
398 ]
399 );
400 }
401
402 /// Handing an object to a pack-tier builder is refused, not ignored. If it
403 /// were ignored the archive would seal, verify and be silently wrong: the
404 /// object would be in no section at all.
405 #[test]
406 fn a_pack_tier_refuses_a_loose_object_rather_than_dropping_it() {
407 let mut b = GitIndexBuilder::pack_tier(GitHashKind::Sha256);
408 let err = b
409 .push_canonical(&canonical(GitObjectKind::Blob, b"hi"))
410 .unwrap_err()
411 .to_string();
412 assert!(
413 err.contains("pack-tier") || err.contains("pack tier") || err.contains("pack-<id>"),
414 "the refusal must say which tier this is: {err}"
415 );
416 assert_eq!(b.len(), 0, "the refused object must not have been recorded");
417 }
418
419 #[test]
420 fn ordinals_are_oid_lexicographic_and_match_the_oid_index() {
421 let mut b = GitIndexBuilder::new(GitHashKind::Sha1);
422 let mut oids = Vec::new();
423 for i in 0..40u32 {
424 oids.push(b.push_canonical(&canonical(GitObjectKind::Blob, format!("b{i}").as_bytes())).unwrap());
425 }
426 let rows: HashMap<&str, u64> =
427 oids.iter().enumerate().map(|(i, o)| (o.as_str(), i as u64 * 2)).collect();
428 let sections = b.build_sections(&rows).unwrap();
429
430 let ReservedPayload::Raw(oid_bytes) = §ions[0].payload else {
431 panic!("first section must be the raw oid index")
432 };
433 let index = GitOidIndex::parse(oid_bytes.clone()).unwrap();
434
435 let mut sorted = oids.clone();
436 sorted.sort();
437 for (i, o) in sorted.iter().enumerate() {
438 let hit = index.lookup_hex(o).unwrap();
439 assert_eq!(hit.ordinal, i as u32, "ordinal must be the oid-lexicographic rank");
440 assert_eq!(hit.lookup_row, rows[o.as_str()]);
441 }
442 }
443}