znippy-plugin-git 0.1.1

Git object-store metadata plugin for znippy (native builtin — no WASM). Carries the reserved oid / commit-graph / reachability sub-indexes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! `GitIndexBuilder` — turns a set of git objects into the three reserved
//! sections a `git`-format archive carries.
//!
//! The caller (gunnar's `repack()`, or a test) hands over the canonical bytes of
//! every object it is sealing. The builder derives each object's oid **by
//! hashing those bytes**, so the oid it indexes and the `relative_path` the
//! archive stores cannot drift apart — there is no second place where an oid is
//! spelled.
//!
//! Payloads are retained only for commits and trees. Blobs are leaves in the
//! reachability graph and contribute nothing but their oid, so their bytes are
//! dropped as soon as they are hashed. On a real repository that is the
//! difference between holding the metadata and holding the repository.
//!
//! The oid index needs lookup **row numbers**, which only exist once the sink has
//! sorted the lookup — hence [`GitIndexBuilder::into_reserved_builder`], which
//! defers exactly that step to `ArrowIpcSink::finish`.
//!
//! ## Two tiers, one seal path
//!
//! A `git` archive holds a repository one of two ways, and the builder has a
//! constructor for each:
//!
//! | constructor | data entries | derived sections |
//! |---|---|---|
//! | [`GitIndexBuilder::new`] | one per object, named by its oid | `__gunnar_oid__`, `__gunnar_graph__`, `__gunnar_reach__` |
//! | [`GitIndexBuilder::pack_tier`] | `pack-<id>.pack` / `.idx` | none — the pack's own `.idx` is the index |
//!
//! [`with_section`](GitIndexBuilder::with_section) — the refs and secrets push
//! logs — works identically on both, because it is the *server's* log and has
//! nothing to do with how the objects are laid out. That is the reason both
//! tiers route through this one type instead of growing a second writer.

use std::collections::HashMap;

use anyhow::{Result, anyhow, bail};
use znippy_common::{
    GUNNAR_GRAPH_MODULE, GUNNAR_OID_MODULE, GUNNAR_REACH_MODULE, ReservedSection,
    ReservedSectionBuilder,
};

use crate::graph::{CommitNode, assign_generations, build_graph_batch, graph_schema};
use crate::object::{GitHashKind, GitObjectKind, parse_canonical};
use crate::oid_index::{OidEntry, build_section};
use crate::reach::{ObjectFacts, ReachPolicy, build_reach, build_reach_batch, reach_schema};

/// Accumulates objects and emits the reserved sections.
pub struct GitIndexBuilder {
    hash: GitHashKind,
    /// oid hex → object kind, in insertion order via `order`.
    kinds: HashMap<String, GitObjectKind>,
    order: Vec<String>,
    /// Tree payloads, kept for the reachability closure.
    trees: HashMap<String, Vec<u8>>,
    /// Commit rows, before generation numbers are assigned.
    commits: Vec<CommitNode>,
    reach: ReachPolicy,
    /// When false, `__gunnar_reach__` is not emitted at all — an archive that
    /// declares no bitmaps is a different thing from one that declares empty
    /// bitmaps, and a consumer must be able to tell them apart.
    emit_reach: bool,
    /// This archive's objects live in **packfiles**, not as one entry per oid.
    ///
    /// See [`GitIndexBuilder::pack_tier`]. The three derived sections are then
    /// not emitted at all, because the pack's own `.idx` already is the oid
    /// index and emitting an empty `__gunnar_oid__` beside it would state, in a
    /// section consumers trust, that the archive holds no objects.
    objects_in_packs: bool,
    /// Already-built sections handed in by the caller — the sealed
    /// `__gunnar_refs__` and `__gunnar_secrets__` push logs.
    ///
    /// They come in ready-made rather than being built here because their
    /// contents are a *log*, owned and appended to by the running server, not
    /// something derivable from the object set. Routing them through this one
    /// builder is what keeps a single seal path (LAW 5): gunnar's `repack()`
    /// hands over refs and secrets, and every reserved section the archive ends
    /// up carrying is emitted by the same code.
    extra: Vec<ReservedSection>,
}

impl GitIndexBuilder {
    pub fn new(hash: GitHashKind) -> Self {
        Self {
            hash,
            kinds: HashMap::new(),
            order: Vec::new(),
            trees: HashMap::new(),
            commits: Vec::new(),
            reach: ReachPolicy::default(),
            emit_reach: true,
            objects_in_packs: false,
            extra: Vec::new(),
        }
    }

    /// A builder for a **pack-tier** archive: one whose data entries are
    /// `pack-<id>.pack` / `pack-<id>.idx` rather than one entry per object.
    ///
    /// This is what gunnar's `repack()` seals. The reasoning is not znippy's to
    /// re-argue but is worth stating, because it decides what this builder must
    /// *not* emit: a git object inside a pack is already deflated and often a
    /// delta, so a tier that stores inflated content per oid pays a full
    /// inflate-plus-deflate round trip on every fetch, for ever, and can never
    /// be pack-copied. Measured on gunnar's own fixture, a znippy archive of
    /// 60000 small git objects came out **3.4× larger than its input** — the
    /// tier inflated rather than compressed.
    ///
    /// So the three derived sections are not emitted:
    ///
    /// * `__gunnar_oid__` — the pack's `.idx` **is** the oid index, and a far
    ///   better one: it addresses pack offsets, which is what a reader needs.
    /// * `__gunnar_graph__` / `__gunnar_reach__` — derived from object payloads
    ///   this builder never sees.
    ///
    /// They are omitted rather than emitted empty. An empty `__gunnar_oid__`
    /// beside a pack holding a million objects is not a smaller truth, it is a
    /// false one, and [`GitOidIndex::open`](crate::GitOidIndex::open) returning
    /// `None` is the state a consumer can act on.
    ///
    /// [`with_section`](Self::with_section) still works and is the point: refs
    /// and secrets are sealed the same way, through this one composition point,
    /// whichever tier the archive holds (LAW 5).
    pub fn pack_tier(hash: GitHashKind) -> Self {
        Self {
            objects_in_packs: true,
            ..Self::new(hash)
        }
    }

    /// True when this builder seals a pack tier rather than loose objects.
    pub fn is_pack_tier(&self) -> bool {
        self.objects_in_packs
    }

    /// Seal an already-built reserved section — the `__gunnar_refs__` or
    /// `__gunnar_secrets__` push log — alongside the object sections.
    ///
    /// Refused unless the module is reserved: a section the manifest readers do
    /// not classify as reserved is merged into the *data* index, which silently
    /// corrupts `list`, `decompress` and the iceberg sink. Catching it here
    /// names the offending module, instead of surfacing later as a wrong file
    /// count.
    pub fn with_section(mut self, section: ReservedSection) -> Result<Self> {
        if !znippy_common::is_reserved_module(&section.module_name) {
            bail!(
                "'{}' is not a reserved module — sealing it would merge it into the data index",
                section.module_name
            );
        }
        self.extra.push(section);
        Ok(self)
    }

    pub fn with_reach_policy(mut self, policy: ReachPolicy) -> Self {
        self.reach = policy;
        self
    }

    /// Do not emit `__gunnar_reach__`. Use when the bitmaps' build cost is not
    /// wanted for this repack.
    pub fn without_reach(mut self) -> Self {
        self.emit_reach = false;
        self
    }

    pub fn hash_kind(&self) -> GitHashKind {
        self.hash
    }

    pub fn len(&self) -> usize {
        self.order.len()
    }

    pub fn is_empty(&self) -> bool {
        self.order.is_empty()
    }

    /// Add one object from its canonical bytes. Returns the oid hex — which is
    /// also the `relative_path` the archive must store the object under.
    pub fn push_canonical(&mut self, canonical_bytes: &[u8]) -> Result<String> {
        // Refused rather than accepted-and-ignored. A pack-tier builder emits no
        // oid index, so an object handed to it would be silently absent from
        // every section — and the archive would still seal, still verify, and
        // still be wrong. The two tiers are alternatives, not a mixture.
        if self.objects_in_packs {
            bail!(
                "this is a pack-tier archive: its objects live in `pack-<id>.pack`, and the \
                 pack's own `.idx` is the oid index. Use `GitIndexBuilder::new` to seal loose \
                 objects instead."
            );
        }
        let obj = parse_canonical(canonical_bytes)
            .ok_or_else(|| anyhow!("not a canonical git object (`<type> <size>\\0<content>`)"))?;
        let oid = self.hash.oid_hex_of(canonical_bytes);
        if self.kinds.insert(oid.clone(), obj.kind).is_none() {
            self.order.push(oid.clone());
        }
        match obj.kind {
            GitObjectKind::Tree => {
                self.trees.insert(oid.clone(), obj.payload.to_vec());
            }
            GitObjectKind::Commit => {
                let h = crate::object::parse_commit(obj.payload);
                self.commits.push(CommitNode {
                    oid: oid.clone(),
                    parents: h.parents,
                    tree: h.tree,
                    committer_time: h.committer_time,
                    generation: 0,
                });
            }
            _ => {}
        }
        Ok(oid)
    }

    /// Add many objects at once.
    pub fn extend_canonical<'a, I: IntoIterator<Item = &'a [u8]>>(
        &mut self,
        objects: I,
    ) -> Result<Vec<String>> {
        objects.into_iter().map(|b| self.push_canonical(b)).collect()
    }

    /// Object ordinals: the oid-lexicographic ordering of the distinct objects.
    /// This is the space `__gunnar_reach__` bitmaps address and the `ordinal`
    /// column of `__gunnar_oid__` records.
    fn ordinals(&self) -> (Vec<&str>, HashMap<String, u32>) {
        let mut sorted: Vec<&str> = self.order.iter().map(|s| s.as_str()).collect();
        sorted.sort_unstable();
        let map = sorted
            .iter()
            .enumerate()
            .map(|(i, o)| ((*o).to_string(), i as u32))
            .collect();
        (sorted, map)
    }

    /// Build the sections against an explicit path → first-lookup-row mapping.
    ///
    /// Separated from [`into_reserved_builder`](Self::into_reserved_builder) so
    /// it is testable without sealing an archive, and so the sink path and the
    /// direct path are the same code (LAW 5).
    pub fn build_sections(&self, first_row: &HashMap<&str, u64>) -> Result<Vec<ReservedSection>> {
        // A pack tier derives nothing from object payloads it never saw. Not an
        // early return over an empty vector by accident: see
        // [`GitIndexBuilder::pack_tier`] for why absent beats empty here.
        if self.objects_in_packs {
            return Ok(Vec::new());
        }
        let (sorted, ordinal) = self.ordinals();

        let mut entries = Vec::with_capacity(sorted.len());
        for (i, oid_hex) in sorted.iter().enumerate() {
            let raw = hex::decode(oid_hex)
                .map_err(|e| anyhow!("oid {oid_hex} is not hex: {e}"))?;
            if raw.len() != self.hash.oid_len() {
                bail!(
                    "oid {oid_hex} is {} bytes, expected {} for {:?}",
                    raw.len(),
                    self.hash.oid_len(),
                    self.hash
                );
            }
            let row = *first_row.get(*oid_hex).ok_or_else(|| {
                anyhow!(
                    "object {oid_hex} was indexed but no archive entry has it as its \
                     relative_path — the archive and the git index disagree"
                )
            })?;
            entries.push(OidEntry { oid: raw, lookup_row: row, ordinal: i as u32 });
        }

        let mut sections = vec![ReservedSection::raw(
            GUNNAR_OID_MODULE,
            build_section(&entries, self.hash)?,
        )];

        let commits = assign_generations(self.commits.clone());
        sections.push(ReservedSection::arrow(
            GUNNAR_GRAPH_MODULE,
            graph_schema(),
            vec![build_graph_batch(&commits)?],
        ));

        if self.emit_reach {
            let facts = ObjectFacts {
                ordinal: &ordinal,
                trees: &self.trees,
                oid_len: self.hash.oid_len(),
            };
            let reach = build_reach(&commits, &facts, self.reach);
            sections.push(ReservedSection::arrow(
                GUNNAR_REACH_MODULE,
                reach_schema(),
                vec![build_reach_batch(&reach)?],
            ));
        }
        Ok(sections)
    }

    /// Every reserved section this archive will carry: the object sections from
    /// [`build_sections`](Self::build_sections), then the caller's push logs.
    ///
    /// The one composition point. Both the direct path and the sink path go
    /// through it, so a section added here cannot reach one and miss the other.
    pub fn finish_sections(mut self, first_row: &HashMap<&str, u64>) -> Result<Vec<ReservedSection>> {
        let mut sections = self.build_sections(first_row)?;
        sections.append(&mut self.extra);
        Ok(sections)
    }

    /// Hand the builder to `ArrowIpcSink::with_reserved_builder`.
    ///
    /// The closure runs at seal time with the sink's final sorted lookup, so
    /// `__gunnar_oid__` points at the rows the archive actually has. It fails
    /// loudly — rather than emitting a silently wrong index — if an object it
    /// was given is not present in the archive as a `relative_path`.
    pub fn into_reserved_builder(self) -> ReservedSectionBuilder {
        Box::new(move |view| {
            let rows = view.first_rows();
            let first_row: HashMap<&str, u64> = rows.into_iter().collect();
            self.finish_sections(&first_row)
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::object::{GitObjectKind, canonical};
    use crate::oid_index::GitOidIndex;
    use znippy_common::ReservedPayload;

    #[test]
    fn a_missing_archive_entry_is_a_loud_error_not_a_wrong_index() {
        let mut b = GitIndexBuilder::new(GitHashKind::Sha256);
        let oid = b.push_canonical(&canonical(GitObjectKind::Blob, b"hi")).unwrap();
        let empty: HashMap<&str, u64> = HashMap::new();
        let err = b.build_sections(&empty).unwrap_err().to_string();
        assert!(err.contains(&oid), "error should name the missing oid: {err}");
    }

    /// A pack-tier archive emits the push logs and **nothing derived**. The
    /// distinction that matters is absent-versus-empty: an empty
    /// `__gunnar_oid__` beside a pack of a million objects would read, to every
    /// consumer, as "this archive holds nothing".
    #[test]
    fn a_pack_tier_emits_the_push_logs_and_no_derived_section() {
        let dir = tempfile::tempdir().unwrap();
        let refs = crate::refs::RefLog::new(dir.path().join("refs.log"));
        refs.push(&[crate::refs::RefUpdate::set("refs/heads/main", &"a".repeat(64))])
            .unwrap();

        let b = GitIndexBuilder::pack_tier(GitHashKind::Sha256)
            .with_section(refs.seal_section().unwrap())
            .unwrap();
        assert!(b.is_pack_tier());

        let empty: HashMap<&str, u64> = HashMap::new();
        let derived = b.build_sections(&empty).unwrap();
        assert!(
            derived.is_empty(),
            "a pack tier must derive no section from objects it never saw, got {:?}",
            derived.iter().map(|s| s.module_name.clone()).collect::<Vec<_>>()
        );

        let all = b.finish_sections(&empty).unwrap();
        let names: Vec<&str> = all.iter().map(|s| s.module_name.as_str()).collect();
        assert_eq!(
            names,
            vec![znippy_common::GUNNAR_REFS_MODULE],
            "the push log is the only section a pack tier carries"
        );
    }

    /// The mirror, and the reason the test above is not vacuous: the SAME calls
    /// on a loose-object builder do emit the derived sections. Without this a
    /// builder that had stopped emitting them entirely would still pass.
    #[test]
    fn a_loose_object_builder_still_emits_all_three_derived_sections() {
        let mut b = GitIndexBuilder::new(GitHashKind::Sha256);
        let oid = b.push_canonical(&canonical(GitObjectKind::Blob, b"hi")).unwrap();
        assert!(!b.is_pack_tier());
        let rows: HashMap<&str, u64> = [(oid.as_str(), 0u64)].into_iter().collect();
        let names: Vec<String> = b
            .build_sections(&rows)
            .unwrap()
            .iter()
            .map(|s| s.module_name.clone())
            .collect();
        assert_eq!(
            names,
            vec![
                znippy_common::GUNNAR_OID_MODULE,
                znippy_common::GUNNAR_GRAPH_MODULE,
                znippy_common::GUNNAR_REACH_MODULE
            ]
        );
    }

    /// Handing an object to a pack-tier builder is refused, not ignored. If it
    /// were ignored the archive would seal, verify and be silently wrong: the
    /// object would be in no section at all.
    #[test]
    fn a_pack_tier_refuses_a_loose_object_rather_than_dropping_it() {
        let mut b = GitIndexBuilder::pack_tier(GitHashKind::Sha256);
        let err = b
            .push_canonical(&canonical(GitObjectKind::Blob, b"hi"))
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("pack-tier") || err.contains("pack tier") || err.contains("pack-<id>"),
            "the refusal must say which tier this is: {err}"
        );
        assert_eq!(b.len(), 0, "the refused object must not have been recorded");
    }

    #[test]
    fn ordinals_are_oid_lexicographic_and_match_the_oid_index() {
        let mut b = GitIndexBuilder::new(GitHashKind::Sha1);
        let mut oids = Vec::new();
        for i in 0..40u32 {
            oids.push(b.push_canonical(&canonical(GitObjectKind::Blob, format!("b{i}").as_bytes())).unwrap());
        }
        let rows: HashMap<&str, u64> =
            oids.iter().enumerate().map(|(i, o)| (o.as_str(), i as u64 * 2)).collect();
        let sections = b.build_sections(&rows).unwrap();

        let ReservedPayload::Raw(oid_bytes) = &sections[0].payload else {
            panic!("first section must be the raw oid index")
        };
        let index = GitOidIndex::parse(oid_bytes.clone()).unwrap();

        let mut sorted = oids.clone();
        sorted.sort();
        for (i, o) in sorted.iter().enumerate() {
            let hit = index.lookup_hex(o).unwrap();
            assert_eq!(hit.ordinal, i as u32, "ordinal must be the oid-lexicographic rank");
            assert_eq!(hit.lookup_row, rows[o.as_str()]);
        }
    }
}