znippy_plugin_git/reach.rs
1//! `__gunnar_reach__` — reachability bitmaps.
2//!
3//! For each *selected* commit, the set of objects reachable from it — the commit
4//! itself, its ancestors, and every tree and blob those commits point at —
5//! recorded as a roaring bitmap over **object ordinals**.
6//!
7//! The ordinal space is the archive's oid-lexicographic ordering of its distinct
8//! objects, which is also the order the sorted lookup sub-index is in and the
9//! order `__gunnar_oid__` records in its `ordinal` column. So a bitmap position
10//! is resolvable to an object with the oid index alone.
11//!
12//! What it buys: `want − have` becomes `reach(want) ANDNOT reach(have)` — a
13//! bitmap operation instead of a graph traversal. **No speedup is claimed here.**
14//! Nothing in this repository has measured it against a real repository with real
15//! merge history; the structure is built and correct, the number is not earned
16//! (LAW 7).
17//!
18//! Cost, stated plainly: the builder is O(commits × bitmap width) in time and
19//! holds live bitmaps for commits whose children are not yet processed, plus one
20//! memoized tree-closure bitmap per distinct tree. On a wide DAG that is not
21//! cheap. `ReachPolicy::max_commits` bounds how many bitmaps are *kept*, not how
22//! many are computed.
23
24use 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/// Which commits get a bitmap.
41#[derive(Debug, Clone, Copy)]
42pub struct ReachPolicy {
43 /// Upper bound on how many bitmaps are sealed. Tips are taken first (a
44 /// commit with no child inside the archive — the branch heads a clone
45 /// negotiates against), then the remaining commits are sampled at an even
46 /// stride through generation order.
47 pub max_commits: usize,
48}
49
50impl Default for ReachPolicy {
51 fn default() -> Self {
52 // git's own bitmap selection is of this order: cover the tips, then
53 // sample the history so a `have` usually lands near a bitmapped commit.
54 Self { max_commits: 512 }
55 }
56}
57
58/// One sealed bitmap.
59#[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
73/// What the builder needs to know about every object in the archive.
74pub struct ObjectFacts<'a> {
75 /// Object ordinal for each oid hex — the bitmap space.
76 pub ordinal: &'a HashMap<String, u32>,
77 /// Payload of every tree object, by oid hex. Blobs are not needed (they are
78 /// leaves) and are deliberately not held.
79 pub trees: &'a HashMap<String, Vec<u8>>,
80 /// Raw oid width, needed to walk tree records.
81 pub oid_len: usize,
82}
83
84/// Build the reachability bitmaps.
85///
86/// `commits` must already carry generation numbers (see
87/// [`crate::graph::assign_generations`]) and be ordered parents-before-children,
88/// which that function guarantees.
89pub 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 // How many children still need each commit's bitmap. Once it hits zero and
114 // the commit is not selected, the bitmap is dropped — that is what keeps
115 // peak memory to the DAG's frontier rather than the whole history.
116 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
131 for i in 0..n {
132 let c = &commits[i];
133 let mut bm = RoaringBitmap::new();
134 if let Some(&o) = facts.ordinal.get(c.oid.as_str()) {
135 bm.insert(o);
136 }
137 if let Some(t) = &c.tree {
138 // Borrowed out of the memo, not cloned out of it. See
139 // [`tree_closure`]'s own note: this is once per commit and the thing
140 // being copied is a whole tree closure, which on a repository-shaped
141 // history is most of the object graph.
142 if let Some(tc) = tree_closure(t, facts, &mut tree_memo) {
143 bm |= tc;
144 }
145 }
146 for &p in &parent_idx[i] {
147 if let Some(pb) = live.get(&p) {
148 bm |= pb;
149 }
150 pending[p] -= 1;
151 if pending[p] == 0 && !keep[p] {
152 live.remove(&p);
153 }
154 }
155 // **Nothing is cloned here.** A kept commit's bitmap is inserted into
156 // `live` like any other and harvested below; the `out.push(ReachEntry {
157 // bitmap: bm.clone() })` that used to stand here made a second, deep
158 // copy of every selected commit's roaring bitmap while `live` held the
159 // first — and on the live selection path `keep` is *every* commit, so
160 // that was one full duplicate of the entire index. MEASURED on oden
161 // 2026-08-14, a 1 581-commit / 12 455-object store: the whole build was
162 // 530 218 allocations.
163 //
164 // Safe by the retention rule directly above: an entry is dropped from
165 // `live` only when `pending[p] == 0 && !keep[p]`, so a kept one is never
166 // dropped and is still there when the harvest runs.
167 if pending[i] > 0 || keep[i] {
168 live.insert(i, bm);
169 }
170 }
171
172 // The harvest: **move** each selected commit's bitmap out of `live`.
173 //
174 // `selected` is sorted ascending (`select_commits` sorts and dedups), and
175 // the loop above pushed in `0..n` order filtered by `keep`, so this produces
176 // the identical order the clone-per-iteration version did. That is not a
177 // detail to leave implicit — `decode_reach`/`build_reach_batch` round-trip
178 // this vector positionally.
179 let mut out: Vec<ReachEntry> = Vec::with_capacity(selected.len());
180 for &i in &selected {
181 // `expect` rather than `unwrap_or_default`: a missing entry here means
182 // the retention rule above let a kept commit be dropped, and answering
183 // with an EMPTY bitmap would be a silent under-send — a clone served
184 // one object per branch, exiting zero. Panicking names the commit.
185 let bitmap = live.remove(&i).unwrap_or_else(|| {
186 panic!(
187 "commit {} was selected for a reachability bitmap and its bitmap is not live at \
188 the end of the build; an empty one here would under-send a clone",
189 commits[i].oid
190 )
191 });
192 out.push(ReachEntry {
193 commit: commits[i].oid.clone(),
194 bitmap,
195 });
196 }
197 out
198}
199
200/// Tips first, then an even sample through the rest, capped at `max_commits`.
201fn select_commits(
202 commits: &[CommitNode],
203 pos: &HashMap<&str, usize>,
204 policy: ReachPolicy,
205) -> Vec<usize> {
206 let n = commits.len();
207 let mut has_child = vec![false; n];
208 for c in commits {
209 for p in &c.parents {
210 if let Some(&pi) = pos.get(p.as_str()) {
211 has_child[pi] = true;
212 }
213 }
214 }
215 let mut chosen: Vec<usize> = (0..n).filter(|&i| !has_child[i]).collect();
216 chosen.truncate(policy.max_commits);
217
218 if chosen.len() < policy.max_commits {
219 let room = policy.max_commits - chosen.len();
220 let rest: Vec<usize> = (0..n).filter(|&i| has_child[i]).collect();
221 if !rest.is_empty() {
222 let stride = rest.len().div_ceil(room).max(1);
223 for &i in rest.iter().step_by(stride).take(room) {
224 chosen.push(i);
225 }
226 }
227 }
228 chosen.sort_unstable();
229 chosen.dedup();
230 chosen
231}
232
233/// Every object reachable from a tree, memoized per tree oid.
234///
235/// Iterative (an explicit post-order stack), not recursive: a deep source tree
236/// would otherwise be a stack-depth bet.
237///
238/// # It **borrows** out of the memo rather than cloning out of it
239///
240/// It returned `RoaringBitmap` by value until 2026-08-14, which meant a deep
241/// copy of a whole tree closure on every call — including, and especially, the
242/// memo *hit* on the first line, which is the common case: consecutive commits
243/// in a history overwhelmingly share a root tree or find one already computed.
244/// The thing being copied is not small. On a repository-shaped history a root
245/// tree's closure is most of the object graph, so this was one full copy of it
246/// per commit, 1 581 times on the store this was measured against.
247///
248/// `None` means the memo has no entry for `root` even after the walk, which the
249/// walk only produces for an oid this archive does not hold. The caller ORs
250/// nothing in, which is what `unwrap_or_default()` did and is the same answer —
251/// it is `Option` rather than an empty bitmap so that "absent" and "reaches
252/// nothing" stay different words at the call site.
253pub(crate) fn tree_closure<'m>(
254 root: &str,
255 facts: &ObjectFacts<'_>,
256 memo: &'m mut HashMap<String, RoaringBitmap>,
257) -> Option<&'m RoaringBitmap> {
258 if memo.contains_key(root) {
259 // Deliberately re-looked-up at the bottom rather than returned from
260 // here: NLL cannot see that the early borrow ends on this path, and the
261 // alternative is a second `HashMap` probe on the hit path against a deep
262 // copy of the whole closure. The probe wins by orders of magnitude.
263 return memo.get(root);
264 }
265 // (oid, children_already_expanded)
266 let mut stack: Vec<(String, bool)> = vec![(root.to_string(), false)];
267 // Trees currently on the stack. A tree graph cannot contain a cycle, but a
268 // *corrupt* one can, and "cannot happen" is not a termination argument for
269 // bytes that arrived over the wire.
270 let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
271
272 while let Some((oid, expanded)) = stack.pop() {
273 if memo.contains_key(&oid) {
274 visiting.remove(&oid);
275 continue;
276 }
277 let Some(payload) = facts.trees.get(&oid) else {
278 // Not a tree we hold (a blob, or an object outside the archive).
279 let mut bm = RoaringBitmap::new();
280 if let Some(&o) = facts.ordinal.get(oid.as_str()) {
281 bm.insert(o);
282 }
283 memo.insert(oid, bm);
284 continue;
285 };
286 let children: Vec<String> = tree_entries(payload, facts.oid_len)
287 .iter()
288 .map(|e| hex::encode(e.oid))
289 .collect();
290 if !expanded {
291 visiting.insert(oid.clone());
292 // A child that is already `visiting` is a back-edge: do not push it
293 // again, or the stack never drains.
294 let unresolved: Vec<String> = children
295 .iter()
296 .filter(|c| !memo.contains_key(*c) && !visiting.contains(*c))
297 .cloned()
298 .collect();
299 if !unresolved.is_empty() {
300 stack.push((oid, true));
301 for c in unresolved {
302 stack.push((c, false));
303 }
304 continue;
305 }
306 }
307 let mut bm = RoaringBitmap::new();
308 if let Some(&o) = facts.ordinal.get(oid.as_str()) {
309 bm.insert(o);
310 }
311 for c in &children {
312 if let Some(cb) = memo.get(c) {
313 bm |= cb;
314 } else if let Some(&o) = facts.ordinal.get(c.as_str()) {
315 // Back-edge or an object outside the archive: take the object
316 // itself and stop rather than loop forever.
317 bm.insert(o);
318 }
319 }
320 memo.insert(oid.clone(), bm);
321 visiting.remove(&oid);
322 }
323 memo.get(root)
324}
325
326/// **The bounded walk: OR into `acc` everything reachable from `tip`, stopping
327/// at every commit that already has a bitmap.**
328///
329/// # Why this function is what makes a sampled bitmap table legal
330///
331/// Until 2026-08-14 this file had no walk at all, and the consequence ran all
332/// the way up the stack. [`crate::git_ops::GitStore::reachable_oids`] answered a
333/// `want` with no bitmap by contributing *the object itself and nothing else* —
334/// a silent under-send, a clone that exits zero having served one object per
335/// branch. `crate::serve`'s `select` therefore had to refuse any request whose
336/// tips were not all bitmapped, and the only way to make that refusal never fire
337/// was to bitmap **every** commit: `ReachPolicy { max_commits: usize::MAX }`, on
338/// the request thread, holding the derived write lock, after every push (because
339/// `refold` clears the table). MEASURED: 530 218 of a fetch's 575 349
340/// allocations were that build, for a request that sent four objects.
341///
342/// With a walk, a missing bitmap is a *bounded amount of work* instead of a
343/// wrong answer, and the table can be sampled like the sealed archive's already
344/// is.
345///
346/// # Why it is bounded, stated as the property it rests on
347///
348/// **A stored bitmap is closed under reachability.** So reaching one accounts
349/// for the entire history behind it, and the walk stops there rather than
350/// descending. What it covers is therefore only what has been pushed since the
351/// last time the table was built — not the repository — whatever the repository's
352/// size. This is git's `add_to_include_set` rule, arrived at independently here
353/// over a different object store: nothing of gitoxide's or of git's is copied,
354/// and this crate links neither (LAW 5 applies to code, and these are different
355/// codebases with different indexes).
356///
357/// # The two phases are an ordering, not a style
358///
359/// The commit walk runs to **completion** before a single tree is opened. Git
360/// applies its skip rule one level down as well — `should_include_obj`, do not
361/// descend into a tree whose bit is already set — and that rule is worth nothing
362/// unless every stored bitmap this request will ever OR in is already in `acc`
363/// when the tree walk starts. Get the order wrong and the first unbitmapped
364/// commit costs a walk of every tree and blob in the repository, and the bounded
365/// commit walk buys exactly nothing.
366pub(crate) fn accumulate(
367 tip: &str,
368 by_commit: &HashMap<&str, &RoaringBitmap>,
369 graph: &HashMap<&str, &CommitNode>,
370 facts: &ObjectFacts<'_>,
371 acc: &mut RoaringBitmap,
372) {
373 // ── phase 1: the commit walk, to completion ────────────────────────────
374 //
375 // Collect the commits that have no bitmap, ORing in every stored bitmap the
376 // frontier touches. Nothing here opens a tree.
377 let mut unbitmapped: Vec<&str> = Vec::new();
378 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
379 let mut stack: Vec<&str> = vec![tip];
380 while let Some(oid) = stack.pop() {
381 if !seen.insert(oid) {
382 continue;
383 }
384 // The stopping rule. A stored bitmap is closed, so everything behind
385 // this commit is already accounted for and its parents are not walked.
386 if let Some(bm) = by_commit.get(oid) {
387 *acc |= *bm;
388 continue;
389 }
390 let Some(node) = graph.get(oid) else {
391 // Not a commit this archive holds as a graph row. It contributes
392 // itself if the index knows it, and nothing else — there is no
393 // history here to walk. A `want` that reaches this and is genuinely
394 // absent is refused by the caller, which is the one place that can
395 // tell "absent" from "not a commit".
396 if let Some(&o) = facts.ordinal.get(oid) {
397 acc.insert(o);
398 }
399 continue;
400 };
401 unbitmapped.push(oid);
402 for p in &node.parents {
403 stack.push(p.as_str());
404 }
405 }
406
407 // ── phase 2: the trees, with the skip rule ─────────────────────────────
408 for oid in unbitmapped {
409 if let Some(&o) = facts.ordinal.get(oid) {
410 acc.insert(o);
411 }
412 let Some(node) = graph.get(oid) else { continue };
413 if let Some(tree) = &node.tree {
414 accumulate_tree(tree.as_str(), facts, acc);
415 }
416 }
417}
418
419/// [`accumulate`]'s second phase for one root tree: every object under it that
420/// is not already in `acc`.
421///
422/// The skip is git's `should_include_obj` and it is what keeps phase 2 bounded:
423/// a set bit means that object — and, for a tree, everything under it — is
424/// already accounted for, either by a stored bitmap ORed in during phase 1 or by
425/// an earlier commit in this same walk. Descending anyway would be correct and
426/// would also make the whole exercise pointless.
427///
428/// Iterative for the reason [`tree_closure`] is: a deep source tree is not a
429/// stack-depth bet. It needs no `visiting` set of its own — a corrupt cycle
430/// terminates here because a revisited oid's bit is already set.
431fn accumulate_tree(root: &str, facts: &ObjectFacts<'_>, acc: &mut RoaringBitmap) {
432 let mut stack: Vec<String> = vec![root.to_string()];
433 while let Some(oid) = stack.pop() {
434 let Some(&o) = facts.ordinal.get(oid.as_str()) else {
435 // An object this archive does not hold. A thin push's base, or a
436 // submodule gitlink, which is a commit oid that lives in another
437 // repository entirely and must never be selected for emission.
438 continue;
439 };
440 if !acc.insert(o) {
441 // Already set: this object, and everything under it, is accounted
442 // for. `RoaringBitmap::insert` returns false when the bit was
443 // already present, so the test and the set are one operation.
444 continue;
445 }
446 // Only trees carry pointers. A blob is a leaf and `facts.trees`
447 // deliberately does not hold one.
448 if let Some(payload) = facts.trees.get(oid.as_str()) {
449 for e in tree_entries(payload, facts.oid_len) {
450 stack.push(hex::encode(e.oid));
451 }
452 }
453 }
454}
455
456/// Which objects a repack must hold to make the reachability pass exact.
457///
458/// Only commits and trees carry pointers; blobs are leaves. The builder
459/// therefore needs tree payloads and nothing else, which is why
460/// [`ObjectFacts::trees`] exists and there is no `blobs` field.
461pub fn needs_payload(kind: GitObjectKind) -> bool {
462 matches!(kind, GitObjectKind::Commit | GitObjectKind::Tree)
463}
464
465pub fn build_reach_batch(entries: &[ReachEntry]) -> Result<RecordBatch> {
466 use znippy_common::arrow::array::UInt64Builder;
467 let n = entries.len();
468 let mut oid_b = StringBuilder::with_capacity(n, n * 64);
469 let mut card_b = UInt64Builder::with_capacity(n);
470 let mut bm_b = BinaryBuilder::with_capacity(n, n * 128);
471 for e in entries {
472 oid_b.append_value(&e.commit);
473 card_b.append_value(e.bitmap.len());
474 let mut buf = Vec::new();
475 e.bitmap
476 .serialize_into(&mut buf)
477 .map_err(|err| anyhow!("reach bitmap serialize: {err}"))?;
478 bm_b.append_value(&buf);
479 }
480 RecordBatch::try_new(
481 reach_schema(),
482 vec![Arc::new(oid_b.finish()), Arc::new(card_b.finish()), Arc::new(bm_b.finish())],
483 )
484 .map_err(|e| anyhow!("reach batch: {e}"))
485}
486
487pub fn decode_reach(bytes: &[u8]) -> Result<Vec<ReachEntry>> {
488 let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)
489 .map_err(|e| anyhow!("reach reader: {e}"))?;
490 let mut out = Vec::new();
491 for batch in reader {
492 let batch = batch.map_err(|e| anyhow!("reach batch read: {e}"))?;
493 let oids = batch
494 .column_by_name("commit_oid")
495 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
496 .ok_or_else(|| anyhow!("reach: no `commit_oid` column"))?;
497 let bms = batch
498 .column_by_name("bitmap")
499 .and_then(|c| c.as_any().downcast_ref::<BinaryArray>())
500 .ok_or_else(|| anyhow!("reach: no `bitmap` column"))?;
501 for i in 0..batch.num_rows() {
502 let bitmap = RoaringBitmap::deserialize_from(bms.value(i))
503 .map_err(|e| anyhow!("reach bitmap deserialize: {e}"))?;
504 out.push(ReachEntry { commit: oids.value(i).to_string(), bitmap });
505 }
506 }
507 Ok(out)
508}
509
510/// Read the reachability bitmaps out of a sealed archive. `Ok(None)` when absent.
511pub fn read_reach(archive: &Path) -> Result<Option<Vec<ReachEntry>>> {
512 match read_reserved_section_bytes(archive, GUNNAR_REACH_MODULE)? {
513 Some(b) => Ok(Some(decode_reach(&b)?)),
514 None => Ok(None),
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::graph::assign_generations;
522
523 fn hexid(c: char) -> String {
524 std::iter::repeat_n(c, 40).collect()
525 }
526
527 /// Build a tiny repo: two commits, the second adding a file.
528 /// Returns (commits, facts-owned-data).
529 #[allow(clippy::type_complexity)]
530 fn tiny_repo() -> (Vec<CommitNode>, HashMap<String, u32>, HashMap<String, Vec<u8>>) {
531 let blob1 = hexid('1');
532 let blob2 = hexid('2');
533 let tree1 = hexid('3');
534 let tree2 = hexid('4');
535 let c1 = hexid('5');
536 let c2 = hexid('6');
537
538 let mut trees: HashMap<String, Vec<u8>> = HashMap::new();
539 let mut t1 = Vec::new();
540 t1.extend_from_slice(b"100644 a\0");
541 t1.extend_from_slice(&hex::decode(&blob1).unwrap());
542 trees.insert(tree1.clone(), t1);
543
544 let mut t2 = Vec::new();
545 t2.extend_from_slice(b"100644 a\0");
546 t2.extend_from_slice(&hex::decode(&blob1).unwrap());
547 t2.extend_from_slice(b"100644 b\0");
548 t2.extend_from_slice(&hex::decode(&blob2).unwrap());
549 trees.insert(tree2.clone(), t2);
550
551 let ordinal: HashMap<String, u32> = [
552 (blob1, 0u32),
553 (blob2, 1),
554 (tree1, 2),
555 (tree2, 3),
556 (c1.clone(), 4),
557 (c2.clone(), 5),
558 ]
559 .into_iter()
560 .collect();
561
562 let commits = assign_generations(vec![
563 CommitNode {
564 oid: c1.clone(),
565 parents: vec![],
566 tree: Some(hexid('3')),
567 committer_time: Some(1),
568 generation: 0,
569 },
570 CommitNode {
571 oid: c2.clone(),
572 parents: vec![c1.clone()],
573 tree: Some(hexid('4')),
574 committer_time: Some(2),
575 generation: 0,
576 },
577 ]);
578 (commits, ordinal, trees)
579 }
580
581 #[test]
582 fn bitmap_contains_exactly_the_reachable_objects() {
583 let (commits, ordinal, trees) = tiny_repo();
584 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
585 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
586
587 let by_commit: HashMap<&str, &RoaringBitmap> =
588 entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
589
590 // c1 reaches: blob1(0), tree1(2), c1(4).
591 let b1: Vec<u32> = by_commit[hexid('5').as_str()].iter().collect();
592 assert_eq!(b1, vec![0, 2, 4], "c1 must not reach blob2/tree2/c2");
593
594 // c2 reaches everything.
595 let b2: Vec<u32> = by_commit[hexid('6').as_str()].iter().collect();
596 assert_eq!(b2, vec![0, 1, 2, 3, 4, 5]);
597 }
598
599 #[test]
600 fn want_minus_have_is_an_andnot() {
601 let (commits, ordinal, trees) = tiny_repo();
602 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
603 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
604 let by: HashMap<&str, &RoaringBitmap> =
605 entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
606
607 let want = by[hexid('6').as_str()].clone();
608 let have = by[hexid('5').as_str()].clone();
609 let delta: Vec<u32> = (want - have).iter().collect();
610 // Exactly the objects the second commit introduced: blob2(1), tree2(3), c2(5).
611 assert_eq!(delta, vec![1, 3, 5]);
612 }
613
614 #[test]
615 fn selection_takes_tips_first_and_respects_the_cap() {
616 let (commits, ordinal, trees) = tiny_repo();
617 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
618 let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 1 });
619 assert_eq!(entries.len(), 1);
620 assert_eq!(entries[0].commit, hexid('6'), "the tip, not the root");
621 }
622
623 #[test]
624 fn batch_roundtrips_through_arrow_ipc() {
625 let (commits, ordinal, trees) = tiny_repo();
626 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
627 let entries = build_reach(&commits, &facts, ReachPolicy::default());
628 let batch = build_reach_batch(&entries).unwrap();
629 let mut buf = Vec::new();
630 {
631 let mut w = znippy_common::arrow::ipc::writer::StreamWriter::try_new(
632 &mut buf,
633 &reach_schema(),
634 )
635 .unwrap();
636 w.write(&batch).unwrap();
637 w.finish().unwrap();
638 }
639 let back = decode_reach(&buf).unwrap();
640 assert_eq!(back, entries);
641 }
642
643 #[test]
644 fn a_corrupt_tree_cycle_terminates() {
645 // t -> t (self-referential): must not hang or blow the stack.
646 let t = hexid('a');
647 let mut payload = Vec::new();
648 payload.extend_from_slice(b"40000 self\0");
649 payload.extend_from_slice(&hex::decode(&t).unwrap());
650 let trees: HashMap<String, Vec<u8>> = [(t.clone(), payload)].into_iter().collect();
651 let ordinal: HashMap<String, u32> = [(t.clone(), 0u32), (hexid('b'), 1)].into_iter().collect();
652 let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
653 let commits = assign_generations(vec![CommitNode {
654 oid: hexid('b'),
655 parents: vec![],
656 tree: Some(t),
657 committer_time: None,
658 generation: 0,
659 }]);
660 let entries = build_reach(&commits, &facts, ReachPolicy::default());
661 assert_eq!(entries.len(), 1);
662 let bits: Vec<u32> = entries[0].bitmap.iter().collect();
663 assert_eq!(bits, vec![0, 1]);
664 }
665}