1use std::collections::HashMap;
64
65use znippy_common::arrow::datatypes::{DataType, Field};
66use znippy_common::plugin::{
67 ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta,
68};
69
70#[cfg(feature = "bench-kernels")]
73pub mod bench_kernels;
74pub mod graph;
75pub mod object;
76pub mod oid_index;
77pub mod pushlog;
78pub mod reach;
79pub mod refs;
80pub mod secrets;
81pub mod sections;
82
83pub use graph::{CommitNode, decode_graph, graph_schema, read_graph};
84pub use object::{
85 GitHashKind, GitObject, GitObjectKind, PACKFILE_TYPE, PACK_INDEX_TYPE, PackFileKind, canonical,
86 is_oid_path, pack_path_kind, parse_canonical, parse_commit, tree_entries,
87};
88pub use oid_index::{GitOidIndex, OidEntry, OidHit, key_for_oid};
89pub use pushlog::{
90 CompactionPolicy, CompactionReport, Finish, PushLog, PushLogScan, scan_frames,
91};
92pub use reach::{ReachEntry, ReachPolicy, decode_reach, read_reach, reach_schema};
93pub use refs::{RefLog, RefState, RefUpdate, read_refs, refs_schema};
94pub use secrets::{SecretState, SecretUpdate, SecretsLog, read_secrets, secrets_schema};
95pub use sections::GitIndexBuilder;
96
97pub const GIT_TYPE_ID: i8 = 42;
100
101pub const UNKNOWN_OBJECT_TYPE: &str = "unknown";
104
105pub struct NativeGitPlugin;
107
108impl NativeGitPlugin {
109 pub fn new() -> Self {
110 NativeGitPlugin
111 }
112}
113
114impl Default for NativeGitPlugin {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl ArchiveTypePlugin for NativeGitPlugin {
121 fn name(&self) -> &str {
122 "git"
123 }
124
125 fn type_id(&self) -> i8 {
126 GIT_TYPE_ID
127 }
128
129 fn meta(&self) -> HandlerMeta {
130 HandlerMeta {
131 name: "git".into(),
132 aliases: vec!["gunnar".into(), "git-objects".into()],
133 type_id: GIT_TYPE_ID,
134 ecosystem: "Git object store (gunnar cold tier — one archive per repository)".into(),
135 extensions: Vec::new(),
142 description: "Stores a git repository: either canonical objects keyed by oid hex, \
143 with the reserved __gunnar_oid__ (stree) / __gunnar_graph__ (commit \
144 graph) / __gunnar_reach__ (reachability bitmaps) sub-indexes, or the \
145 pack tier (pack-<id>.pack / .idx) that preserves the client's deflate \
146 and its delta chains"
147 .into(),
148 commands: vec![
149 HandlerCommand::new(
150 "inspect",
151 "Print type/size/sha1/sha256 for a file of canonical git object bytes",
152 ),
153 HandlerCommand::new(
154 "lookup",
155 "Resolve an oid against an archive's __gunnar_oid__ index: `git lookup <archive> <oid>`",
156 ),
157 HandlerCommand::new(
158 "graph",
159 "Print an archive's commit graph (oid, generation, time, parents)",
160 ),
161 ],
162 }
163 }
164
165 fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
166 match cmd {
167 "inspect" => {
168 let path = args
169 .first()
170 .ok_or_else(|| anyhow::anyhow!("usage: git inspect <object-file>"))?;
171 let data = std::fs::read(path)?;
172 match parse_canonical(&data) {
173 Some(obj) => {
174 println!("type: {}", obj.kind.as_str());
175 println!("size: {}", obj.payload.len());
176 println!("sha1: {}", GitHashKind::Sha1.oid_hex_of(&data));
177 println!("sha256: {}", GitHashKind::Sha256.oid_hex_of(&data));
178 Ok(())
179 }
180 None => anyhow::bail!(
181 "'{path}' is not canonical git object bytes (`<type> <size>\\0<content>`)"
182 ),
183 }
184 }
185 "lookup" => {
186 let (archive, oid) = match (args.first(), args.get(1)) {
187 (Some(a), Some(o)) => (a, o),
188 _ => anyhow::bail!("usage: git lookup <archive> <oid-hex>"),
189 };
190 let index = GitOidIndex::open(std::path::Path::new(archive))?.ok_or_else(|| {
191 anyhow::anyhow!("'{archive}' carries no __gunnar_oid__ index")
192 })?;
193 match index.lookup_hex(oid) {
194 Some(hit) => {
195 println!("oid: {oid}");
196 println!("lookup_row: {}", hit.lookup_row);
197 println!("ordinal: {}", hit.ordinal);
198 Ok(())
199 }
200 None => anyhow::bail!("oid '{oid}' is not in '{archive}'"),
201 }
202 }
203 "graph" => {
204 let archive = args
205 .first()
206 .ok_or_else(|| anyhow::anyhow!("usage: git graph <archive>"))?;
207 let nodes = read_graph(std::path::Path::new(archive))?.ok_or_else(|| {
208 anyhow::anyhow!("'{archive}' carries no __gunnar_graph__ section")
209 })?;
210 println!("commits: {}", nodes.len());
211 for n in &nodes {
212 println!(
213 " {} gen={} time={} parents=[{}]",
214 n.oid,
215 n.generation,
216 n.committer_time.map(|t| t.to_string()).unwrap_or_else(|| "-".into()),
217 n.parents.join(" ")
218 );
219 }
220 Ok(())
221 }
222 other => anyhow::bail!("git: unknown subcommand '{other}'"),
223 }
224 }
225
226 fn matches_path(&self, path: &str) -> bool {
236 is_oid_path(path) || pack_path_kind(path).is_some()
237 }
238
239 fn schema_fields(&self) -> Vec<Field> {
240 vec![
241 Field::new("object_type", DataType::Utf8, true),
242 Field::new("object_size", DataType::UInt32, true),
247 ]
248 }
249
250 fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
251 if !self.matches_path(path) {
252 return None;
253 }
254 let mut f: HashMap<String, ExtensionValue> = HashMap::new();
255
256 if let Some(kind) = pack_path_kind(path) {
262 let typed = if data.starts_with(kind.magic()) {
263 kind.as_str()
264 } else {
265 UNKNOWN_OBJECT_TYPE
266 };
267 f.insert("object_type".into(), ExtensionValue::Str(typed.into()));
268 f.insert(
269 "object_size".into(),
270 ExtensionValue::U32(data.len().min(u32::MAX as usize) as u32),
271 );
272 return Some(ExtensionRow { fields: f });
273 }
274
275 match parse_canonical(data) {
276 Some(obj) => {
277 f.insert("object_type".into(), ExtensionValue::Str(obj.kind.as_str().into()));
278 f.insert(
279 "object_size".into(),
280 ExtensionValue::U32(obj.payload.len().min(u32::MAX as usize) as u32),
281 );
282 }
283 None => {
284 f.insert(
287 "object_type".into(),
288 ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into()),
289 );
290 f.insert(
291 "object_size".into(),
292 ExtensionValue::U32(data.len().min(u32::MAX as usize) as u32),
293 );
294 }
295 }
296 Some(ExtensionRow { fields: f })
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::object::canonical;
304
305 fn get<'a>(row: &'a ExtensionRow, k: &str) -> Option<&'a ExtensionValue> {
306 row.fields.get(k)
307 }
308
309 #[test]
310 fn claims_oid_paths_and_nothing_else() {
311 let p = NativeGitPlugin::new();
312 assert!(p.matches_path(&"a1b2c3d4".repeat(5))); assert!(p.matches_path(&"0f".repeat(32))); assert!(!p.matches_path("refs/heads/main"));
315 assert!(!p.matches_path("objects/pack/pack-abc.pack"));
316 assert!(!p.matches_path(&"A1B2C3D4".repeat(5)));
317 }
318
319 #[test]
323 fn claims_the_pack_tier_by_its_whole_name_not_by_a_suffix() {
324 let p = NativeGitPlugin::new();
325 let id40 = "a1b2c3d4".repeat(5);
326 let id64 = "0f".repeat(32);
327 assert_eq!(pack_path_kind(&format!("pack-{id40}.pack")), Some(PackFileKind::Data));
328 assert_eq!(pack_path_kind(&format!("pack-{id64}.idx")), Some(PackFileKind::Index));
329 assert!(p.matches_path(&format!("pack-{id64}.pack")));
330 assert!(p.matches_path(&format!("objects/pack/pack-{id40}.idx")));
331
332 for not_ours in [
333 "pack-nothex.pack",
334 "somefile.pack",
335 "pack-.pack",
336 &format!("pack-{id40}.bitmap"),
337 &format!("pack-{}.pack", "A1B2C3D4".repeat(5)),
338 &format!("{id40}.pack"),
339 ] {
340 assert_eq!(pack_path_kind(not_ours), None, "wrongly claimed {not_ours}");
341 }
342 assert!(!p.matches_path("somefile.pack"));
343 }
344
345 #[test]
349 fn a_pack_is_typed_by_its_magic_and_a_liar_is_unknown() {
350 let p = NativeGitPlugin::new();
351 let id = "0f".repeat(32);
352
353 let mut pack = b"PACK".to_vec();
354 pack.extend_from_slice(&2u32.to_be_bytes());
355 pack.extend_from_slice(&7u32.to_be_bytes());
356 let row = p.extract_metadata(&format!("pack-{id}.pack"), &pack).unwrap();
357 assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str(PACKFILE_TYPE.into())));
358 assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(pack.len() as u32)));
359
360 let idx = b"\xfftOc\x00\x00\x00\x02".to_vec();
361 let row = p.extract_metadata(&format!("pack-{id}.idx"), &idx).unwrap();
362 assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str(PACK_INDEX_TYPE.into())));
363
364 for (name, bytes) in [
366 (format!("pack-{id}.pack"), &b"NOTAPACK"[..]),
367 (format!("pack-{id}.idx"), &b"PACK\0\0\0\x02"[..]),
368 (format!("pack-{id}.pack"), &b""[..]),
369 ] {
370 let row = p.extract_metadata(&name, bytes).expect("a claimed path always yields a row");
371 assert_eq!(
372 get(&row, "object_type"),
373 Some(&ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into())),
374 "{name} carries {bytes:?}, which is not that kind of file — it must not be typed \
375 from its name"
376 );
377 }
378 }
379
380 #[test]
381 fn types_and_sizes_come_from_the_stored_bytes() {
382 let p = NativeGitPlugin::new();
383 let body = b"hello world";
384 let bytes = canonical(GitObjectKind::Blob, body);
385 let oid = GitHashKind::Sha256.oid_hex_of(&bytes);
386 let row = p.extract_metadata(&oid, &bytes).unwrap();
387 assert_eq!(get(&row, "object_type"), Some(&ExtensionValue::Str("blob".into())));
388 assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(body.len() as u32)));
389
390 let commit = canonical(GitObjectKind::Commit, b"tree x\n\nmsg\n");
391 let coid = GitHashKind::Sha1.oid_hex_of(&commit);
392 let crow = p.extract_metadata(&coid, &commit).unwrap();
393 assert_eq!(get(&crow, "object_type"), Some(&ExtensionValue::Str("commit".into())));
394 }
395
396 #[test]
397 fn garbage_degrades_to_unknown_and_never_panics() {
398 let p = NativeGitPlugin::new();
399 let oid = "d".repeat(64);
400 for bad in [&b""[..], &b"\0\0\0"[..], &[0xffu8; 4096][..], &b"blob 99\0short"[..]] {
401 let row = p.extract_metadata(&oid, bad).expect("a claimed path always yields a row");
402 assert_eq!(
403 get(&row, "object_type"),
404 Some(&ExtensionValue::Str(UNKNOWN_OBJECT_TYPE.into())),
405 "unparseable bytes must be typed `unknown`, never guessed"
406 );
407 assert_eq!(get(&row, "object_size"), Some(&ExtensionValue::U32(bad.len() as u32)));
408 }
409 }
410
411 #[test]
412 fn a_path_that_is_not_an_oid_yields_no_row() {
413 let p = NativeGitPlugin::new();
414 assert!(p.extract_metadata("HEAD", b"ref: refs/heads/main\n").is_none());
415 }
416
417 #[test]
418 fn meta_is_the_discovery_record() {
419 let m = NativeGitPlugin::new().meta();
420 assert_eq!(m.name, "git");
421 assert_eq!(m.type_id, GIT_TYPE_ID);
422 assert!(m.aliases.contains(&"gunnar".to_string()));
423 assert_eq!(m.commands.len(), 3);
424 }
425}