znippy-plugin-git 0.1.0

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
//! znippy handler for **git object stores** — the `git` package format.
//!
//! One archive holds one git repository (D12: one `.znippy` per repo). The
//! consumer is `gunnar`, a pure-Rust git server whose `repack()` writes the cold
//! tier.
//!
//! ## The two tiers a repository can be stored as
//!
//! | tier | data entries | what it preserves |
//! |---|---|---|
//! | **objects** | one per object, named by its **oid hex**, holding its canonical bytes `"<type> <size>\0<content>"` | the oid is checkable by re-hashing the entry |
//! | **packs** | `pack-<id>.pack` + `pack-<id>.idx` | the client's own **deflate** and every **delta chain** |
//!
//! The object tier is the one this format was designed around and is what the
//! `__gunnar_*` sub-indexes describe. **The pack tier is what gunnar actually
//! seals**, and the reason is measured: a git object in a pack is already
//! deflated and often a delta, so storing it inflated per oid costs an OpenZL
//! decode plus a fresh zlib deflate on every fetch, for ever, and forecloses
//! pack-copy permanently. gunnar measured an archive of 60000 small git objects
//! at 3.4× *larger* than its input. See [`sections::GitIndexBuilder::pack_tier`].
//!
//! ## What this format adds on top of a plain archive
//!
//! | column / module | carries |
//! |---|---|
//! | `object_type` | `blob` / `tree` / `commit` / `tag` for the object tier; `packfile` / `pack-index` for the pack tier — typed listing with no reads |
//! | `object_size` | content length, so quota gates and size analytics are index-only |
//! | `__gunnar_oid__` | an `stree` over the first 8 bytes of every oid → its lookup row |
//! | `__gunnar_graph__` | the commit graph as Arrow: `oid, parents[], tree, committer_time, generation` |
//! | `__gunnar_reach__` | per-commit reachability bitmaps (roaring) over object ordinals |
//! | `__gunnar_refs__` / `__gunnar_secrets__` | the server's push logs, on **either** tier |
//!
//! The last three object modules are emitted for the object tier only — a pack
//! carries its own `.idx`, which is a better oid index than `__gunnar_oid__`
//! because it addresses pack offsets.
//!
//! All of these modules are **reserved** (`znippy_common::is_reserved_module`),
//! so `znippy list`, `decompress`, the iceberg sink and the manifest readers skip
//! them exactly as they skip the lookup and the trie.
//!
//! ## Laws it honours
//!
//! **P-1** one archive = one ecosystem (`--format git`); **P-2** it writes only
//! the columns it declared; **P-3** it is discovered through [`meta`]; **P-4** a
//! malformed or hostile entry never panics — `object_type` degrades to
//! `unknown` and the archive still writes.
//!
//! ## Who writes the reserved sections
//!
//! [`GitIndexBuilder`] does, handed to `ArrowIpcSink::with_reserved_builder` by
//! the writer that knows the object set — gunnar's `repack()`. It is deliberately
//! **not** wired into `znippy compress --format git`: the CLI's small-file batch
//! pass never sees objects that take the big-file path, so a CLI-built index
//! would be silently incomplete, and a silently incomplete oid index is worse
//! than none. `znippy compress --format git` therefore writes the two columns and
//! no reserved sections; `znippy run git lookup|graph` reads archives that carry
//! them.
//!
//! Native builtin, registered in `znippy-cli/src/handlers.rs::builtin_handlers`.
//! Deliberately **not** a WASM plugin: the oid index's hot path is `stree`, which
//! needs AVX2, an mmap and prefetch, none of which wasm offers.

use std::collections::HashMap;

use znippy_common::arrow::datatypes::{DataType, Field};
use znippy_common::plugin::{
    ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta,
};

/// The measured workloads behind the `znippy.git_*` benches (feature
/// `bench-kernels`). Not linked into a shipped archiver.
#[cfg(feature = "bench-kernels")]
pub mod bench_kernels;
pub mod graph;
pub mod object;
pub mod oid_index;
pub mod pushlog;
pub mod reach;
pub mod refs;
pub mod secrets;
pub mod sections;

pub use graph::{CommitNode, decode_graph, graph_schema, read_graph};
pub use object::{
    GitHashKind, GitObject, GitObjectKind, PACKFILE_TYPE, PACK_INDEX_TYPE, PackFileKind, canonical,
    is_oid_path, pack_path_kind, parse_canonical, parse_commit, tree_entries,
};
pub use oid_index::{GitOidIndex, OidEntry, OidHit, key_for_oid};
pub use pushlog::{
    CompactionPolicy, CompactionReport, Finish, PushLog, PushLogScan, scan_frames,
};
pub use reach::{ReachEntry, ReachPolicy, decode_reach, read_reach, reach_schema};
pub use refs::{RefLog, RefState, RefUpdate, read_refs, refs_schema};
pub use secrets::{SecretState, SecretUpdate, SecretsLog, read_secrets, secrets_schema};
pub use sections::GitIndexBuilder;

/// DenseUnion / `pkg_type` discriminant. Clear of the built-ins (1–25), media
/// (25), skidbladnir (40) and rust-toolchain (41).
pub const GIT_TYPE_ID: i8 = 42;

/// `object_type` written when the entry is not parseable as a canonical git
/// object. A distinct, queryable state — never a silent `blob`.
pub const UNKNOWN_OBJECT_TYPE: &str = "unknown";

/// Native git-object handler.
pub struct NativeGitPlugin;

impl NativeGitPlugin {
    pub fn new() -> Self {
        NativeGitPlugin
    }
}

impl Default for NativeGitPlugin {
    fn default() -> Self {
        Self::new()
    }
}

impl ArchiveTypePlugin for NativeGitPlugin {
    fn name(&self) -> &str {
        "git"
    }

    fn type_id(&self) -> i8 {
        GIT_TYPE_ID
    }

    fn meta(&self) -> HandlerMeta {
        HandlerMeta {
            name: "git".into(),
            aliases: vec!["gunnar".into(), "git-objects".into()],
            type_id: GIT_TYPE_ID,
            ecosystem: "Git object store (gunnar cold tier — one archive per repository)".into(),
            // Left empty on purpose, and it is not an oversight. A loose git
            // object carries no extension at all — its name IS its oid — and
            // the pack entries this handler also claims are matched on their
            // whole `pack-<oid>.<ext>` shape, not on a suffix. An extension
            // list here would claim every `.pack` and `.idx` in any archive,
            // which is wider than the truth. `matches_path` is the authority.
            extensions: Vec::new(),
            description: "Stores a git repository: either canonical objects keyed by oid hex, \
                          with the reserved __gunnar_oid__ (stree) / __gunnar_graph__ (commit \
                          graph) / __gunnar_reach__ (reachability bitmaps) sub-indexes, or the \
                          pack tier (pack-<id>.pack / .idx) that preserves the client's deflate \
                          and its delta chains"
                .into(),
            commands: vec![
                HandlerCommand::new(
                    "inspect",
                    "Print type/size/sha1/sha256 for a file of canonical git object bytes",
                ),
                HandlerCommand::new(
                    "lookup",
                    "Resolve an oid against an archive's __gunnar_oid__ index: `git lookup <archive> <oid>`",
                ),
                HandlerCommand::new(
                    "graph",
                    "Print an archive's commit graph (oid, generation, time, parents)",
                ),
            ],
        }
    }

    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
        match cmd {
            "inspect" => {
                let path = args
                    .first()
                    .ok_or_else(|| anyhow::anyhow!("usage: git inspect <object-file>"))?;
                let data = std::fs::read(path)?;
                match parse_canonical(&data) {
                    Some(obj) => {
                        println!("type:   {}", obj.kind.as_str());
                        println!("size:   {}", obj.payload.len());
                        println!("sha1:   {}", GitHashKind::Sha1.oid_hex_of(&data));
                        println!("sha256: {}", GitHashKind::Sha256.oid_hex_of(&data));
                        Ok(())
                    }
                    None => anyhow::bail!(
                        "'{path}' is not canonical git object bytes (`<type> <size>\\0<content>`)"
                    ),
                }
            }
            "lookup" => {
                let (archive, oid) = match (args.first(), args.get(1)) {
                    (Some(a), Some(o)) => (a, o),
                    _ => anyhow::bail!("usage: git lookup <archive> <oid-hex>"),
                };
                let index = GitOidIndex::open(std::path::Path::new(archive))?.ok_or_else(|| {
                    anyhow::anyhow!("'{archive}' carries no __gunnar_oid__ index")
                })?;
                match index.lookup_hex(oid) {
                    Some(hit) => {
                        println!("oid:        {oid}");
                        println!("lookup_row: {}", hit.lookup_row);
                        println!("ordinal:    {}", hit.ordinal);
                        Ok(())
                    }
                    None => anyhow::bail!("oid '{oid}' is not in '{archive}'"),
                }
            }
            "graph" => {
                let archive = args
                    .first()
                    .ok_or_else(|| anyhow::anyhow!("usage: git graph <archive>"))?;
                let nodes = read_graph(std::path::Path::new(archive))?.ok_or_else(|| {
                    anyhow::anyhow!("'{archive}' carries no __gunnar_graph__ section")
                })?;
                println!("commits: {}", nodes.len());
                for n in &nodes {
                    println!(
                        "  {} gen={} time={} parents=[{}]",
                        n.oid,
                        n.generation,
                        n.committer_time.map(|t| t.to_string()).unwrap_or_else(|| "-".into()),
                        n.parents.join(" ")
                    );
                }
                Ok(())
            }
            other => anyhow::bail!("git: unknown subcommand '{other}'"),
        }
    }

    /// A git archive's entries are either **oids** — the whole `relative_path`
    /// is 40 or 64 lowercase hex characters — or the **packs** of a repository's
    /// cold tier, `pack-<id>.pack` / `pack-<id>.idx`.
    ///
    /// Both are the same ecosystem and so belong to one handler (P-1). Which of
    /// the two an archive holds is not a variant of the format: it is what the
    /// writer had. gunnar's `repack()` produces a consolidated pack and seals
    /// that, because a pack preserves the pushing client's own deflate and its
    /// delta chains, and an inflated per-oid tier destroys both permanently.
    fn matches_path(&self, path: &str) -> bool {
        is_oid_path(path) || pack_path_kind(path).is_some()
    }

    fn schema_fields(&self) -> Vec<Field> {
        vec![
            Field::new("object_type", DataType::Utf8, true),
            // UInt32: the index's extension-column writer materialises UInt32 and
            // Utf8 only. A git object larger than 4 GiB saturates here; its exact
            // byte length is always available from the base `uncompressed_size`
            // column, which is u64 and is the authority.
            Field::new("object_size", DataType::UInt32, true),
        ]
    }

    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
        if !self.matches_path(path) {
            return None;
        }
        let mut f: HashMap<String, ExtensionValue> = HashMap::new();

        // A pack is typed by its magic, never by its name. A name is a claim
        // the writer made; the magic is what the bytes are. `pack-<hex>.pack`
        // over something that is not a packfile degrades to `unknown` exactly
        // as malformed object bytes do (P-4) — a wrong type in the index is
        // worse than an honest absence, because it is queried and believed.
        if let Some(kind) = pack_path_kind(path) {
            let typed = if data.starts_with(kind.magic()) {
                kind.as_str()
            } else {
                UNKNOWN_OBJECT_TYPE
            };
            f.insert("object_type".into(), ExtensionValue::Str(typed.into()));
            f.insert(
                "object_size".into(),
                ExtensionValue::U32(data.len().min(u32::MAX as usize) as u32),
            );
            return Some(ExtensionRow { fields: f });
        }

        match parse_canonical(data) {
            Some(obj) => {
                f.insert("object_type".into(), ExtensionValue::Str(obj.kind.as_str().into()));
                f.insert(
                    "object_size".into(),
                    ExtensionValue::U32(obj.payload.len().min(u32::MAX as usize) as u32),
                );
            }
            None => {
                // P-4: a decompress bomb, a truncated object or plain garbage
                // still writes a row — typed `unknown`, sized by what is there.
                f.insert(
                    "object_type".into(),
                    ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into()),
                );
                f.insert(
                    "object_size".into(),
                    ExtensionValue::U32(data.len().min(u32::MAX as usize) as u32),
                );
            }
        }
        Some(ExtensionRow { fields: f })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::object::canonical;

    fn get<'a>(row: &'a ExtensionRow, k: &str) -> Option<&'a ExtensionValue> {
        row.fields.get(k)
    }

    #[test]
    fn claims_oid_paths_and_nothing_else() {
        let p = NativeGitPlugin::new();
        assert!(p.matches_path(&"a1b2c3d4".repeat(5)));       // 40 hex
        assert!(p.matches_path(&"0f".repeat(32)));            // 64 hex
        assert!(!p.matches_path("refs/heads/main"));
        assert!(!p.matches_path("objects/pack/pack-abc.pack"));
        assert!(!p.matches_path(&"A1B2C3D4".repeat(5)));
    }

    /// The pack tier's two file names are claimed, and only in the shape git
    /// itself writes. A bare `.pack` suffix is not enough: an archive of
    /// arbitrary files must not have its entries retyped as git packs.
    #[test]
    fn claims_the_pack_tier_by_its_whole_name_not_by_a_suffix() {
        let p = NativeGitPlugin::new();
        let id40 = "a1b2c3d4".repeat(5);
        let id64 = "0f".repeat(32);
        assert_eq!(pack_path_kind(&format!("pack-{id40}.pack")), Some(PackFileKind::Data));
        assert_eq!(pack_path_kind(&format!("pack-{id64}.idx")), Some(PackFileKind::Index));
        assert!(p.matches_path(&format!("pack-{id64}.pack")));
        assert!(p.matches_path(&format!("objects/pack/pack-{id40}.idx")));

        for not_ours in [
            "pack-nothex.pack",
            "somefile.pack",
            "pack-.pack",
            &format!("pack-{id40}.bitmap"),
            &format!("pack-{}.pack", "A1B2C3D4".repeat(5)),
            &format!("{id40}.pack"),
        ] {
            assert_eq!(pack_path_kind(not_ours), None, "wrongly claimed {not_ours}");
        }
        assert!(!p.matches_path("somefile.pack"));
    }

    /// A pack is typed by its **magic**, never by the name the writer chose.
    /// A file called `pack-….pack` that does not start with `PACK` is
    /// `unknown` — the same honest state malformed object bytes get (P-4).
    #[test]
    fn a_pack_is_typed_by_its_magic_and_a_liar_is_unknown() {
        let p = NativeGitPlugin::new();
        let id = "0f".repeat(32);

        let mut pack = b"PACK".to_vec();
        pack.extend_from_slice(&2u32.to_be_bytes());
        pack.extend_from_slice(&7u32.to_be_bytes());
        let row = p.extract_metadata(&format!("pack-{id}.pack"), &pack).unwrap();
        assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str(PACKFILE_TYPE.into())));
        assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(pack.len() as u32)));

        let idx = b"\xfftOc\x00\x00\x00\x02".to_vec();
        let row = p.extract_metadata(&format!("pack-{id}.idx"), &idx).unwrap();
        assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str(PACK_INDEX_TYPE.into())));

        // Now the liars. Each is named as a pack and is not one.
        for (name, bytes) in [
            (format!("pack-{id}.pack"), &b"NOTAPACK"[..]),
            (format!("pack-{id}.idx"), &b"PACK\0\0\0\x02"[..]),
            (format!("pack-{id}.pack"), &b""[..]),
        ] {
            let row = p.extract_metadata(&name, bytes).expect("a claimed path always yields a row");
            assert_eq!(
                get(&row, "object_type"),
                Some(&ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into())),
                "{name} carries {bytes:?}, which is not that kind of file — it must not be typed \
                 from its name"
            );
        }
    }

    #[test]
    fn types_and_sizes_come_from_the_stored_bytes() {
        let p = NativeGitPlugin::new();
        let body = b"hello world";
        let bytes = canonical(GitObjectKind::Blob, body);
        let oid = GitHashKind::Sha256.oid_hex_of(&bytes);
        let row = p.extract_metadata(&oid, &bytes).unwrap();
        assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str("blob".into())));
        assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(body.len() as u32)));

        let commit = canonical(GitObjectKind::Commit, b"tree x\n\nmsg\n");
        let coid = GitHashKind::Sha1.oid_hex_of(&commit);
        let crow = p.extract_metadata(&coid, &commit).unwrap();
        assert_eq!(get(&crow, "object_type"), Some(&ExtensionValue::Str("commit".into())));
    }

    #[test]
    fn garbage_degrades_to_unknown_and_never_panics() {
        let p = NativeGitPlugin::new();
        let oid = "d".repeat(64);
        for bad in [&b""[..], &b"\0\0\0"[..], &[0xffu8; 4096][..], &b"blob 99\0short"[..]] {
            let row = p.extract_metadata(&oid, bad).expect("a claimed path always yields a row");
            assert_eq!(
                get(&row, "object_type"),
                Some(&ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into())),
                "unparseable bytes must be typed `unknown`, never guessed"
            );
            assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(bad.len() as u32)));
        }
    }

    #[test]
    fn a_path_that_is_not_an_oid_yields_no_row() {
        let p = NativeGitPlugin::new();
        assert!(p.extract_metadata("HEAD", b"ref: refs/heads/main\n").is_none());
    }

    #[test]
    fn meta_is_the_discovery_record() {
        let m = NativeGitPlugin::new().meta();
        assert_eq!(m.name, "git");
        assert_eq!(m.type_id, GIT_TYPE_ID);
        assert!(m.aliases.contains(&"gunnar".to_string()));
        assert_eq!(m.commands.len(), 3);
    }
}