znippy_plugin_git/lib.rs
1//! znippy handler for **git object stores** — the `git` package format.
2//!
3//! One archive holds one git repository (D12: one `.znippy` per repo). The
4//! consumer is `gunnar`, a pure-Rust git server whose `repack()` writes the cold
5//! tier.
6//!
7//! ## The two tiers a repository can be stored as
8//!
9//! | tier | data entries | what it preserves |
10//! |---|---|---|
11//! | **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 |
12//! | **packs** | `pack-<id>.pack` + `pack-<id>.idx` | the client's own **deflate** and every **delta chain** |
13//!
14//! The object tier is the one this format was designed around and is what the
15//! `__gunnar_*` sub-indexes describe. **The pack tier is what gunnar actually
16//! seals**, and the reason is measured: a git object in a pack is already
17//! deflated and often a delta, so storing it inflated per oid costs an OpenZL
18//! decode plus a fresh zlib deflate on every fetch, for ever, and forecloses
19//! pack-copy permanently. gunnar measured an archive of 60000 small git objects
20//! at 3.4× *larger* than its input. See [`sections::GitIndexBuilder::pack_tier`].
21//!
22//! ## What this format adds on top of a plain archive
23//!
24//! | column / module | carries |
25//! |---|---|
26//! | `object_type` | `blob` / `tree` / `commit` / `tag` for the object tier; `packfile` / `pack-index` for the pack tier — typed listing with no reads |
27//! | `object_size` | content length, so quota gates and size analytics are index-only |
28//! | `__gunnar_oid__` | an `stree` over the first 8 bytes of every oid → its lookup row |
29//! | `__gunnar_graph__` | the commit graph as Arrow: `oid, parents[], tree, committer_time, generation` |
30//! | `__gunnar_reach__` | per-commit reachability bitmaps (roaring) over object ordinals |
31//! | `__gunnar_refs__` / `__gunnar_secrets__` | the server's push logs, on **either** tier |
32//!
33//! The last three object modules are emitted for the object tier only — a pack
34//! carries its own `.idx`, which is a better oid index than `__gunnar_oid__`
35//! because it addresses pack offsets.
36//!
37//! All of these modules are **reserved** (`znippy_common::is_reserved_module`),
38//! so `znippy list`, `decompress`, the iceberg sink and the manifest readers skip
39//! them exactly as they skip the lookup and the trie.
40//!
41//! ## Laws it honours
42//!
43//! **P-1** one archive = one ecosystem (`--format git`); **P-2** it writes only
44//! the columns it declared; **P-3** it is discovered through [`meta`]; **P-4** a
45//! malformed or hostile entry never panics — `object_type` degrades to
46//! `unknown` and the archive still writes.
47//!
48//! ## Who writes the reserved sections
49//!
50//! [`GitIndexBuilder`] does, handed to `ArrowIpcSink::with_reserved_builder` by
51//! the writer that knows the object set — gunnar's `repack()`. It is deliberately
52//! **not** wired into `znippy compress --format git`: the CLI's small-file batch
53//! pass never sees objects that take the big-file path, so a CLI-built index
54//! would be silently incomplete, and a silently incomplete oid index is worse
55//! than none. `znippy compress --format git` therefore writes the two columns and
56//! no reserved sections; `znippy run git lookup|graph` reads archives that carry
57//! them.
58//!
59//! Native builtin, registered in `znippy-cli/src/handlers.rs::builtin_handlers`.
60//! Deliberately **not** a WASM plugin: the oid index's hot path is `stree`, which
61//! needs AVX2, an mmap and prefetch, none of which wasm offers.
62
63use std::collections::HashMap;
64
65use znippy_common::arrow::datatypes::{DataType, Field};
66use znippy_common::plugin::{
67 ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta,
68};
69
70/// The measured workloads behind the `znippy.git_*` benches (feature
71/// `bench-kernels`). Not linked into a shipped archiver.
72#[cfg(feature = "bench-kernels")]
73pub mod bench_kernels;
74/// The push path's archive writer: one trait, three durability contracts
75/// ([`FastWriter`](archive_write::FastWriter),
76/// [`SafeWriter`](archive_write::SafeWriter),
77/// [`UringWriter`](uring_write::UringWriter)).
78pub mod archive_write;
79pub(crate) mod archive_map;
80/// **Which implementation of each trait a store is built on.** One selector,
81/// read once at construction, never per operation — [`arms::StoreConfig`].
82pub mod arms;
83pub mod graph;
84pub mod index_layout;
85/// The shared, off-the-ack-path index builder — one indexer per account,
86/// gatling fan-out, index tables built last.
87pub mod indexer;
88pub mod object;
89pub mod oid_index;
90/// Computing a git delta, for the one entry shape this engine cannot copy: a
91/// stored delta whose base falls outside the request and whose receiver holds
92/// nothing. The only place in this crate that computes one.
93pub mod delta;
94/// One pass over a pushed packfile's entries — the split the closure check
95/// falls out of. Ours, gated against gix's grammar entry for entry.
96pub mod pack_walk;
97pub mod pushlog;
98/// The read path as one stack: Ragnar `stree` in front, the Arrow index tables
99/// it points into, and the redb un-sealed tail it falls through to.
100pub mod read_stack;
101/// Pack entries to oids: the indexer's half of the split, off the ack path.
102pub mod resolve;
103/// §14's exploded objects table: resolved content, derived from the verbatim
104/// truth, built eagerly by the same indexer, droppable at any time.
105pub mod exploded;
106pub mod exploded_arrow;
107pub mod reach;
108pub mod refs;
109/// Store-side replication to another gunnar. **Deliberately empty** — signature
110/// and contract only, no transport.
111pub mod replicate;
112pub mod secrets;
113pub mod store;
114/// Garbage collection: one trait, two implementations
115/// ([`gc::CompactInPlace`] in place, [`gc::NewGeneration`] into a new
116/// generation — the default).
117pub mod gc;
118/// `GitOps` — the typed API gunnar calls, and the store that implements it.
119/// Base znippy never sees this trait.
120pub mod git_ops;
121/// `GitServe` — the **reading** contract: decoded reads, the `HEAD` accessor
122/// pair, negotiation and pack emission. Layered above [`git_ops`], answered out
123/// of Arrow IPC, and **containing no gix** — see the module header for why that
124/// is a rule rather than a coincidence.
125pub mod serve;
126/// **Stock `git` as the arbiter, inside a repository.** Test support only —
127/// nothing on a serving, storing or indexing path may call it, because it forks
128/// `git`. It is `pub` because `tests/concurrent_push.rs` compiles against the
129/// library, and it exists because `git index-pack --strict` *segfaults* outside
130/// a repository, which is how an oracle came to be incapable of failing.
131pub mod git_oracle;
132/// io_uring arm of [`archive_write`]. Linux only — the module is empty
133/// elsewhere rather than substituting a `pwrite` path under an io_uring name.
134pub mod uring_write;
135pub mod sections;
136
137pub use archive_write::{ArchiveWrite, Faults, FastWriter, SafeWriter};
138pub use arms::{
139 ALL_ENV, DEFAULT_REDB_CACHE_BYTES, ENV_BOUNDARY_DELTA, ENV_EMIT_WORKERS, ENV_EXPLODE, ENV_GC,
140 ENV_INDEX, ENV_REACH_COMMITS, ENV_REDB_CACHE, ENV_WRITER, GcArm, IndexArm, StoreConfig,
141 WriterArm, env_reads, env_reads_here, redb_cache_bytes,
142};
143pub use indexer::{AccountIndexer, IndexJob, IndexRow, IndexerPool, Lookup, PushPath};
144#[cfg(target_os = "linux")]
145pub use uring_write::UringWriter;
146
147pub use graph::{CommitNode, decode_graph, graph_schema, read_graph};
148pub use object::{
149 GitHashKind, GitObject, GitObjectKind, PACKFILE_TYPE, PACK_INDEX_TYPE, PackFileKind, canonical,
150 is_oid_path, pack_path_kind, parse_canonical, parse_commit, tree_entries,
151};
152pub use oid_index::{GitOidIndex, OidEntry, OidHit, OidLayout, key_for_oid};
153pub use pushlog::{
154 CompactionPolicy, CompactionReport, Finish, PushLog, PushLogScan, scan_frames,
155};
156pub use reach::{ReachEntry, ReachPolicy, decode_reach, read_reach, reach_schema};
157pub use refs::{RefLog, RefState, RefUpdate, read_refs, refs_schema};
158pub use secrets::{SecretState, SecretUpdate, SecretsLog, read_secrets, secrets_schema};
159pub use sections::GitIndexBuilder;
160pub use git_ops::{
161 GitOps, GitStore, LookupPath, Oid, RefRow, SelectedStore, Stored, TxId, lookup_path,
162 open_from_env, open_selected,
163};
164pub use serve::{Caps, GitServe, PackStats, ReachSet, HEAD};
165/// The typed ref rejection, re-exported so a consumer of this crate reaches it
166/// without also naming the contract crate. `RefRejection::of(&err)` is the only
167/// sanctioned way to ask whether a ref write lost a race — never a message.
168pub use git_storage_trait::{Observed, RefCas, RefRejection, RefTarget};
169
170/// DenseUnion / `pkg_type` discriminant. Clear of the built-ins (1–25), media
171/// (25), skidbladnir (40) and rust-toolchain (41).
172pub const GIT_TYPE_ID: i8 = 42;
173
174/// `object_type` written when the entry is not parseable as a canonical git
175/// object. A distinct, queryable state — never a silent `blob`.
176pub const UNKNOWN_OBJECT_TYPE: &str = "unknown";
177
178/// Native git-object handler.
179pub struct NativeGitPlugin;
180
181impl NativeGitPlugin {
182 pub fn new() -> Self {
183 NativeGitPlugin
184 }
185}
186
187impl Default for NativeGitPlugin {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193impl ArchiveTypePlugin for NativeGitPlugin {
194 fn name(&self) -> &str {
195 "git"
196 }
197
198 fn type_id(&self) -> i8 {
199 GIT_TYPE_ID
200 }
201
202 fn meta(&self) -> HandlerMeta {
203 HandlerMeta {
204 name: "git".into(),
205 aliases: vec!["gunnar".into(), "git-objects".into()],
206 type_id: GIT_TYPE_ID,
207 ecosystem: "Git object store (gunnar cold tier — one archive per repository)".into(),
208 // Left empty on purpose, and it is not an oversight. A loose git
209 // object carries no extension at all — its name IS its oid — and
210 // the pack entries this handler also claims are matched on their
211 // whole `pack-<oid>.<ext>` shape, not on a suffix. An extension
212 // list here would claim every `.pack` and `.idx` in any archive,
213 // which is wider than the truth. `matches_path` is the authority.
214 extensions: Vec::new(),
215 description: "Stores a git repository: either canonical objects keyed by oid hex, \
216 with the reserved __gunnar_oid__ (stree) / __gunnar_graph__ (commit \
217 graph) / __gunnar_reach__ (reachability bitmaps) sub-indexes, or the \
218 pack tier (pack-<id>.pack / .idx) that preserves the client's deflate \
219 and its delta chains"
220 .into(),
221 commands: vec![
222 HandlerCommand::new(
223 "inspect",
224 "Print type/size/sha1/sha256 for a file of canonical git object bytes",
225 ),
226 HandlerCommand::new(
227 "lookup",
228 "Resolve an oid against an archive's __gunnar_oid__ index: `git lookup <archive> <oid>`",
229 ),
230 HandlerCommand::new(
231 "graph",
232 "Print an archive's commit graph (oid, generation, time, parents)",
233 ),
234 HandlerCommand::new(
235 "refs",
236 "Print a live store's ref namespace: `git refs <store-root>`",
237 ),
238 ],
239 }
240 }
241
242 /// **CLI-only string dispatch** (§13.3). A server never comes through here:
243 /// it holds a [`GitStore`] and calls [`GitOps`] directly, typed.
244 ///
245 /// `refs` is what that delegation looks like — open the store, call the trait
246 /// method, print what it returned, and carry no logic of its own. The three
247 /// commands beside it are older and are **archive inspection**, not store
248 /// operations: they read a *sealed* archive's sections, which is a different
249 /// thing from a live store and is deliberately not modelled as a `GitOps`
250 /// call. They are left as they are rather than bent through a trait that does
251 /// not describe them.
252 fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
253 match cmd {
254 "refs" => {
255 let root = args
256 .first()
257 .ok_or_else(|| anyhow::anyhow!("usage: git refs <store-root>"))?;
258 // Everything below this line is printing. The answer comes from
259 // the trait.
260 let store = GitStore::open(std::path::Path::new(root), "cli")?;
261 for r in store.refs()? {
262 println!(
263 "{}\t{}{}",
264 r.name,
265 r.oid.as_deref().map(hex::encode).unwrap_or_else(|| "-".into()),
266 match (&r.peeled, &r.symref_target) {
267 (Some(p), _) => format!("\t^{}", hex::encode(p)),
268 (None, Some(t)) => format!("\t-> {t}"),
269 (None, None) => String::new(),
270 }
271 );
272 }
273 Ok(())
274 }
275 "inspect" => {
276 let path = args
277 .first()
278 .ok_or_else(|| anyhow::anyhow!("usage: git inspect <object-file>"))?;
279 let data = std::fs::read(path)?;
280 match parse_canonical(&data) {
281 Some(obj) => {
282 println!("type: {}", obj.kind.as_str());
283 println!("size: {}", obj.payload.len());
284 println!("sha1: {}", GitHashKind::Sha1.oid_hex_of(&data));
285 println!("sha256: {}", GitHashKind::Sha256.oid_hex_of(&data));
286 Ok(())
287 }
288 None => anyhow::bail!(
289 "'{path}' is not canonical git object bytes (`<type> <size>\\0<content>`)"
290 ),
291 }
292 }
293 "lookup" => {
294 let (archive, oid) = match (args.first(), args.get(1)) {
295 (Some(a), Some(o)) => (a, o),
296 _ => anyhow::bail!("usage: git lookup <archive> <oid-hex>"),
297 };
298 let index = GitOidIndex::open(std::path::Path::new(archive))?.ok_or_else(|| {
299 anyhow::anyhow!("'{archive}' carries no __gunnar_oid__ index")
300 })?;
301 match index.lookup_hex(oid) {
302 Some(hit) => {
303 println!("oid: {oid}");
304 println!("lookup_row: {}", hit.lookup_row);
305 println!("ordinal: {}", hit.ordinal);
306 Ok(())
307 }
308 None => anyhow::bail!("oid '{oid}' is not in '{archive}'"),
309 }
310 }
311 "graph" => {
312 let archive = args
313 .first()
314 .ok_or_else(|| anyhow::anyhow!("usage: git graph <archive>"))?;
315 let nodes = read_graph(std::path::Path::new(archive))?.ok_or_else(|| {
316 anyhow::anyhow!("'{archive}' carries no __gunnar_graph__ section")
317 })?;
318 println!("commits: {}", nodes.len());
319 for n in &nodes {
320 println!(
321 " {} gen={} time={} parents=[{}]",
322 n.oid,
323 n.generation,
324 n.committer_time.map(|t| t.to_string()).unwrap_or_else(|| "-".into()),
325 n.parents.join(" ")
326 );
327 }
328 Ok(())
329 }
330 other => anyhow::bail!("git: unknown subcommand '{other}'"),
331 }
332 }
333
334 /// A git archive's entries are either **oids** — the whole `relative_path`
335 /// is 40 or 64 lowercase hex characters — or the **packs** of a repository's
336 /// cold tier, `pack-<id>.pack` / `pack-<id>.idx`.
337 ///
338 /// Both are the same ecosystem and so belong to one handler (P-1). Which of
339 /// the two an archive holds is not a variant of the format: it is what the
340 /// writer had. gunnar's `repack()` produces a consolidated pack and seals
341 /// that, because a pack preserves the pushing client's own deflate and its
342 /// delta chains, and an inflated per-oid tier destroys both permanently.
343 fn matches_path(&self, path: &str) -> bool {
344 is_oid_path(path) || pack_path_kind(path).is_some()
345 }
346
347 fn schema_fields(&self) -> Vec<Field> {
348 vec![
349 Field::new("object_type", DataType::Utf8, true),
350 // UInt32: the index's extension-column writer materialises UInt32 and
351 // Utf8 only. A git object larger than 4 GiB saturates here; its exact
352 // byte length is always available from the base `uncompressed_size`
353 // column, which is u64 and is the authority.
354 Field::new("object_size", DataType::UInt32, true),
355 ]
356 }
357
358 fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
359 if !self.matches_path(path) {
360 return None;
361 }
362 let mut f: HashMap<String, ExtensionValue> = HashMap::new();
363
364 // A pack is typed by its magic, never by its name. A name is a claim
365 // the writer made; the magic is what the bytes are. `pack-<hex>.pack`
366 // over something that is not a packfile degrades to `unknown` exactly
367 // as malformed object bytes do (P-4) — a wrong type in the index is
368 // worse than an honest absence, because it is queried and believed.
369 if let Some(kind) = pack_path_kind(path) {
370 let typed = if data.starts_with(kind.magic()) {
371 kind.as_str()
372 } else {
373 UNKNOWN_OBJECT_TYPE
374 };
375 f.insert("object_type".into(), ExtensionValue::Str(typed.into()));
376 f.insert(
377 "object_size".into(),
378 ExtensionValue::U32(data.len().min(u32::MAX as usize) as u32),
379 );
380 return Some(ExtensionRow { fields: f });
381 }
382
383 match parse_canonical(data) {
384 Some(obj) => {
385 f.insert("object_type".into(), ExtensionValue::Str(obj.kind.as_str().into()));
386 f.insert(
387 "object_size".into(),
388 ExtensionValue::U32(obj.payload.len().min(u32::MAX as usize) as u32),
389 );
390 }
391 None => {
392 // P-4: a decompress bomb, a truncated object or plain garbage
393 // still writes a row — typed `unknown`, sized by what is there.
394 f.insert(
395 "object_type".into(),
396 ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into()),
397 );
398 f.insert(
399 "object_size".into(),
400 ExtensionValue::U32(data.len().min(u32::MAX as usize) as u32),
401 );
402 }
403 }
404 Some(ExtensionRow { fields: f })
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::object::canonical;
412
413 fn get<'a>(row: &'a ExtensionRow, k: &str) -> Option<&'a ExtensionValue> {
414 row.fields.get(k)
415 }
416
417 #[test]
418 fn claims_oid_paths_and_nothing_else() {
419 let p = NativeGitPlugin::new();
420 assert!(p.matches_path(&"a1b2c3d4".repeat(5))); // 40 hex
421 assert!(p.matches_path(&"0f".repeat(32))); // 64 hex
422 assert!(!p.matches_path("refs/heads/main"));
423 assert!(!p.matches_path("objects/pack/pack-abc.pack"));
424 assert!(!p.matches_path(&"A1B2C3D4".repeat(5)));
425 }
426
427 /// The pack tier's two file names are claimed, and only in the shape git
428 /// itself writes. A bare `.pack` suffix is not enough: an archive of
429 /// arbitrary files must not have its entries retyped as git packs.
430 #[test]
431 fn claims_the_pack_tier_by_its_whole_name_not_by_a_suffix() {
432 let p = NativeGitPlugin::new();
433 let id40 = "a1b2c3d4".repeat(5);
434 let id64 = "0f".repeat(32);
435 assert_eq!(pack_path_kind(&format!("pack-{id40}.pack")), Some(PackFileKind::Data));
436 assert_eq!(pack_path_kind(&format!("pack-{id64}.idx")), Some(PackFileKind::Index));
437 assert!(p.matches_path(&format!("pack-{id64}.pack")));
438 assert!(p.matches_path(&format!("objects/pack/pack-{id40}.idx")));
439
440 for not_ours in [
441 "pack-nothex.pack",
442 "somefile.pack",
443 "pack-.pack",
444 &format!("pack-{id40}.bitmap"),
445 &format!("pack-{}.pack", "A1B2C3D4".repeat(5)),
446 &format!("{id40}.pack"),
447 ] {
448 assert_eq!(pack_path_kind(not_ours), None, "wrongly claimed {not_ours}");
449 }
450 assert!(!p.matches_path("somefile.pack"));
451 }
452
453 /// A pack is typed by its **magic**, never by the name the writer chose.
454 /// A file called `pack-….pack` that does not start with `PACK` is
455 /// `unknown` — the same honest state malformed object bytes get (P-4).
456 #[test]
457 fn a_pack_is_typed_by_its_magic_and_a_liar_is_unknown() {
458 let p = NativeGitPlugin::new();
459 let id = "0f".repeat(32);
460
461 let mut pack = b"PACK".to_vec();
462 pack.extend_from_slice(&2u32.to_be_bytes());
463 pack.extend_from_slice(&7u32.to_be_bytes());
464 let row = p.extract_metadata(&format!("pack-{id}.pack"), &pack).unwrap();
465 assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str(PACKFILE_TYPE.into())));
466 assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(pack.len() as u32)));
467
468 let idx = b"\xfftOc\x00\x00\x00\x02".to_vec();
469 let row = p.extract_metadata(&format!("pack-{id}.idx"), &idx).unwrap();
470 assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str(PACK_INDEX_TYPE.into())));
471
472 // Now the liars. Each is named as a pack and is not one.
473 for (name, bytes) in [
474 (format!("pack-{id}.pack"), &b"NOTAPACK"[..]),
475 (format!("pack-{id}.idx"), &b"PACK\0\0\0\x02"[..]),
476 (format!("pack-{id}.pack"), &b""[..]),
477 ] {
478 let row = p.extract_metadata(&name, bytes).expect("a claimed path always yields a row");
479 assert_eq!(
480 get(&row, "object_type"),
481 Some(&ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into())),
482 "{name} carries {bytes:?}, which is not that kind of file — it must not be typed \
483 from its name"
484 );
485 }
486 }
487
488 #[test]
489 fn types_and_sizes_come_from_the_stored_bytes() {
490 let p = NativeGitPlugin::new();
491 let body = b"hello world";
492 let bytes = canonical(GitObjectKind::Blob, body);
493 let oid = GitHashKind::Sha256.oid_hex_of(&bytes);
494 let row = p.extract_metadata(&oid, &bytes).unwrap();
495 assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str("blob".into())));
496 assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(body.len() as u32)));
497
498 let commit = canonical(GitObjectKind::Commit, b"tree x\n\nmsg\n");
499 let coid = GitHashKind::Sha1.oid_hex_of(&commit);
500 let crow = p.extract_metadata(&coid, &commit).unwrap();
501 assert_eq!(get(&crow, "object_type"), Some(&ExtensionValue::Str("commit".into())));
502 }
503
504 #[test]
505 fn garbage_degrades_to_unknown_and_never_panics() {
506 let p = NativeGitPlugin::new();
507 let oid = "d".repeat(64);
508 for bad in [&b""[..], &b"\0\0\0"[..], &[0xffu8; 4096][..], &b"blob 99\0short"[..]] {
509 let row = p.extract_metadata(&oid, bad).expect("a claimed path always yields a row");
510 assert_eq!(
511 get(&row, "object_type"),
512 Some(&ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into())),
513 "unparseable bytes must be typed `unknown`, never guessed"
514 );
515 assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(bad.len() as u32)));
516 }
517 }
518
519 #[test]
520 fn a_path_that_is_not_an_oid_yields_no_row() {
521 let p = NativeGitPlugin::new();
522 assert!(p.extract_metadata("HEAD", b"ref: refs/heads/main\n").is_none());
523 }
524
525 #[test]
526 fn meta_is_the_discovery_record() {
527 let m = NativeGitPlugin::new().meta();
528 assert_eq!(m.name, "git");
529 assert_eq!(m.type_id, GIT_TYPE_ID);
530 assert!(m.aliases.contains(&"gunnar".to_string()));
531 assert_eq!(m.commands.len(), 4);
532 assert!(
533 m.commands.iter().any(|c| c.name == "refs"),
534 "the GitOps-delegating command is not advertised"
535 );
536 }
537}