1use std::collections::HashMap;
25use std::path::Path;
26use std::sync::Arc;
27
28use anyhow::{Result, anyhow};
29use roaring::RoaringBitmap;
30use znippy_common::GUNNAR_REACH_MODULE;
31use znippy_common::arrow::array::{Array, BinaryArray, BinaryBuilder, StringArray, StringBuilder};
32use znippy_common::arrow::datatypes::{DataType, Field, Schema};
33use znippy_common::arrow::ipc::reader::StreamReader;
34use znippy_common::arrow::record_batch::RecordBatch;
35use znippy_common::read_reserved_section_bytes;
36
37use crate::graph::CommitNode;
38use crate::object::{GitObjectKind, tree_entries};
39
40#[derive(Debug, Clone, Copy)]
42pub struct ReachPolicy {
43 pub max_commits: usize,
48}
49
50impl Default for ReachPolicy {
51 fn default() -> Self {
52 Self { max_commits: 512 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq)]
60pub struct ReachEntry {
61 pub commit: String,
62 pub bitmap: RoaringBitmap,
63}
64
65pub fn reach_schema() -> Arc<Schema> {
66 Arc::new(Schema::new(vec![
67 Field::new("commit_oid", DataType::Utf8, false),
68 Field::new("cardinality", DataType::UInt64, false),
69 Field::new("bitmap", DataType::Binary, false),
70 ]))
71}
72
73pub struct ObjectFacts<'a> {
75 pub ordinal: &'a HashMap<String, u32>,
77 pub trees: &'a HashMap<String, Vec<u8>>,
80 pub oid_len: usize,
82}
83
84pub fn build_reach(
90 commits: &[CommitNode],
91 facts: &ObjectFacts<'_>,
92 policy: ReachPolicy,
93) -> Vec<ReachEntry> {
94 let n = commits.len();
95 if n == 0 || policy.max_commits == 0 {
96 return Vec::new();
97 }
98 let pos: HashMap<&str, usize> = commits
99 .iter()
100 .enumerate()
101 .map(|(i, c)| (c.oid.as_str(), i))
102 .collect();
103
104 let selected = select_commits(commits, &pos, policy);
105 let keep: Vec<bool> = {
106 let mut k = vec![false; n];
107 for &i in &selected {
108 k[i] = true;
109 }
110 k
111 };
112
113 let mut pending: Vec<usize> = vec![0; n];
117 let mut parent_idx: Vec<Vec<usize>> = Vec::with_capacity(n);
118 for c in commits {
119 let mut ps: Vec<usize> = c.parents.iter().filter_map(|p| pos.get(p.as_str()).copied()).collect();
120 ps.sort_unstable();
121 ps.dedup();
122 for &p in &ps {
123 pending[p] += 1;
124 }
125 parent_idx.push(ps);
126 }
127
128 let mut tree_memo: HashMap<String, RoaringBitmap> = HashMap::new();
129 let mut live: HashMap<usize, RoaringBitmap> = HashMap::new();
130 let mut out: Vec<ReachEntry> = Vec::with_capacity(selected.len());
131
132 for i in 0..n {
133 let c = &commits[i];
134 let mut bm = RoaringBitmap::new();
135 if let Some(&o) = facts.ordinal.get(c.oid.as_str()) {
136 bm.insert(o);
137 }
138 if let Some(t) = &c.tree {
139 bm |= tree_closure(t, facts, &mut tree_memo);
140 }
141 for &p in &parent_idx[i] {
142 if let Some(pb) = live.get(&p) {
143 bm |= pb;
144 }
145 pending[p] -= 1;
146 if pending[p] == 0 && !keep[p] {
147 live.remove(&p);
148 }
149 }
150 if keep[i] {
151 out.push(ReachEntry { commit: c.oid.clone(), bitmap: bm.clone() });
152 }
153 if pending[i] > 0 || keep[i] {
154 live.insert(i, bm);
155 }
156 }
157 out
158}
159
160fn select_commits(
162 commits: &[CommitNode],
163 pos: &HashMap<&str, usize>,
164 policy: ReachPolicy,
165) -> Vec<usize> {
166 let n = commits.len();
167 let mut has_child = vec![false; n];
168 for c in commits {
169 for p in &c.parents {
170 if let Some(&pi) = pos.get(p.as_str()) {
171 has_child[pi] = true;
172 }
173 }
174 }
175 let mut chosen: Vec<usize> = (0..n).filter(|&i| !has_child[i]).collect();
176 chosen.truncate(policy.max_commits);
177
178 if chosen.len() < policy.max_commits {
179 let room = policy.max_commits - chosen.len();
180 let rest: Vec<usize> = (0..n).filter(|&i| has_child[i]).collect();
181 if !rest.is_empty() {
182 let stride = rest.len().div_ceil(room).max(1);
183 for &i in rest.iter().step_by(stride).take(room) {
184 chosen.push(i);
185 }
186 }
187 }
188 chosen.sort_unstable();
189 chosen.dedup();
190 chosen
191}
192
193pub(crate) fn tree_closure(
198 root: &str,
199 facts: &ObjectFacts<'_>,
200 memo: &mut HashMap<String, RoaringBitmap>,
201) -> RoaringBitmap {
202 if let Some(b) = memo.get(root) {
203 return b.clone();
204 }
205 let mut stack: Vec<(String, bool)> = vec![(root.to_string(), false)];
207 let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
211
212 while let Some((oid, expanded)) = stack.pop() {
213 if memo.contains_key(&oid) {
214 visiting.remove(&oid);
215 continue;
216 }
217 let Some(payload) = facts.trees.get(&oid) else {
218 let mut bm = RoaringBitmap::new();
220 if let Some(&o) = facts.ordinal.get(oid.as_str()) {
221 bm.insert(o);
222 }
223 memo.insert(oid, bm);
224 continue;
225 };
226 let children: Vec<String> = tree_entries(payload, facts.oid_len)
227 .iter()
228 .map(|e| hex::encode(e.oid))
229 .collect();
230 if !expanded {
231 visiting.insert(oid.clone());
232 let unresolved: Vec<String> = children
235 .iter()
236 .filter(|c| !memo.contains_key(*c) && !visiting.contains(*c))
237 .cloned()
238 .collect();
239 if !unresolved.is_empty() {
240 stack.push((oid, true));
241 for c in unresolved {
242 stack.push((c, false));
243 }
244 continue;
245 }
246 }
247 let mut bm = RoaringBitmap::new();
248 if let Some(&o) = facts.ordinal.get(oid.as_str()) {
249 bm.insert(o);
250 }
251 for c in &children {
252 if let Some(cb) = memo.get(c) {
253 bm |= cb;
254 } else if let Some(&o) = facts.ordinal.get(c.as_str()) {
255 bm.insert(o);
258 }
259 }
260 memo.insert(oid.clone(), bm);
261 visiting.remove(&oid);
262 }
263 memo.get(root).cloned().unwrap_or_default()
264}
265
266pub fn needs_payload(kind: GitObjectKind) -> bool {
272 matches!(kind, GitObjectKind::Commit | GitObjectKind::Tree)
273}
274
275pub fn build_reach_batch(entries: &[ReachEntry]) -> Result<RecordBatch> {
276 use znippy_common::arrow::array::UInt64Builder;
277 let n = entries.len();
278 let mut oid_b = StringBuilder::with_capacity(n, n * 64);
279 let mut card_b = UInt64Builder::with_capacity(n);
280 let mut bm_b = BinaryBuilder::with_capacity(n, n * 128);
281 for e in entries {
282 oid_b.append_value(&e.commit);
283 card_b.append_value(e.bitmap.len());
284 let mut buf = Vec::new();
285 e.bitmap
286 .serialize_into(&mut buf)
287 .map_err(|err| anyhow!("reach bitmap serialize: {err}"))?;
288 bm_b.append_value(&buf);
289 }
290 RecordBatch::try_new(
291 reach_schema(),
292 vec![Arc::new(oid_b.finish()), Arc::new(card_b.finish()), Arc::new(bm_b.finish())],
293 )
294 .map_err(|e| anyhow!("reach batch: {e}"))
295}
296
297pub fn decode_reach(bytes: &[u8]) -> Result<Vec<ReachEntry>> {
298 let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)
299 .map_err(|e| anyhow!("reach reader: {e}"))?;
300 let mut out = Vec::new();
301 for batch in reader {
302 let batch = batch.map_err(|e| anyhow!("reach batch read: {e}"))?;
303 let oids = batch
304 .column_by_name("commit_oid")
305 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
306 .ok_or_else(|| anyhow!("reach: no `commit_oid` column"))?;
307 let bms = batch
308 .column_by_name("bitmap")
309 .and_then(|c| c.as_any().downcast_ref::<BinaryArray>())
310 .ok_or_else(|| anyhow!("reach: no `bitmap` column"))?;
311 for i in 0..batch.num_rows() {
312 let bitmap = RoaringBitmap::deserialize_from(bms.value(i))
313 .map_err(|e| anyhow!("reach bitmap deserialize: {e}"))?;
314 out.push(ReachEntry { commit: oids.value(i).to_string(), bitmap });
315 }
316 }
317 Ok(out)
318}
319
320pub fn read_reach(archive: &Path) -> Result<Option<Vec<ReachEntry>>> {
322 match read_reserved_section_bytes(archive, GUNNAR_REACH_MODULE)? {
323 Some(b) => Ok(Some(decode_reach(&b)?)),
324 None => Ok(None),
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::graph::assign_generations;
332
333 fn hexid(c: char) -> String {
334 std::iter::repeat_n(c, 40).collect()
335 }
336
337 #[allow(clippy::type_complexity)]
340 fn tiny_repo() -> (Vec<CommitNode>, HashMap<String, u32>, HashMap<String, Vec<u8>>) {
341 let blob1 = hexid('1');
342 let blob2 = hexid('2');
343 let tree1 = hexid('3');
344 let tree2 = hexid('4');
345 let c1 = hexid('5');
346 let c2 = hexid('6');
347
348 let mut trees: HashMap<String, Vec<u8>> = HashMap::new();
349 let mut t1 = Vec::new();
350 t1.extend_from_slice(b"100644 a\0");
351 t1.extend_from_slice(&hex::decode(&blob1).unwrap());
352 trees.insert(tree1.clone(), t1);
353
354 let mut t2 = Vec::new();
355 t2.extend_from_slice(b"100644 a\0");
356 t2.extend_from_slice(&hex::decode(&blob1).unwrap());
357 t2.extend_from_slice(b"100644 b\0");
358 t2.extend_from_slice(&hex::decode(&blob2).unwrap());
359 trees.insert(tree2.clone(), t2);
360
361 let ordinal: HashMap<String, u32> = [
362 (blob1, 0u32),
363 (blob2, 1),
364 (tree1, 2),
365 (tree2, 3),
366 (c1.clone(), 4),
367 (c2.clone(), 5),
368 ]
369 .into_iter()
370 .collect();
371
372 let commits = assign_generations(vec![
373 CommitNode {
374 oid: c1.clone(),
375 parents: vec![],
376 tree: Some(hexid('3')),
377 committer_time: Some(1),
378 generation: 0,
379 },
380 CommitNode {
381 oid: c2.clone(),
382 parents: vec![c1.clone()],
383 tree: Some(hexid('4')),
384 committer_time: Some(2),
385 generation: 0,
386 },
387 ]);
388 (commits, ordinal, trees)
389 }
390
391 #[test]
392 fn bitmap_contains_exactly_the_reachable_objects() {
393 let (commits, ordinal, trees) = tiny_repo();
394 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
395 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
396
397 let by_commit: HashMap<&str, &RoaringBitmap> =
398 entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
399
400 let b1: Vec<u32> = by_commit[hexid('5').as_str()].iter().collect();
402 assert_eq!(b1, vec![0, 2, 4], "c1 must not reach blob2/tree2/c2");
403
404 let b2: Vec<u32> = by_commit[hexid('6').as_str()].iter().collect();
406 assert_eq!(b2, vec![0, 1, 2, 3, 4, 5]);
407 }
408
409 #[test]
410 fn want_minus_have_is_an_andnot() {
411 let (commits, ordinal, trees) = tiny_repo();
412 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
413 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
414 let by: HashMap<&str, &RoaringBitmap> =
415 entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
416
417 let want = by[hexid('6').as_str()].clone();
418 let have = by[hexid('5').as_str()].clone();
419 let delta: Vec<u32> = (want - have).iter().collect();
420 assert_eq!(delta, vec![1, 3, 5]);
422 }
423
424 #[test]
425 fn selection_takes_tips_first_and_respects_the_cap() {
426 let (commits, ordinal, trees) = tiny_repo();
427 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
428 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 1 });
429 assert_eq!(entries.len(), 1);
430 assert_eq!(entries[0].commit, hexid('6'), "the tip, not the root");
431 }
432
433 #[test]
434 fn batch_roundtrips_through_arrow_ipc() {
435 let (commits, ordinal, trees) = tiny_repo();
436 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
437 let entries = build_reach(&commits, &facts, ReachPolicy::default());
438 let batch = build_reach_batch(&entries).unwrap();
439 let mut buf = Vec::new();
440 {
441 let mut w = znippy_common::arrow::ipc::writer::StreamWriter::try_new(
442 &mut buf,
443 &reach_schema(),
444 )
445 .unwrap();
446 w.write(&batch).unwrap();
447 w.finish().unwrap();
448 }
449 let back = decode_reach(&buf).unwrap();
450 assert_eq!(back, entries);
451 }
452
453 #[test]
454 fn a_corrupt_tree_cycle_terminates() {
455 let t = hexid('a');
457 let mut payload = Vec::new();
458 payload.extend_from_slice(b"40000 self\0");
459 payload.extend_from_slice(&hex::decode(&t).unwrap());
460 let trees: HashMap<String, Vec<u8>> = [(t.clone(), payload)].into_iter().collect();
461 let ordinal: HashMap<String, u32> = [(t.clone(), 0u32), (hexid('b'), 1)].into_iter().collect();
462 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
463 let commits = assign_generations(vec![CommitNode {
464 oid: hexid('b'),
465 parents: vec![],
466 tree: Some(t),
467 committer_time: None,
468 generation: 0,
469 }]);
470 let entries = build_reach(&commits, &facts, ReachPolicy::default());
471 assert_eq!(entries.len(), 1);
472 let bits: Vec<u32> = entries[0].bitmap.iter().collect();
473 assert_eq!(bits, vec![0, 1]);
474 }
475}