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::record_batch::RecordBatch;
34use znippy_common::read_reserved_section_bytes;
35
36use crate::graph::CommitNode;
37use crate::object::{GitObjectKind, tree_entries};
38
39/// Which commits get a bitmap.
40#[derive(Debug, Clone, Copy)]
41pub struct ReachPolicy {
42    /// Upper bound on how many bitmaps are sealed. Tips are taken first (a
43    /// commit with no child inside the archive — the branch heads a clone
44    /// negotiates against), then the remaining commits are sampled at an even
45    /// stride through generation order.
46    pub max_commits: usize,
47}
48
49impl Default for ReachPolicy {
50    fn default() -> Self {
51        // git's own bitmap selection is of this order: cover the tips, then
52        // sample the history so a `have` usually lands near a bitmapped commit.
53        Self { max_commits: 512 }
54    }
55}
56
57/// One sealed bitmap.
58#[derive(Debug, Clone, PartialEq)]
59pub struct ReachEntry {
60    pub commit: String,
61    pub bitmap: RoaringBitmap,
62}
63
64pub fn reach_schema() -> Arc<Schema> {
65    Arc::new(Schema::new(vec![
66        Field::new("commit_oid", DataType::Utf8, false),
67        Field::new("cardinality", DataType::UInt64, false),
68        Field::new("bitmap", DataType::Binary, false),
69    ]))
70}
71
72/// **Where a tree's bytes come from** — a lookup, not a table.
73///
74/// It was `&HashMap<String, Vec<u8>>`: every tree payload in the repository,
75/// resident, keyed by a hex `String`. MEASURED on oden 2026-08-30, the real
76/// `torvalds/linux` mirror through [`crate::git_ops::GitStore`]: **28.24 GB**
77/// of tree payloads — 3.24× the 8.13 GiB pack — held on disk in the eager
78/// exploded pool so that this map could be rebuilt into RAM on **every** fold.
79/// Peak RSS 50.9 GiB. The appliance has 16.
80///
81/// So the map became a lookup. The eager arm is still a `HashMap` and costs
82/// exactly what it did (`Cow::Borrowed`, no copy); the store's arm resolves the
83/// tree the way any other read does — from the exploded cache if it is there,
84/// by inflating its own delta chain out of the mmapped pack if it is not
85/// ([`crate::resolve::materialize_at`]) — and the walk then puts it in the
86/// cache for the next one.
87///
88/// `None` is *"not a tree this archive holds"* and is an ordinary answer, not
89/// a fault: a blob is a leaf, a gitlink is a commit in another repository, and
90/// a thin push's base may be outside the archive. Every caller below treats it
91/// as "stop here", which is what it always meant.
92pub trait TreeSource: Sync {
93    fn tree(&self, oid_hex: &str) -> Option<std::borrow::Cow<'_, [u8]>>;
94}
95
96/// The eager arm: a map that is already in memory. Unchanged in cost — the
97/// `Cow` borrows, so no payload is copied to satisfy the trait.
98impl TreeSource for HashMap<String, Vec<u8>> {
99    fn tree(&self, oid_hex: &str) -> Option<std::borrow::Cow<'_, [u8]>> {
100        self.get(oid_hex).map(|v| std::borrow::Cow::Borrowed(v.as_slice()))
101    }
102}
103
104/// **oid → ordinal, and ordinal → oid, over one flat buffer and nothing else.**
105///
106/// The ordinal space is `oids_raw` — every distinct oid in the archive,
107/// fixed-width and back to back, in **oid-lexicographic (= raw byte) order**,
108/// which is exactly the order the ordinal numbers it. Because that buffer is
109/// already sorted and already the inverse map (ordinal `o` is the slice at
110/// `o * oid_len`), both directions are answered *from it*:
111///
112/// - `ordinal_of_raw` / `ordinal_of_hex` — a **binary search** over the buffer,
113///   O(log n) with no probe table to keep in step with it;
114/// - `oid` — a slice.
115///
116/// This is what replaced [`Derived::oids`] (a hex `Vec<String>`) and
117/// [`Derived::ordinal`] (a `HashMap<String, u32>`) — two per-oid `String`
118/// allocations, built at every fold, that a comment then had to promise agreed
119/// with the flat buffer. One representation, no promise needed (LAW 5). It owns
120/// nothing: it is a `Copy` view (`&[u8]` + a length), so constructing it at open
121/// allocates zero bytes.
122///
123/// [`Derived::oids`]: crate::git_ops
124/// [`Derived::ordinal`]: crate::git_ops
125#[derive(Clone, Copy)]
126pub struct OrdinalSpace<'a> {
127    /// The distinct oids, fixed-width, sorted, back to back. `oids_raw`.
128    raw: &'a [u8],
129    /// Width of one oid: 20 (sha1) or 32 (sha256).
130    oid_len: usize,
131}
132
133impl<'a> OrdinalSpace<'a> {
134    /// Borrow a flat, **sorted**, fixed-width oid buffer as the ordinal space.
135    /// The sort is the caller's invariant (every builder produces it in
136    /// oid-lexicographic order); the binary search below rests on it.
137    pub fn new(raw: &'a [u8], oid_len: usize) -> Self {
138        debug_assert!(oid_len > 0 && raw.len() % oid_len == 0, "ragged ordinal space");
139        Self { raw, oid_len }
140    }
141
142    /// How many oids the space holds.
143    pub fn len(&self) -> usize {
144        if self.oid_len == 0 { 0 } else { self.raw.len() / self.oid_len }
145    }
146
147    pub fn is_empty(&self) -> bool {
148        self.raw.is_empty()
149    }
150
151    /// ordinal → raw oid, a slice into the flat buffer. `None` for an ordinal
152    /// outside the space.
153    pub fn oid(&self, ordinal: u32) -> Option<&'a [u8]> {
154        let at = (ordinal as usize).checked_mul(self.oid_len)?;
155        self.raw.get(at..at + self.oid_len)
156    }
157
158    /// raw oid → ordinal, by binary search over the sorted flat buffer.
159    ///
160    /// This is the exact inverse of [`Self::oid`]: the buffer is sorted, so the
161    /// index a search lands on IS the ordinal, and it equals what the old
162    /// `HashMap<String, u32>` returned for the same oid — that map keyed the
163    /// same sorted list by its own hex spelling.
164    pub fn ordinal_of_raw(&self, oid: &[u8]) -> Option<u32> {
165        if oid.len() != self.oid_len {
166            return None;
167        }
168        let (mut lo, mut hi) = (0usize, self.len());
169        while lo < hi {
170            let mid = lo + (hi - lo) / 2;
171            let at = mid * self.oid_len;
172            match self.raw[at..at + self.oid_len].cmp(oid) {
173                std::cmp::Ordering::Less => lo = mid + 1,
174                std::cmp::Ordering::Greater => hi = mid,
175                std::cmp::Ordering::Equal => return Some(mid as u32),
176            }
177        }
178        None
179    }
180
181    /// hex oid → ordinal. Decodes the hex into a **stack** buffer (≤ 32 bytes),
182    /// so a lookup allocates nothing, then binary-searches the raw form. A hex
183    /// string that is the wrong length or not hex is simply absent.
184    pub fn ordinal_of_hex(&self, hex: &str) -> Option<u32> {
185        let mut buf = [0u8; 32];
186        let dst = buf.get_mut(..self.oid_len)?;
187        hex::decode_to_slice(hex, dst).ok()?;
188        self.ordinal_of_raw(dst)
189    }
190}
191
192/// **Carry a reachability table across a fold** — the same bitmaps, renumbered
193/// into the new ordinal space, for the commits the new graph still holds.
194///
195/// A bitmap is a fact about an immutable commit: the set of objects reachable
196/// from it never changes while the commit exists. What a fold changes is the
197/// NUMBERING — the ordinal space is the sorted oid buffer, and every oid a push
198/// adds shifts the ordinals above it — so an entry survives a fold exactly by
199/// mapping each set bit through `old ordinal → new ordinal`. That map is
200/// monotone (both spaces are sorted, and the new one is a superset), so it is
201/// one merge pass over the two buffers and one in-order pass over each bitmap.
202///
203/// Returns `None` — *rebuild from scratch* — when the carry cannot be exact:
204/// an old oid that the new space lacks (an object was removed under the table,
205/// which a retain/gc does) or an entry whose commit `keep` no longer knows.
206/// An entry for a commit that is gone is dropped, not carried; a BIT that is
207/// gone refuses the whole carry, because an entry claiming an object the store
208/// no longer holds would be a silent over-claim on the next negotiation.
209///
210/// Commits added since the carried build have no entry; [`accumulate`] walks
211/// a bitmapless commit down to the nearest carried ancestor, so a request
212/// stays exact and pays only for the new commits' trees. The caller bounds how
213/// far that is allowed to grow (`git_ops::REACH_CARRY_MAX_FRACTION`).
214pub fn carry_reach(
215    old: &[ReachEntry],
216    old_space: &OrdinalSpace<'_>,
217    new_space: &OrdinalSpace<'_>,
218    keep: impl Fn(&str) -> bool,
219) -> Option<Vec<ReachEntry>> {
220    if old.is_empty() || old_space.is_empty() {
221        return None;
222    }
223    // old ordinal → new ordinal, by one merge over the two sorted buffers.
224    let mut remap: Vec<Option<u32>> = Vec::with_capacity(old_space.len());
225    let mut j = 0u32;
226    let new_len = new_space.len() as u32;
227    for o in 0..old_space.len() as u32 {
228        let oid = old_space.oid(o)?;
229        let mut hit = None;
230        while j < new_len {
231            let cand = new_space.oid(j)?;
232            match cand.cmp(oid) {
233                std::cmp::Ordering::Less => j += 1,
234                std::cmp::Ordering::Equal => {
235                    hit = Some(j);
236                    j += 1;
237                    break;
238                }
239                std::cmp::Ordering::Greater => break,
240            }
241        }
242        remap.push(hit);
243    }
244    let mut out = Vec::with_capacity(old.len());
245    for e in old {
246        if !keep(&e.commit) {
247            continue;
248        }
249        let mut mapped = Vec::with_capacity(e.bitmap.len() as usize);
250        for b in e.bitmap.iter() {
251            match remap.get(b as usize).copied().flatten() {
252                Some(n) => mapped.push(n),
253                None => return None,
254            }
255        }
256        // Monotone map ⇒ `mapped` is ascending; the sorted constructor is the
257        // cheap one and asserts exactly that.
258        let bitmap = RoaringBitmap::from_sorted_iter(mapped).ok()?;
259        out.push(ReachEntry { commit: e.commit.clone(), bitmap });
260    }
261    Some(out)
262}
263
264#[cfg(test)]
265mod carry_tests {
266    use super::*;
267
268    fn space(oids: &[u8]) -> Vec<u8> {
269        // 1-byte "oids" are enough for the arithmetic; the space is sorted.
270        let mut v = oids.to_vec();
271        v.sort_unstable();
272        v
273    }
274
275    /// The carry renumbers every bit through the merge, drops an entry whose
276    /// commit is gone, and refuses when a set bit's object is gone — red in
277    /// every arm before the function existed, since a fold used to drop the
278    /// table outright.
279    #[test]
280    fn a_carry_renumbers_bits_drops_dead_commits_and_refuses_a_missing_object() {
281        let old_raw = space(&[10, 20, 30, 40]);
282        let old = OrdinalSpace::new(&old_raw, 1);
283        // Two oids inserted below 30 and one above 40: ordinals shift by 2 and 2.
284        let new_raw = space(&[10, 15, 20, 25, 30, 40, 50]);
285        let new = OrdinalSpace::new(&new_raw, 1);
286        let entries = vec![
287            ReachEntry { commit: "keep".into(), bitmap: [0u32, 2, 3].into_iter().collect() },
288            ReachEntry { commit: "gone".into(), bitmap: [1u32].into_iter().collect() },
289        ];
290        let carried = carry_reach(&entries, &old, &new, |c| c == "keep").expect("exact carry");
291        assert_eq!(carried.len(), 1, "the dead commit's entry is dropped, not carried");
292        assert_eq!(carried[0].commit, "keep");
293        let bits: Vec<u32> = carried[0].bitmap.iter().collect();
294        assert_eq!(bits, vec![0, 4, 5], "10→0, 30→4, 40→5 in the new numbering");
295
296        // An object removed from the space under a kept entry: refuse the carry.
297        let shrunk_raw = space(&[10, 20, 40]);
298        let shrunk = OrdinalSpace::new(&shrunk_raw, 1);
299        assert!(carry_reach(&entries, &old, &shrunk, |_| true).is_none(), "30 is gone; a bitmap naming it must not survive");
300        // Nothing to carry is not a carry.
301        assert!(carry_reach(&[], &old, &new, |_| true).is_none());
302    }
303}
304
305/// What the builder needs to know about every object in the archive.
306pub struct ObjectFacts<'a> {
307    /// oid ⇄ ordinal — the bitmap space, over the flat `oids_raw` buffer.
308    pub ordinal: OrdinalSpace<'a>,
309    /// Tree payloads, by oid hex. Blobs are not needed (they are leaves) and
310    /// are deliberately not asked for.
311    pub trees: &'a dyn TreeSource,
312    /// Raw oid width, needed to walk tree records.
313    pub oid_len: usize,
314}
315
316/// Build the reachability bitmaps.
317///
318/// `commits` must already carry generation numbers (see
319/// [`crate::graph::assign_generations`]) and be ordered parents-before-children,
320/// which that function guarantees.
321pub fn build_reach(
322    commits: &[CommitNode],
323    facts: &ObjectFacts<'_>,
324    policy: ReachPolicy,
325) -> Vec<ReachEntry> {
326    let n = commits.len();
327    if n == 0 || policy.max_commits == 0 {
328        return Vec::new();
329    }
330    let pos: HashMap<&str, usize> = commits
331        .iter()
332        .enumerate()
333        .map(|(i, c)| (c.oid.as_str(), i))
334        .collect();
335
336    let selected = select_commits(commits, &pos, policy);
337    let keep: Vec<bool> = {
338        let mut k = vec![false; n];
339        for &i in &selected {
340            k[i] = true;
341        }
342        k
343    };
344
345    // How many children still need each commit's bitmap. Once it hits zero and
346    // the commit is not selected, the bitmap is dropped — that is what keeps
347    // peak memory to the DAG's frontier rather than the whole history.
348    let mut pending: Vec<usize> = vec![0; n];
349    let mut parent_idx: Vec<Vec<usize>> = Vec::with_capacity(n);
350    for c in commits {
351        let mut ps: Vec<usize> = c.parents.iter().filter_map(|p| pos.get(p.as_str()).copied()).collect();
352        ps.sort_unstable();
353        ps.dedup();
354        for &p in &ps {
355            pending[p] += 1;
356        }
357        parent_idx.push(ps);
358    }
359
360    let mut tree_memo: HashMap<String, RoaringBitmap> = HashMap::new();
361    let mut live: HashMap<usize, RoaringBitmap> = HashMap::new();
362
363    for i in 0..n {
364        let c = &commits[i];
365        let mut bm = RoaringBitmap::new();
366        if let Some(o) = facts.ordinal.ordinal_of_hex(c.oid.as_str()) {
367            bm.insert(o);
368        }
369        if let Some(t) = &c.tree {
370            // Borrowed out of the memo, not cloned out of it. See
371            // [`tree_closure`]'s own note: this is once per commit and the thing
372            // being copied is a whole tree closure, which on a repository-shaped
373            // history is most of the object graph.
374            if let Some(tc) = tree_closure(t, facts, &mut tree_memo) {
375                bm |= tc;
376            }
377        }
378        for &p in &parent_idx[i] {
379            if let Some(pb) = live.get(&p) {
380                bm |= pb;
381            }
382            pending[p] -= 1;
383            if pending[p] == 0 && !keep[p] {
384                live.remove(&p);
385            }
386        }
387        // **Nothing is cloned here.** A kept commit's bitmap is inserted into
388        // `live` like any other and harvested below; the `out.push(ReachEntry {
389        // bitmap: bm.clone() })` that used to stand here made a second, deep
390        // copy of every selected commit's roaring bitmap while `live` held the
391        // first — and on the live selection path `keep` is *every* commit, so
392        // that was one full duplicate of the entire index. MEASURED on oden
393        // 2026-08-14, a 1 581-commit / 12 455-object store: the whole build was
394        // 530 218 allocations.
395        //
396        // Safe by the retention rule directly above: an entry is dropped from
397        // `live` only when `pending[p] == 0 && !keep[p]`, so a kept one is never
398        // dropped and is still there when the harvest runs.
399        if pending[i] > 0 || keep[i] {
400            live.insert(i, bm);
401        }
402    }
403
404    // The harvest: **move** each selected commit's bitmap out of `live`.
405    //
406    // `selected` is sorted ascending (`select_commits` sorts and dedups), and
407    // the loop above pushed in `0..n` order filtered by `keep`, so this produces
408    // the identical order the clone-per-iteration version did. That is not a
409    // detail to leave implicit — `decode_reach`/`build_reach_batch` round-trip
410    // this vector positionally.
411    let mut out: Vec<ReachEntry> = Vec::with_capacity(selected.len());
412    for &i in &selected {
413        // `expect` rather than `unwrap_or_default`: a missing entry here means
414        // the retention rule above let a kept commit be dropped, and answering
415        // with an EMPTY bitmap would be a silent under-send — a clone served
416        // one object per branch, exiting zero. Panicking names the commit.
417        let bitmap = live.remove(&i).unwrap_or_else(|| {
418            panic!(
419                "commit {} was selected for a reachability bitmap and its bitmap is not live at \
420                 the end of the build; an empty one here would under-send a clone",
421                commits[i].oid
422            )
423        });
424        out.push(ReachEntry {
425            commit: commits[i].oid.clone(),
426            bitmap,
427        });
428    }
429    out
430}
431
432/// Tips first, then an even sample through the rest, capped at `max_commits`.
433fn select_commits(
434    commits: &[CommitNode],
435    pos: &HashMap<&str, usize>,
436    policy: ReachPolicy,
437) -> Vec<usize> {
438    let n = commits.len();
439    let mut has_child = vec![false; n];
440    for c in commits {
441        for p in &c.parents {
442            if let Some(&pi) = pos.get(p.as_str()) {
443                has_child[pi] = true;
444            }
445        }
446    }
447    let mut chosen: Vec<usize> = (0..n).filter(|&i| !has_child[i]).collect();
448    chosen.truncate(policy.max_commits);
449
450    if chosen.len() < policy.max_commits {
451        let room = policy.max_commits - chosen.len();
452        let rest: Vec<usize> = (0..n).filter(|&i| has_child[i]).collect();
453        if !rest.is_empty() {
454            let stride = rest.len().div_ceil(room).max(1);
455            for &i in rest.iter().step_by(stride).take(room) {
456                chosen.push(i);
457            }
458        }
459    }
460    chosen.sort_unstable();
461    chosen.dedup();
462    chosen
463}
464
465/// Every object reachable from a tree, memoized per tree oid.
466///
467/// Iterative (an explicit post-order stack), not recursive: a deep source tree
468/// would otherwise be a stack-depth bet.
469///
470/// # It **borrows** out of the memo rather than cloning out of it
471///
472/// It returned `RoaringBitmap` by value until 2026-08-14, which meant a deep
473/// copy of a whole tree closure on every call — including, and especially, the
474/// memo *hit* on the first line, which is the common case: consecutive commits
475/// in a history overwhelmingly share a root tree or find one already computed.
476/// The thing being copied is not small. On a repository-shaped history a root
477/// tree's closure is most of the object graph, so this was one full copy of it
478/// per commit, 1 581 times on the store this was measured against.
479///
480/// `None` means the memo has no entry for `root` even after the walk, which the
481/// walk only produces for an oid this archive does not hold. The caller ORs
482/// nothing in, which is what `unwrap_or_default()` did and is the same answer —
483/// it is `Option` rather than an empty bitmap so that "absent" and "reaches
484/// nothing" stay different words at the call site.
485pub(crate) fn tree_closure<'m>(
486    root: &str,
487    facts: &ObjectFacts<'_>,
488    memo: &'m mut HashMap<String, RoaringBitmap>,
489) -> Option<&'m RoaringBitmap> {
490    if memo.contains_key(root) {
491        // Deliberately re-looked-up at the bottom rather than returned from
492        // here: NLL cannot see that the early borrow ends on this path, and the
493        // alternative is a second `HashMap` probe on the hit path against a deep
494        // copy of the whole closure. The probe wins by orders of magnitude.
495        return memo.get(root);
496    }
497    // (oid, children_already_expanded)
498    let mut stack: Vec<(String, bool)> = vec![(root.to_string(), false)];
499    // Trees currently on the stack. A tree graph cannot contain a cycle, but a
500    // *corrupt* one can, and "cannot happen" is not a termination argument for
501    // bytes that arrived over the wire.
502    let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
503
504    while let Some((oid, expanded)) = stack.pop() {
505        if memo.contains_key(&oid) {
506            visiting.remove(&oid);
507            continue;
508        }
509        let Some(payload) = facts.trees.tree(&oid) else {
510            // Not a tree we hold (a blob, or an object outside the archive).
511            let mut bm = RoaringBitmap::new();
512            if let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) {
513                bm.insert(o);
514            }
515            memo.insert(oid, bm);
516            continue;
517        };
518        let children: Vec<String> = tree_entries(&payload, facts.oid_len)
519            .iter()
520            .map(|e| hex::encode(e.oid))
521            .collect();
522        if !expanded {
523            visiting.insert(oid.clone());
524            // A child that is already `visiting` is a back-edge: do not push it
525            // again, or the stack never drains.
526            let unresolved: Vec<String> = children
527                .iter()
528                .filter(|c| !memo.contains_key(*c) && !visiting.contains(*c))
529                .cloned()
530                .collect();
531            if !unresolved.is_empty() {
532                stack.push((oid, true));
533                for c in unresolved {
534                    stack.push((c, false));
535                }
536                continue;
537            }
538        }
539        let mut bm = RoaringBitmap::new();
540        if let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) {
541            bm.insert(o);
542        }
543        for c in &children {
544            if let Some(cb) = memo.get(c) {
545                bm |= cb;
546            } else if let Some(o) = facts.ordinal.ordinal_of_hex(c.as_str()) {
547                // Back-edge or an object outside the archive: take the object
548                // itself and stop rather than loop forever.
549                bm.insert(o);
550            }
551        }
552        memo.insert(oid.clone(), bm);
553        visiting.remove(&oid);
554    }
555    memo.get(root)
556}
557
558/// **The bounded walk: OR into `acc` everything reachable from `tip`, stopping
559/// at every commit that already has a bitmap.**
560///
561/// # Why this function is what makes a sampled bitmap table legal
562///
563/// Until 2026-08-14 this file had no walk at all, and the consequence ran all
564/// the way up the stack. [`crate::git_ops::GitStore::reachable_oids`] answered a
565/// `want` with no bitmap by contributing *the object itself and nothing else* —
566/// a silent under-send, a clone that exits zero having served one object per
567/// branch. `crate::serve`'s `select` therefore had to refuse any request whose
568/// tips were not all bitmapped, and the only way to make that refusal never fire
569/// was to bitmap **every** commit: `ReachPolicy { max_commits: usize::MAX }`, on
570/// the request thread, holding the derived write lock, after every push (because
571/// `refold` clears the table). MEASURED: 530 218 of a fetch's 575 349
572/// allocations were that build, for a request that sent four objects.
573///
574/// With a walk, a missing bitmap is a *bounded amount of work* instead of a
575/// wrong answer, and the table can be sampled like the sealed archive's already
576/// is.
577///
578/// # Why it is bounded, stated as the property it rests on
579///
580/// **A stored bitmap is closed under reachability.** So reaching one accounts
581/// for the entire history behind it, and the walk stops there rather than
582/// descending. What it covers is therefore only what has been pushed since the
583/// last time the table was built — not the repository — whatever the repository's
584/// size. This is git's `add_to_include_set` rule, arrived at independently here
585/// over a different object store: nothing of gitoxide's or of git's is copied,
586/// and this crate links neither (LAW 5 applies to code, and these are different
587/// codebases with different indexes).
588///
589/// # The two phases are an ordering, not a style
590///
591/// The commit walk runs to **completion** before a single tree is opened. Git
592/// applies its skip rule one level down as well — `should_include_obj`, do not
593/// descend into a tree whose bit is already set — and that rule is worth nothing
594/// unless every stored bitmap this request will ever OR in is already in `acc`
595/// when the tree walk starts. Get the order wrong and the first unbitmapped
596/// commit costs a walk of every tree and blob in the repository, and the bounded
597/// commit walk buys exactly nothing.
598/// **The two per-fold lookup tables the reach walk keys into, paired as one
599/// view.** `bitmap` is the stored closure of a commit (the stopping rule);
600/// `node` is a commit's graph row (its parents and tree). Both were rebuilt as
601/// `HashMap<&str, _>` on every negotiation — O(commits) per fetch, for tables
602/// that cannot change between folds — and are now folded once and borrowed
603/// through this view. Both are keyed by **hex** oid, the form the walk carries
604/// (a [`CommitNode`]'s parents and tree are hex), so no lookup here allocates.
605///
606/// The index maps are `oid → row`; the view resolves that to a borrow of the
607/// row so the caller never indexes a table by hand and never pairs an index
608/// with the wrong table — the pair is built and handed out together by
609/// [`crate::git_ops`]'s `reach_tables_with`.
610#[derive(Clone, Copy)]
611pub(crate) struct ReachView<'a> {
612    pub reach: &'a [ReachEntry],
613    pub reach_index: &'a HashMap<String, u32>,
614    pub graph: &'a [CommitNode],
615    pub graph_index: &'a HashMap<String, u32>,
616}
617
618impl<'a> ReachView<'a> {
619    /// The stored bitmap of a commit, or `None` if it has none.
620    #[inline]
621    pub(crate) fn bitmap(&self, oid: &str) -> Option<&'a RoaringBitmap> {
622        self.reach_index
623            .get(oid)
624            .map(|&i| &self.reach[i as usize].bitmap)
625    }
626    /// A commit's graph row, or `None` if this archive holds no such commit.
627    #[inline]
628    pub(crate) fn node(&self, oid: &str) -> Option<&'a CommitNode> {
629        self.graph_index.get(oid).map(|&i| &self.graph[i as usize])
630    }
631    /// Whether the graph holds `oid` as a commit — the `want`/`have` test the
632    /// negotiation makes before it decides to walk.
633    #[inline]
634    pub(crate) fn is_commit(&self, oid: &str) -> bool {
635        self.graph_index.contains_key(oid)
636    }
637}
638
639pub(crate) fn accumulate(
640    tip: &str,
641    view: &ReachView<'_>,
642    facts: &ObjectFacts<'_>,
643    acc: &mut RoaringBitmap,
644) {
645    // ── phase 1: the commit walk, to completion ────────────────────────────
646    //
647    // Collect the commits that have no bitmap, ORing in every stored bitmap the
648    // frontier touches. Nothing here opens a tree.
649    let mut unbitmapped: Vec<&str> = Vec::new();
650    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
651    let mut stack: Vec<&str> = vec![tip];
652    while let Some(oid) = stack.pop() {
653        if !seen.insert(oid) {
654            continue;
655        }
656        // The stopping rule. A stored bitmap is closed, so everything behind
657        // this commit is already accounted for and its parents are not walked.
658        if let Some(bm) = view.bitmap(oid) {
659            *acc |= bm;
660            continue;
661        }
662        let Some(node) = view.node(oid) else {
663            // Not a commit this archive holds as a graph row. It contributes
664            // itself if the index knows it, and nothing else — there is no
665            // history here to walk. A `want` that reaches this and is genuinely
666            // absent is refused by the caller, which is the one place that can
667            // tell "absent" from "not a commit".
668            if let Some(o) = facts.ordinal.ordinal_of_hex(oid) {
669                acc.insert(o);
670            }
671            continue;
672        };
673        unbitmapped.push(oid);
674        for p in &node.parents {
675            stack.push(p.as_str());
676        }
677    }
678
679    // ── phase 2: the trees, with the skip rule ─────────────────────────────
680    for oid in unbitmapped {
681        if let Some(o) = facts.ordinal.ordinal_of_hex(oid) {
682            acc.insert(o);
683        }
684        let Some(node) = view.node(oid) else { continue };
685        if let Some(tree) = &node.tree {
686            accumulate_tree(tree.as_str(), facts, acc);
687        }
688    }
689}
690
691/// [`accumulate`]'s second phase for one root tree: every object under it that
692/// is not already in `acc`.
693///
694/// The skip is git's `should_include_obj` and it is what keeps phase 2 bounded:
695/// a set bit means that object — and, for a tree, everything under it — is
696/// already accounted for, either by a stored bitmap ORed in during phase 1 or by
697/// an earlier commit in this same walk. Descending anyway would be correct and
698/// would also make the whole exercise pointless.
699///
700/// Iterative for the reason [`tree_closure`] is: a deep source tree is not a
701/// stack-depth bet. It needs no `visiting` set of its own — a corrupt cycle
702/// terminates here because a revisited oid's bit is already set.
703pub(crate) fn accumulate_tree(root: &str, facts: &ObjectFacts<'_>, acc: &mut RoaringBitmap) {
704    let mut stack: Vec<String> = vec![root.to_string()];
705    while let Some(oid) = stack.pop() {
706        let Some(o) = facts.ordinal.ordinal_of_hex(oid.as_str()) else {
707            // An object this archive does not hold. A thin push's base, or a
708            // submodule gitlink, which is a commit oid that lives in another
709            // repository entirely and must never be selected for emission.
710            continue;
711        };
712        if !acc.insert(o) {
713            // Already set: this object, and everything under it, is accounted
714            // for. `RoaringBitmap::insert` returns false when the bit was
715            // already present, so the test and the set are one operation.
716            continue;
717        }
718        // Only trees carry pointers. A blob is a leaf and `facts.trees`
719        // deliberately does not hold one.
720        if let Some(payload) = facts.trees.tree(oid.as_str()) {
721            for e in tree_entries(&payload, facts.oid_len) {
722                stack.push(hex::encode(e.oid));
723            }
724        }
725    }
726}
727
728/// Which objects a repack must hold to make the reachability pass exact.
729///
730/// Only commits and trees carry pointers; blobs are leaves. The builder
731/// therefore needs tree payloads and nothing else, which is why
732/// [`ObjectFacts::trees`] exists and there is no `blobs` field.
733pub fn needs_payload(kind: GitObjectKind) -> bool {
734    matches!(kind, GitObjectKind::Commit | GitObjectKind::Tree)
735}
736
737pub fn build_reach_batch(entries: &[ReachEntry]) -> Result<RecordBatch> {
738    use znippy_common::arrow::array::UInt64Builder;
739    let n = entries.len();
740    let mut oid_b = StringBuilder::with_capacity(n, n * 64);
741    let mut card_b = UInt64Builder::with_capacity(n);
742    let mut bm_b = BinaryBuilder::with_capacity(n, n * 128);
743    for e in entries {
744        oid_b.append_value(&e.commit);
745        card_b.append_value(e.bitmap.len());
746        let mut buf = Vec::new();
747        e.bitmap
748            .serialize_into(&mut buf)
749            .map_err(|err| anyhow!("reach bitmap serialize: {err}"))?;
750        bm_b.append_value(&buf);
751    }
752    RecordBatch::try_new(
753        reach_schema(),
754        vec![Arc::new(oid_b.finish()), Arc::new(card_b.finish()), Arc::new(bm_b.finish())],
755    )
756    .map_err(|e| anyhow!("reach batch: {e}"))
757}
758
759pub fn decode_reach(bytes: &[u8]) -> Result<Vec<ReachEntry>> {
760    // Through the IPC guard: the section comes out of an archive.
761    let reader = znippy_common::ipc_guard::stream_reader(bytes)
762        .map_err(|e| anyhow!("reach reader: {e}"))?;
763    let mut out = Vec::new();
764    for batch in reader {
765        let batch = batch.map_err(|e| anyhow!("reach batch read: {e}"))?;
766        let oids = batch
767            .column_by_name("commit_oid")
768            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
769            .ok_or_else(|| anyhow!("reach: no `commit_oid` column"))?;
770        let bms = batch
771            .column_by_name("bitmap")
772            .and_then(|c| c.as_any().downcast_ref::<BinaryArray>())
773            .ok_or_else(|| anyhow!("reach: no `bitmap` column"))?;
774        for i in 0..batch.num_rows() {
775            let bitmap = RoaringBitmap::deserialize_from(bms.value(i))
776                .map_err(|e| anyhow!("reach bitmap deserialize: {e}"))?;
777            out.push(ReachEntry { commit: oids.value(i).to_string(), bitmap });
778        }
779    }
780    Ok(out)
781}
782
783/// Read the reachability bitmaps out of a sealed archive. `Ok(None)` when absent.
784pub fn read_reach(archive: &Path) -> Result<Option<Vec<ReachEntry>>> {
785    match read_reserved_section_bytes(archive, GUNNAR_REACH_MODULE)? {
786        Some(b) => Ok(Some(decode_reach(&b)?)),
787        None => Ok(None),
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use crate::graph::assign_generations;
795
796    fn hexid(c: char) -> String {
797        std::iter::repeat_n(c, 40).collect()
798    }
799
800    /// Build a tiny repo: two commits, the second adding a file.
801    /// A flat, sorted, fixed-width ordinal buffer from oids **already in
802    /// ordinal order** — the shape [`OrdinalSpace`] borrows. The old tests
803    /// hand-built a `HashMap<String, u32>` with the same ordinals; because the
804    /// ordinal IS the oid-lexicographic rank, that map and this buffer are two
805    /// spellings of one list.
806    fn ordinal_raw(oids_in_order: &[&str]) -> Vec<u8> {
807        let mut v = Vec::new();
808        for o in oids_in_order {
809            v.extend_from_slice(&hex::decode(o).unwrap());
810        }
811        v
812    }
813
814    /// Returns (commits, facts-owned-data).
815    #[allow(clippy::type_complexity)]
816    fn tiny_repo() -> (Vec<CommitNode>, Vec<u8>, HashMap<String, Vec<u8>>) {
817        let blob1 = hexid('1');
818        let blob2 = hexid('2');
819        let tree1 = hexid('3');
820        let tree2 = hexid('4');
821        let c1 = hexid('5');
822        let c2 = hexid('6');
823
824        let mut trees: HashMap<String, Vec<u8>> = HashMap::new();
825        let mut t1 = Vec::new();
826        t1.extend_from_slice(b"100644 a\0");
827        t1.extend_from_slice(&hex::decode(&blob1).unwrap());
828        trees.insert(tree1.clone(), t1);
829
830        let mut t2 = Vec::new();
831        t2.extend_from_slice(b"100644 a\0");
832        t2.extend_from_slice(&hex::decode(&blob1).unwrap());
833        t2.extend_from_slice(b"100644 b\0");
834        t2.extend_from_slice(&hex::decode(&blob2).unwrap());
835        trees.insert(tree2.clone(), t2);
836
837        // Ordinal = oid-lexicographic rank: hexid('1') < … < hexid('6'), so the
838        // sorted raw buffer is simply these six in this order.
839        let ordinal = ordinal_raw(&[&blob1, &blob2, &tree1, &tree2, &c1, &c2]);
840
841        let commits = assign_generations(vec![
842            CommitNode {
843                oid: c1.clone(),
844                parents: vec![],
845                tree: Some(hexid('3')),
846                committer_time: Some(1),
847                generation: 0,
848            },
849            CommitNode {
850                oid: c2.clone(),
851                parents: vec![c1.clone()],
852                tree: Some(hexid('4')),
853                committer_time: Some(2),
854                generation: 0,
855            },
856        ]);
857        (commits, ordinal, trees)
858    }
859
860    #[test]
861    fn bitmap_contains_exactly_the_reachable_objects() {
862        let (commits, ordinal, trees) = tiny_repo();
863        let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
864        let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
865
866        let by_commit: HashMap<&str, &RoaringBitmap> =
867            entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
868
869        // c1 reaches: blob1(0), tree1(2), c1(4).
870        let b1: Vec<u32> = by_commit[hexid('5').as_str()].iter().collect();
871        assert_eq!(b1, vec![0, 2, 4], "c1 must not reach blob2/tree2/c2");
872
873        // c2 reaches everything.
874        let b2: Vec<u32> = by_commit[hexid('6').as_str()].iter().collect();
875        assert_eq!(b2, vec![0, 1, 2, 3, 4, 5]);
876    }
877
878    #[test]
879    fn want_minus_have_is_an_andnot() {
880        let (commits, ordinal, trees) = tiny_repo();
881        let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
882        let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
883        let by: HashMap<&str, &RoaringBitmap> =
884            entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
885
886        let want = by[hexid('6').as_str()].clone();
887        let have = by[hexid('5').as_str()].clone();
888        let delta: Vec<u32> = (want - have).iter().collect();
889        // Exactly the objects the second commit introduced: blob2(1), tree2(3), c2(5).
890        assert_eq!(delta, vec![1, 3, 5]);
891    }
892
893    #[test]
894    fn selection_takes_tips_first_and_respects_the_cap() {
895        let (commits, ordinal, trees) = tiny_repo();
896        let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
897        let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 1 });
898        assert_eq!(entries.len(), 1);
899        assert_eq!(entries[0].commit, hexid('6'), "the tip, not the root");
900    }
901
902    #[test]
903    fn batch_roundtrips_through_arrow_ipc() {
904        let (commits, ordinal, trees) = tiny_repo();
905        let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
906        let entries = build_reach(&commits, &facts, ReachPolicy::default());
907        let batch = build_reach_batch(&entries).unwrap();
908        let mut buf = Vec::new();
909        {
910            let mut w = znippy_common::arrow::ipc::writer::StreamWriter::try_new(
911                &mut buf,
912                &reach_schema(),
913            )
914            .unwrap();
915            w.write(&batch).unwrap();
916            w.finish().unwrap();
917        }
918        let back = decode_reach(&buf).unwrap();
919        assert_eq!(back, entries);
920    }
921
922    #[test]
923    fn a_corrupt_tree_cycle_terminates() {
924        // t -> t (self-referential): must not hang or blow the stack.
925        let t = hexid('a');
926        let mut payload = Vec::new();
927        payload.extend_from_slice(b"40000 self\0");
928        payload.extend_from_slice(&hex::decode(&t).unwrap());
929        let trees: HashMap<String, Vec<u8>> = [(t.clone(), payload)].into_iter().collect();
930        let ordinal = ordinal_raw(&[&t, &hexid('b')]);
931        let facts = ObjectFacts { ordinal: OrdinalSpace::new(&ordinal, 20), trees: &trees, oid_len: 20 };
932        let commits = assign_generations(vec![CommitNode {
933            oid: hexid('b'),
934            parents: vec![],
935            tree: Some(t),
936            committer_time: None,
937            generation: 0,
938        }]);
939        let entries = build_reach(&commits, &facts, ReachPolicy::default());
940        assert_eq!(entries.len(), 1);
941        let bits: Vec<u32> = entries[0].bitmap.iter().collect();
942        assert_eq!(bits, vec![0, 1]);
943    }
944
945    /// **The binary search is the exact inverse of the slice, and it is the same
946    /// answer a `HashMap<String, u32>` over the sorted list would give.**
947    ///
948    /// Over a sorted raw space this asserts, at every ordinal: `oid(o)` is the
949    /// slice, `ordinal_of_raw`/`ordinal_of_hex` round-trip back to `o`, and an
950    /// oid the space does not hold is `None` both ways (the search must not land
951    /// on a neighbour). Seen RED by `Equal => Some(mid as u32 + 1)` in
952    /// `ordinal_of_raw`: every round trip below then names the next ordinal.
953    #[test]
954    fn ordinal_space_is_the_inverse_of_the_slice() {
955        // 32 distinct sha1-width oids, deliberately built OUT of order so the
956        // sort is doing real work, then sorted into the flat space.
957        let mut oids: Vec<[u8; 20]> = (0..32u8)
958            .map(|i| {
959                let mut o = [0u8; 20];
960                o[0] = i.wrapping_mul(37);
961                o[19] = i;
962                o
963            })
964            .collect();
965        oids.sort_unstable();
966        oids.dedup();
967        let mut raw = Vec::new();
968        for o in &oids {
969            raw.extend_from_slice(o);
970        }
971        let space = OrdinalSpace::new(&raw, 20);
972        assert_eq!(space.len(), oids.len());
973        for (o, oid) in oids.iter().enumerate() {
974            assert_eq!(space.oid(o as u32), Some(oid.as_slice()));
975            assert_eq!(space.ordinal_of_raw(oid), Some(o as u32));
976            assert_eq!(space.ordinal_of_hex(&hex::encode(oid)), Some(o as u32));
977        }
978        // Absent, wrong width, and out of range are all None — not a neighbour.
979        assert_eq!(space.ordinal_of_raw(&[0xffu8; 20]), None);
980        assert_eq!(space.ordinal_of_raw(&[0u8; 19]), None);
981        assert_eq!(space.oid(oids.len() as u32), None);
982        assert_eq!(space.ordinal_of_hex("not-hex"), None);
983    }
984
985    /// **The single representation owns nothing.** `OrdinalSpace` is a `Copy`
986    /// view of `&[u8] + usize` — a slice reference and a length — so building one
987    /// at open allocates zero bytes, which is the whole point of removing the hex
988    /// `Vec<String>` and the `HashMap<String, u32>`. Red if either an owning
989    /// field creeps back in (the size grows) or `Copy` is lost.
990    #[test]
991    fn ordinal_space_is_a_borrowed_view() {
992        assert_eq!(
993            std::mem::size_of::<OrdinalSpace<'_>>(),
994            std::mem::size_of::<&[u8]>() + std::mem::size_of::<usize>(),
995            "OrdinalSpace grew an owned field — it must stay a borrow of oids_raw"
996        );
997        fn assert_copy<T: Copy>() {}
998        assert_copy::<OrdinalSpace<'_>>();
999    }
1000}