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
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
//! 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;
/// The push path's archive writer: one trait, three durability contracts
/// ([`FastWriter`](archive_write::FastWriter),
/// [`SafeWriter`](archive_write::SafeWriter),
/// [`UringWriter`](uring_write::UringWriter)).
pub mod archive_write;
pub(crate) mod archive_map;
/// **Which implementation of each trait a store is built on.** One selector,
/// read once at construction, never per operation — [`arms::StoreConfig`].
pub mod arms;
pub mod graph;
pub mod index_layout;
/// The shared, off-the-ack-path index builder — one indexer per account,
/// gatling fan-out, index tables built last.
pub mod indexer;
pub mod object;
pub mod oid_index;
/// Computing a git delta, for the one entry shape this engine cannot copy: a
/// stored delta whose base falls outside the request and whose receiver holds
/// nothing. The only place in this crate that computes one.
pub mod delta;
/// One pass over a pushed packfile's entries — the split the closure check
/// falls out of. Ours, gated against gix's grammar entry for entry.
pub mod pack_walk;
pub mod pushlog;
/// The read path as one stack: Ragnar `stree` in front, the Arrow index tables
/// it points into, and the redb un-sealed tail it falls through to.
pub mod read_stack;
/// Pack entries to oids: the indexer's half of the split, off the ack path.
pub mod resolve;
/// §14's exploded objects table: resolved content, derived from the verbatim
/// truth, built eagerly by the same indexer, droppable at any time.
pub mod exploded;
pub mod exploded_arrow;
pub mod reach;
pub mod refs;
/// Store-side replication to another gunnar. **Deliberately empty** — signature
/// and contract only, no transport.
pub mod replicate;
pub mod secrets;
pub mod store;
/// Garbage collection: one trait, two implementations
/// ([`gc::CompactInPlace`] in place, [`gc::NewGeneration`] into a new
/// generation — the default).
pub mod gc;
/// `GitOps` — the typed API gunnar calls, and the store that implements it.
/// Base znippy never sees this trait.
pub mod git_ops;
/// `GitServe` — the **reading** contract: decoded reads, the `HEAD` accessor
/// pair, negotiation and pack emission. Layered above [`git_ops`], answered out
/// of Arrow IPC, and **containing no gix** — see the module header for why that
/// is a rule rather than a coincidence.
pub mod serve;
/// **Stock `git` as the arbiter, inside a repository.** Test support only —
/// nothing on a serving, storing or indexing path may call it, because it forks
/// `git`. It is `pub` because `tests/concurrent_push.rs` compiles against the
/// library, and it exists because `git index-pack --strict` *segfaults* outside
/// a repository, which is how an oracle came to be incapable of failing.
pub mod git_oracle;
/// io_uring arm of [`archive_write`]. Linux only — the module is empty
/// elsewhere rather than substituting a `pwrite` path under an io_uring name.
pub mod uring_write;
pub mod sections;

pub use archive_write::{ArchiveWrite, Faults, FastWriter, SafeWriter};
pub use arms::{
    ALL_ENV, DEFAULT_REDB_CACHE_BYTES, ENV_BOUNDARY_DELTA, ENV_EMIT_WORKERS, ENV_EXPLODE, ENV_GC,
    ENV_INDEX, ENV_REACH_COMMITS, ENV_REDB_CACHE, ENV_WRITER, GcArm, IndexArm, StoreConfig,
    WriterArm, env_reads, env_reads_here, redb_cache_bytes,
};
pub use indexer::{AccountIndexer, IndexJob, IndexRow, IndexerPool, Lookup, PushPath};
#[cfg(target_os = "linux")]
pub use uring_write::UringWriter;

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, OidLayout, 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;
pub use git_ops::{
    GitOps, GitStore, LookupPath, Oid, RefRow, SelectedStore, Stored, TxId, lookup_path,
    open_from_env, open_selected,
};
pub use serve::{Caps, GitServe, PackStats, ReachSet, HEAD};
/// The typed ref rejection, re-exported so a consumer of this crate reaches it
/// without also naming the contract crate. `RefRejection::of(&err)` is the only
/// sanctioned way to ask whether a ref write lost a race — never a message.
pub use git_storage_trait::{Observed, RefCas, RefRejection, RefTarget};

/// 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)",
                ),
                HandlerCommand::new(
                    "refs",
                    "Print a live store's ref namespace: `git refs <store-root>`",
                ),
            ],
        }
    }

    /// **CLI-only string dispatch** (§13.3). A server never comes through here:
    /// it holds a [`GitStore`] and calls [`GitOps`] directly, typed.
    ///
    /// `refs` is what that delegation looks like — open the store, call the trait
    /// method, print what it returned, and carry no logic of its own. The three
    /// commands beside it are older and are **archive inspection**, not store
    /// operations: they read a *sealed* archive's sections, which is a different
    /// thing from a live store and is deliberately not modelled as a `GitOps`
    /// call. They are left as they are rather than bent through a trait that does
    /// not describe them.
    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
        match cmd {
            "refs" => {
                let root = args
                    .first()
                    .ok_or_else(|| anyhow::anyhow!("usage: git refs <store-root>"))?;
                // Everything below this line is printing. The answer comes from
                // the trait.
                let store = GitStore::open(std::path::Path::new(root), "cli")?;
                for r in store.refs()? {
                    println!(
                        "{}\t{}{}",
                        r.name,
                        r.oid.as_deref().map(hex::encode).unwrap_or_else(|| "-".into()),
                        match (&r.peeled, &r.symref_target) {
                            (Some(p), _) => format!("\t^{}", hex::encode(p)),
                            (None, Some(t)) => format!("\t-> {t}"),
                            (None, None) => String::new(),
                        }
                    );
                }
                Ok(())
            }
            "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(), 4);
        assert!(
            m.commands.iter().any(|c| c.name == "refs"),
            "the GitOps-delegating command is not advertised"
        );
    }
}