Skip to main content

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}