use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Result, anyhow};
use roaring::RoaringBitmap;
use znippy_common::GUNNAR_REACH_MODULE;
use znippy_common::arrow::array::{Array, BinaryArray, BinaryBuilder, StringArray, StringBuilder};
use znippy_common::arrow::datatypes::{DataType, Field, Schema};
use znippy_common::arrow::record_batch::RecordBatch;
use znippy_common::read_reserved_section_bytes;
use crate::graph::CommitNode;
use crate::object::{GitObjectKind, tree_entries};
#[derive(Debug, Clone, Copy)]
pub struct ReachPolicy {
pub max_commits: usize,
}
impl Default for ReachPolicy {
fn default() -> Self {
Self { max_commits: 512 }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReachEntry {
pub commit: String,
pub bitmap: RoaringBitmap,
}
pub fn reach_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("commit_oid", DataType::Utf8, false),
Field::new("cardinality", DataType::UInt64, false),
Field::new("bitmap", DataType::Binary, false),
]))
}
pub trait TreeSource: Sync {
fn tree(&self, oid_hex: &str) -> Option<std::borrow::Cow<'_, [u8]>>;
}
impl TreeSource for HashMap<String, Vec<u8>> {
fn tree(&self, oid_hex: &str) -> Option<std::borrow::Cow<'_, [u8]>> {
self.get(oid_hex).map(|v| std::borrow::Cow::Borrowed(v.as_slice()))
}
}
#[derive(Clone, Copy)]
pub struct OrdinalSpace<'a> {
raw: &'a [u8],
oid_len: usize,
}
impl<'a> OrdinalSpace<'a> {
pub fn new(raw: &'a [u8], oid_len: usize) -> Self {
debug_assert!(oid_len > 0 && raw.len() % oid_len == 0, "ragged ordinal space");
Self { raw, oid_len }
}
pub fn len(&self) -> usize {
if self.oid_len == 0 { 0 } else { self.raw.len() / self.oid_len }
}
pub fn is_empty(&self) -> bool {
self.raw.is_empty()
}
pub fn oid(&self, ordinal: u32) -> Option<&'a [u8]> {
let at = (ordinal as usize).checked_mul(self.oid_len)?;
self.raw.get(at..at + self.oid_len)
}
pub fn ordinal_of_raw(&self, oid: &[u8]) -> Option<u32> {
if oid.len() != self.oid_len {
return None;
}
let (mut lo, mut hi) = (0usize, self.len());
while lo < hi {
let mid = lo + (hi - lo) / 2;
let at = mid * self.oid_len;
match self.raw[at..at + self.oid_len].cmp(oid) {
std::cmp::Ordering::Less => lo = mid + 1,
std::cmp::Ordering::Greater => hi = mid,
std::cmp::Ordering::Equal => return Some(mid as u32),
}
}
None
}
pub fn ordinal_of_hex(&self, hex: &str) -> Option<u32> {
let mut buf = [0u8; 32];
let dst = buf.get_mut(..self.oid_len)?;
hex::decode_to_slice(hex, dst).ok()?;
self.ordinal_of_raw(dst)
}
}
pub fn carry_reach(
old: &[ReachEntry],
old_space: &OrdinalSpace<'_>,
new_space: &OrdinalSpace<'_>,
keep: impl Fn(&str) -> bool,
) -> Option<Vec<ReachEntry>> {
if old.is_empty() || old_space.is_empty() {
return None;
}
let mut remap: Vec<Option<u32>> = Vec::with_capacity(old_space.len());
let mut j = 0u32;
let new_len = new_space.len() as u32;
for o in 0..old_space.len() as u32 {
let oid = old_space.oid(o)?;
let mut hit = None;
while j < new_len {
let cand = new_space.oid(j)?;
match cand.cmp(oid) {
std::cmp::Ordering::Less => j += 1,
std::cmp::Ordering::Equal => {
hit = Some(j);
j += 1;
break;
}
std::cmp::Ordering::Greater => break,
}
}
remap.push(hit);
}
let mut out = Vec::with_capacity(old.len());
for e in old {
if !keep(&e.commit) {
continue;
}
let mut mapped = Vec::with_capacity(e.bitmap.len() as usize);
for b in e.bitmap.iter() {
match remap.get(b as usize).copied().flatten() {
Some(n) => mapped.push(n),
None => return None,
}
}
let bitmap = RoaringBitmap::from_sorted_iter(mapped).ok()?;
out.push(ReachEntry { commit: e.commit.clone(), bitmap });
}
Some(out)
}
#[cfg(test)]
mod carry_tests {
use super::*;
fn space(oids: &[u8]) -> Vec<u8> {
let mut v = oids.to_vec();
v.sort_unstable();
v
}
#[test]
fn a_carry_renumbers_bits_drops_dead_commits_and_refuses_a_missing_object() {
let old_raw = space(&[10, 20, 30, 40]);
let old = OrdinalSpace::new(&old_raw, 1);
let new_raw = space(&[10, 15, 20, 25, 30, 40, 50]);
let new = OrdinalSpace::new(&new_raw, 1);
let entries = vec![
ReachEntry { commit: "keep".into(), bitmap: [0u32, 2, 3].into_iter().collect() },
ReachEntry { commit: "gone".into(), bitmap: [1u32].into_iter().collect() },
];
let carried = carry_reach(&entries, &old, &new, |c| c == "keep").expect("exact carry");
assert_eq!(carried.len(), 1, "the dead commit's entry is dropped, not carried");
assert_eq!(carried[0].commit, "keep");
let bits: Vec<u32> = carried[0].bitmap.iter().collect();
assert_eq!(bits, vec![0, 4, 5], "10→0, 30→4, 40→5 in the new numbering");
let shrunk_raw = space(&[10, 20, 40]);
let shrunk = OrdinalSpace::new(&shrunk_raw, 1);
assert!(carry_reach(&entries, &old, &shrunk, |_| true).is_none(), "30 is gone; a bitmap naming it must not survive");
assert!(carry_reach(&[], &old, &new, |_| true).is_none());
}
}
pub struct ObjectFacts<'a> {
pub ordinal: OrdinalSpace<'a>,
pub trees: &'a dyn TreeSource,
pub oid_len: usize,
}
pub fn build_reach(
commits: &[CommitNode],
facts: &ObjectFacts<'_>,
policy: ReachPolicy,
) -> Vec<ReachEntry> {
let n = commits.len();
if n == 0 || policy.max_commits == 0 {
return Vec::new();
}
let pos: HashMap<&str, usize> = commits
.iter()
.enumerate()
.map(|(i, c)| (c.oid.as_str(), i))
.collect();
let selected = select_commits(commits, &pos, policy);
let keep: Vec<bool> = {
let mut k = vec![false; n];
for &i in &selected {
k[i] = true;
}
k
};
let mut pending: Vec<usize> = vec![0; n];
let mut parent_idx: Vec<Vec<usize>> = Vec::with_capacity(n);
for c in commits {
let mut ps: Vec<usize> = c.parents.iter().filter_map(|p| pos.get(p.as_str()).copied()).collect();
ps.sort_unstable();
ps.dedup();
for &p in &ps {
pending[p] += 1;
}
parent_idx.push(ps);
}
let mut tree_memo: HashMap<String, RoaringBitmap> = HashMap::new();
let mut live: HashMap<usize, RoaringBitmap> = HashMap::new();
for i in 0..n {
let c = &commits[i];
let mut bm = RoaringBitmap::new();
if let Some(o) = facts.ordinal.ordinal_of_hex(c.oid.as_str()) {
bm.insert(o);
}
if let Some(t) = &c.tree {
if let Some(tc) = tree_closure(t, facts, &mut tree_memo) {
bm |= tc;
}
}
for &p in &parent_idx[i] {
if let Some(pb) = live.get(&p) {
bm |= pb;
}
pending[p] -= 1;
if pending[p] == 0 && !keep[p] {
live.remove(&p);
}
}
if pending[i] > 0 || keep[i] {
live.insert(i, bm);
}
}
let mut out: Vec<ReachEntry> = Vec::with_capacity(selected.len());
for &i in &selected {
let bitmap = live.remove(&i).unwrap_or_else(|| {
panic!(
"commit {} was selected for a reachability bitmap and its bitmap is not live at \
the end of the build; an empty one here would under-send a clone",
commits[i].oid
)
});
out.push(ReachEntry {
commit: commits[i].oid.clone(),
bitmap,
});
}
out
}
fn select_commits(
commits: &[CommitNode],
pos: &HashMap<&str, usize>,
policy: ReachPolicy,
) -> Vec<usize> {
let n = commits.len();
let mut has_child = vec![false; n];
for c in commits {
for p in &c.parents {
if let Some(&pi) = pos.get(p.as_str()) {
has_child[pi] = true;
}
}
}
let mut chosen: Vec<usize> = (0..n).filter(|&i| !has_child[i]).collect();
chosen.truncate(policy.max_commits);
if chosen.len() < policy.max_commits {
let room = policy.max_commits - chosen.len();
let rest: Vec<usize> = (0..n).filter(|&i| has_child[i]).collect();
if !rest.is_empty() {
let stride = rest.len().div_ceil(room).max(1);
for &i in rest.iter().step_by(stride).take(room) {
chosen.push(i);
}
}
}
chosen.sort_unstable();
chosen.dedup();
chosen
}
pub(crate) fn tree_closure<'m>(
root: &str,
facts: &ObjectFacts<'_>,
memo: &'m mut HashMap<String, RoaringBitmap>,
) -> Option<&'m RoaringBitmap> {
if memo.contains_key(root) {
return memo.get(root);
}
let mut stack: Vec<(String, bool)> = vec![(root.to_string(), false)];
let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
while let Some((oid, expanded)) = stack.pop() {
if memo.contains_key(&oid) {
visiting.remove(&oid);
continue;
}
let Some(payload) = facts.trees.tree(&oid) else {
let mut bm = RoaringBitmap::new();
if let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) {
bm.insert(o);
}
memo.insert(oid, bm);
continue;
};
let children: Vec<String> = tree_entries(&payload, facts.oid_len)
.iter()
.map(|e| hex::encode(e.oid))
.collect();
if !expanded {
visiting.insert(oid.clone());
let unresolved: Vec<String> = children
.iter()
.filter(|c| !memo.contains_key(*c) && !visiting.contains(*c))
.cloned()
.collect();
if !unresolved.is_empty() {
stack.push((oid, true));
for c in unresolved {
stack.push((c, false));
}
continue;
}
}
let mut bm = RoaringBitmap::new();
if let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) {
bm.insert(o);
}
for c in &children {
if let Some(cb) = memo.get(c) {
bm |= cb;
} else if let Some(o) = facts.ordinal.ordinal_of_hex(c.as_str()) {
bm.insert(o);
}
}
memo.insert(oid.clone(), bm);
visiting.remove(&oid);
}
memo.get(root)
}
#[derive(Clone, Copy)]
pub(crate) struct ReachView<'a> {
pub reach: &'a [ReachEntry],
pub reach_index: &'a HashMap<String, u32>,
pub graph: &'a [CommitNode],
pub graph_index: &'a HashMap<String, u32>,
}
impl<'a> ReachView<'a> {
#[inline]
pub(crate) fn bitmap(&self, oid: &str) -> Option<&'a RoaringBitmap> {
self.reach_index
.get(oid)
.map(|&i| &self.reach[i as usize].bitmap)
}
#[inline]
pub(crate) fn node(&self, oid: &str) -> Option<&'a CommitNode> {
self.graph_index.get(oid).map(|&i| &self.graph[i as usize])
}
#[inline]
pub(crate) fn is_commit(&self, oid: &str) -> bool {
self.graph_index.contains_key(oid)
}
}
pub(crate) fn accumulate(
tip: &str,
view: &ReachView<'_>,
facts: &ObjectFacts<'_>,
acc: &mut RoaringBitmap,
) {
let mut unbitmapped: Vec<&str> = Vec::new();
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut stack: Vec<&str> = vec![tip];
while let Some(oid) = stack.pop() {
if !seen.insert(oid) {
continue;
}
if let Some(bm) = view.bitmap(oid) {
*acc |= bm;
continue;
}
let Some(node) = view.node(oid) else {
if let Some(o) = facts.ordinal.ordinal_of_hex(oid) {
acc.insert(o);
}
continue;
};
unbitmapped.push(oid);
for p in &node.parents {
stack.push(p.as_str());
}
}
for oid in unbitmapped {
if let Some(o) = facts.ordinal.ordinal_of_hex(oid) {
acc.insert(o);
}
let Some(node) = view.node(oid) else { continue };
if let Some(tree) = &node.tree {
accumulate_tree(tree.as_str(), facts, acc);
}
}
}
pub(crate) fn accumulate_tree(root: &str, facts: &ObjectFacts<'_>, acc: &mut RoaringBitmap) {
let mut stack: Vec<String> = vec![root.to_string()];
while let Some(oid) = stack.pop() {
let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) else {
continue;
};
if !acc.insert(o) {
continue;
}
if let Some(payload) = facts.trees.tree(oid.as_str()) {
for e in tree_entries(&payload, facts.oid_len) {
stack.push(hex::encode(e.oid));
}
}
}
}
pub fn needs_payload(kind: GitObjectKind) -> bool {
matches!(kind, GitObjectKind::Commit | GitObjectKind::Tree)
}
pub fn build_reach_batch(entries: &[ReachEntry]) -> Result<RecordBatch> {
use znippy_common::arrow::array::UInt64Builder;
let n = entries.len();
let mut oid_b = StringBuilder::with_capacity(n, n * 64);
let mut card_b = UInt64Builder::with_capacity(n);
let mut bm_b = BinaryBuilder::with_capacity(n, n * 128);
for e in entries {
oid_b.append_value(&e.commit);
card_b.append_value(e.bitmap.len());
let mut buf = Vec::new();
e.bitmap
.serialize_into(&mut buf)
.map_err(|err| anyhow!("reach bitmap serialize: {err}"))?;
bm_b.append_value(&buf);
}
RecordBatch::try_new(
reach_schema(),
vec![Arc::new(oid_b.finish()), Arc::new(card_b.finish()), Arc::new(bm_b.finish())],
)
.map_err(|e| anyhow!("reach batch: {e}"))
}
pub fn decode_reach(bytes: &[u8]) -> Result<Vec<ReachEntry>> {
let reader = znippy_common::ipc_guard::stream_reader(bytes)
.map_err(|e| anyhow!("reach reader: {e}"))?;
let mut out = Vec::new();
for batch in reader {
let batch = batch.map_err(|e| anyhow!("reach batch read: {e}"))?;
let oids = batch
.column_by_name("commit_oid")
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
.ok_or_else(|| anyhow!("reach: no `commit_oid` column"))?;
let bms = batch
.column_by_name("bitmap")
.and_then(|c| c.as_any().downcast_ref::<BinaryArray>())
.ok_or_else(|| anyhow!("reach: no `bitmap` column"))?;
for i in 0..batch.num_rows() {
let bitmap = RoaringBitmap::deserialize_from(bms.value(i))
.map_err(|e| anyhow!("reach bitmap deserialize: {e}"))?;
out.push(ReachEntry { commit: oids.value(i).to_string(), bitmap });
}
}
Ok(out)
}
pub fn read_reach(archive: &Path) -> Result<Option<Vec<ReachEntry>>> {
match read_reserved_section_bytes(archive, GUNNAR_REACH_MODULE)? {
Some(b) => Ok(Some(decode_reach(&b)?)),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::assign_generations;
fn hexid(c: char) -> String {
std::iter::repeat_n(c, 40).collect()
}
fn ordinal_raw(oids_in_order: &[&str]) -> Vec<u8> {
let mut v = Vec::new();
for o in oids_in_order {
v.extend_from_slice(&hex::decode(o).unwrap());
}
v
}
#[allow(clippy::type_complexity)]
fn tiny_repo() -> (Vec<CommitNode>, Vec<u8>, HashMap<String, Vec<u8>>) {
let blob1 = hexid('1');
let blob2 = hexid('2');
let tree1 = hexid('3');
let tree2 = hexid('4');
let c1 = hexid('5');
let c2 = hexid('6');
let mut trees: HashMap<String, Vec<u8>> = HashMap::new();
let mut t1 = Vec::new();
t1.extend_from_slice(b"100644 a\0");
t1.extend_from_slice(&hex::decode(&blob1).unwrap());
trees.insert(tree1.clone(), t1);
let mut t2 = Vec::new();
t2.extend_from_slice(b"100644 a\0");
t2.extend_from_slice(&hex::decode(&blob1).unwrap());
t2.extend_from_slice(b"100644 b\0");
t2.extend_from_slice(&hex::decode(&blob2).unwrap());
trees.insert(tree2.clone(), t2);
let ordinal = ordinal_raw(&[&blob1, &blob2, &tree1, &tree2, &c1, &c2]);
let commits = assign_generations(vec![
CommitNode {
oid: c1.clone(),
parents: vec![],
tree: Some(hexid('3')),
committer_time: Some(1),
generation: 0,
},
CommitNode {
oid: c2.clone(),
parents: vec![c1.clone()],
tree: Some(hexid('4')),
committer_time: Some(2),
generation: 0,
},
]);
(commits, ordinal, trees)
}
#[test]
fn bitmap_contains_exactly_the_reachable_objects() {
let (commits, ordinal, trees) = tiny_repo();
let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
let by_commit: HashMap<&str, &RoaringBitmap> =
entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
let b1: Vec<u32> = by_commit[hexid('5').as_str()].iter().collect();
assert_eq!(b1, vec![0, 2, 4], "c1 must not reach blob2/tree2/c2");
let b2: Vec<u32> = by_commit[hexid('6').as_str()].iter().collect();
assert_eq!(b2, vec![0, 1, 2, 3, 4, 5]);
}
#[test]
fn want_minus_have_is_an_andnot() {
let (commits, ordinal, trees) = tiny_repo();
let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
let by: HashMap<&str, &RoaringBitmap> =
entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
let want = by[hexid('6').as_str()].clone();
let have = by[hexid('5').as_str()].clone();
let delta: Vec<u32> = (want - have).iter().collect();
assert_eq!(delta, vec![1, 3, 5]);
}
#[test]
fn selection_takes_tips_first_and_respects_the_cap() {
let (commits, ordinal, trees) = tiny_repo();
let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 1 });
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].commit, hexid('6'), "the tip, not the root");
}
#[test]
fn batch_roundtrips_through_arrow_ipc() {
let (commits, ordinal, trees) = tiny_repo();
let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
let entries = build_reach(&commits, &facts, ReachPolicy::default());
let batch = build_reach_batch(&entries).unwrap();
let mut buf = Vec::new();
{
let mut w = znippy_common::arrow::ipc::writer::StreamWriter::try_new(
&mut buf,
&reach_schema(),
)
.unwrap();
w.write(&batch).unwrap();
w.finish().unwrap();
}
let back = decode_reach(&buf).unwrap();
assert_eq!(back, entries);
}
#[test]
fn a_corrupt_tree_cycle_terminates() {
let t = hexid('a');
let mut payload = Vec::new();
payload.extend_from_slice(b"40000 self\0");
payload.extend_from_slice(&hex::decode(&t).unwrap());
let trees: HashMap<String, Vec<u8>> = [(t.clone(), payload)].into_iter().collect();
let ordinal = ordinal_raw(&[&t, &hexid('b')]);
let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
let commits = assign_generations(vec![CommitNode {
oid: hexid('b'),
parents: vec![],
tree: Some(t),
committer_time: None,
generation: 0,
}]);
let entries = build_reach(&commits, &facts, ReachPolicy::default());
assert_eq!(entries.len(), 1);
let bits: Vec<u32> = entries[0].bitmap.iter().collect();
assert_eq!(bits, vec![0, 1]);
}
#[test]
fn ordinal_space_is_the_inverse_of_the_slice() {
let mut oids: Vec<[u8; 20]> = (0..32u8)
.map(|i| {
let mut o = [0u8; 20];
o[0] = i.wrapping_mul(37);
o[19] = i;
o
})
.collect();
oids.sort_unstable();
oids.dedup();
let mut raw = Vec::new();
for o in &oids {
raw.extend_from_slice(o);
}
let space = OrdinalSpace::new(&raw, 20);
assert_eq!(space.len(), oids.len());
for (o, oid) in oids.iter().enumerate() {
assert_eq!(space.oid(o as u32), Some(oid.as_slice()));
assert_eq!(space.ordinal_of_raw(oid), Some(o as u32));
assert_eq!(space.ordinal_of_hex(&hex::encode(oid)), Some(o as u32));
}
assert_eq!(space.ordinal_of_raw(&[0xffu8; 20]), None);
assert_eq!(space.ordinal_of_raw(&[0u8; 19]), None);
assert_eq!(space.oid(oids.len() as u32), None);
assert_eq!(space.ordinal_of_hex("not-hex"), None);
}
#[test]
fn ordinal_space_is_a_borrowed_view() {
assert_eq!(
std::mem::size_of::<OrdinalSpace<'_>>(),
std::mem::size_of::<&[u8]>() + std::mem::size_of::<usize>(),
"OrdinalSpace grew an owned field — it must stay a borrow of oids_raw"
);
fn assert_copy<T: Copy>() {}
assert_copy::<OrdinalSpace<'_>>();
}
}