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};
pub struct GitIndexBuilder {
hash: GitHashKind,
kinds: HashMap<String, GitObjectKind>,
order: Vec<String>,
trees: HashMap<String, Vec<u8>>,
commits: Vec<CommitNode>,
reach: ReachPolicy,
emit_reach: bool,
objects_in_packs: bool,
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(),
}
}
pub fn pack_tier(hash: GitHashKind) -> Self {
Self {
objects_in_packs: true,
..Self::new(hash)
}
}
pub fn is_pack_tier(&self) -> bool {
self.objects_in_packs
}
pub fn with_section(mut self, section: ReservedSection) -> Result<Self> {
if !znippy_common::is_reserved_module(§ion.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
}
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()
}
pub fn push_canonical(&mut self, canonical_bytes: &[u8]) -> Result<String> {
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)
}
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()
}
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)
}
pub fn build_sections(&self, first_row: &HashMap<&str, u64>) -> Result<Vec<ReservedSection>> {
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)
}
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)
}
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}");
}
#[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"
);
}
#[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
]
);
}
#[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) = §ions[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()]);
}
}
}