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    let mut out: Vec<ReachEntry> = Vec::with_capacity(selected.len());
131
132    for i in 0..n {
133        let c = &commits[i];
134        let mut bm = RoaringBitmap::new();
135        if let Some(&o) = facts.ordinal.get(c.oid.as_str()) {
136            bm.insert(o);
137        }
138        if let Some(t) = &c.tree {
139            bm |= tree_closure(t, facts, &mut tree_memo);
140        }
141        for &p in &parent_idx[i] {
142            if let Some(pb) = live.get(&p) {
143                bm |= pb;
144            }
145            pending[p] -= 1;
146            if pending[p] == 0 && !keep[p] {
147                live.remove(&p);
148            }
149        }
150        if keep[i] {
151            out.push(ReachEntry { commit: c.oid.clone(), bitmap: bm.clone() });
152        }
153        if pending[i] > 0 || keep[i] {
154            live.insert(i, bm);
155        }
156    }
157    out
158}
159
160/// Tips first, then an even sample through the rest, capped at `max_commits`.
161fn select_commits(
162    commits: &[CommitNode],
163    pos: &HashMap<&str, usize>,
164    policy: ReachPolicy,
165) -> Vec<usize> {
166    let n = commits.len();
167    let mut has_child = vec![false; n];
168    for c in commits {
169        for p in &c.parents {
170            if let Some(&pi) = pos.get(p.as_str()) {
171                has_child[pi] = true;
172            }
173        }
174    }
175    let mut chosen: Vec<usize> = (0..n).filter(|&i| !has_child[i]).collect();
176    chosen.truncate(policy.max_commits);
177
178    if chosen.len() < policy.max_commits {
179        let room = policy.max_commits - chosen.len();
180        let rest: Vec<usize> = (0..n).filter(|&i| has_child[i]).collect();
181        if !rest.is_empty() {
182            let stride = rest.len().div_ceil(room).max(1);
183            for &i in rest.iter().step_by(stride).take(room) {
184                chosen.push(i);
185            }
186        }
187    }
188    chosen.sort_unstable();
189    chosen.dedup();
190    chosen
191}
192
193/// Every object reachable from a tree, memoized per tree oid.
194///
195/// Iterative (an explicit post-order stack), not recursive: a deep source tree
196/// would otherwise be a stack-depth bet.
197pub(crate) fn tree_closure(
198    root: &str,
199    facts: &ObjectFacts<'_>,
200    memo: &mut HashMap<String, RoaringBitmap>,
201) -> RoaringBitmap {
202    if let Some(b) = memo.get(root) {
203        return b.clone();
204    }
205    // (oid, children_already_expanded)
206    let mut stack: Vec<(String, bool)> = vec![(root.to_string(), false)];
207    // Trees currently on the stack. A tree graph cannot contain a cycle, but a
208    // *corrupt* one can, and "cannot happen" is not a termination argument for
209    // bytes that arrived over the wire.
210    let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
211
212    while let Some((oid, expanded)) = stack.pop() {
213        if memo.contains_key(&oid) {
214            visiting.remove(&oid);
215            continue;
216        }
217        let Some(payload) = facts.trees.get(&oid) else {
218            // Not a tree we hold (a blob, or an object outside the archive).
219            let mut bm = RoaringBitmap::new();
220            if let Some(&o) = facts.ordinal.get(oid.as_str()) {
221                bm.insert(o);
222            }
223            memo.insert(oid, bm);
224            continue;
225        };
226        let children: Vec<String> = tree_entries(payload, facts.oid_len)
227            .iter()
228            .map(|e| hex::encode(e.oid))
229            .collect();
230        if !expanded {
231            visiting.insert(oid.clone());
232            // A child that is already `visiting` is a back-edge: do not push it
233            // again, or the stack never drains.
234            let unresolved: Vec<String> = children
235                .iter()
236                .filter(|c| !memo.contains_key(*c) && !visiting.contains(*c))
237                .cloned()
238                .collect();
239            if !unresolved.is_empty() {
240                stack.push((oid, true));
241                for c in unresolved {
242                    stack.push((c, false));
243                }
244                continue;
245            }
246        }
247        let mut bm = RoaringBitmap::new();
248        if let Some(&o) = facts.ordinal.get(oid.as_str()) {
249            bm.insert(o);
250        }
251        for c in &children {
252            if let Some(cb) = memo.get(c) {
253                bm |= cb;
254            } else if let Some(&o) = facts.ordinal.get(c.as_str()) {
255                // Back-edge or an object outside the archive: take the object
256                // itself and stop rather than loop forever.
257                bm.insert(o);
258            }
259        }
260        memo.insert(oid.clone(), bm);
261        visiting.remove(&oid);
262    }
263    memo.get(root).cloned().unwrap_or_default()
264}
265
266/// Which objects a repack must hold to make the reachability pass exact.
267///
268/// Only commits and trees carry pointers; blobs are leaves. The builder
269/// therefore needs tree payloads and nothing else, which is why
270/// [`ObjectFacts::trees`] exists and there is no `blobs` field.
271pub fn needs_payload(kind: GitObjectKind) -> bool {
272    matches!(kind, GitObjectKind::Commit | GitObjectKind::Tree)
273}
274
275pub fn build_reach_batch(entries: &[ReachEntry]) -> Result<RecordBatch> {
276    use znippy_common::arrow::array::UInt64Builder;
277    let n = entries.len();
278    let mut oid_b = StringBuilder::with_capacity(n, n * 64);
279    let mut card_b = UInt64Builder::with_capacity(n);
280    let mut bm_b = BinaryBuilder::with_capacity(n, n * 128);
281    for e in entries {
282        oid_b.append_value(&e.commit);
283        card_b.append_value(e.bitmap.len());
284        let mut buf = Vec::new();
285        e.bitmap
286            .serialize_into(&mut buf)
287            .map_err(|err| anyhow!("reach bitmap serialize: {err}"))?;
288        bm_b.append_value(&buf);
289    }
290    RecordBatch::try_new(
291        reach_schema(),
292        vec![Arc::new(oid_b.finish()), Arc::new(card_b.finish()), Arc::new(bm_b.finish())],
293    )
294    .map_err(|e| anyhow!("reach batch: {e}"))
295}
296
297pub fn decode_reach(bytes: &[u8]) -> Result<Vec<ReachEntry>> {
298    let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)
299        .map_err(|e| anyhow!("reach reader: {e}"))?;
300    let mut out = Vec::new();
301    for batch in reader {
302        let batch = batch.map_err(|e| anyhow!("reach batch read: {e}"))?;
303        let oids = batch
304            .column_by_name("commit_oid")
305            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
306            .ok_or_else(|| anyhow!("reach: no `commit_oid` column"))?;
307        let bms = batch
308            .column_by_name("bitmap")
309            .and_then(|c| c.as_any().downcast_ref::<BinaryArray>())
310            .ok_or_else(|| anyhow!("reach: no `bitmap` column"))?;
311        for i in 0..batch.num_rows() {
312            let bitmap = RoaringBitmap::deserialize_from(bms.value(i))
313                .map_err(|e| anyhow!("reach bitmap deserialize: {e}"))?;
314            out.push(ReachEntry { commit: oids.value(i).to_string(), bitmap });
315        }
316    }
317    Ok(out)
318}
319
320/// Read the reachability bitmaps out of a sealed archive. `Ok(None)` when absent.
321pub fn read_reach(archive: &Path) -> Result<Option<Vec<ReachEntry>>> {
322    match read_reserved_section_bytes(archive, GUNNAR_REACH_MODULE)? {
323        Some(b) => Ok(Some(decode_reach(&b)?)),
324        None => Ok(None),
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::graph::assign_generations;
332
333    fn hexid(c: char) -> String {
334        std::iter::repeat_n(c, 40).collect()
335    }
336
337    /// Build a tiny repo: two commits, the second adding a file.
338    /// Returns (commits, facts-owned-data).
339    #[allow(clippy::type_complexity)]
340    fn tiny_repo() -> (Vec<CommitNode>, HashMap<String, u32>, HashMap<String, Vec<u8>>) {
341        let blob1 = hexid('1');
342        let blob2 = hexid('2');
343        let tree1 = hexid('3');
344        let tree2 = hexid('4');
345        let c1 = hexid('5');
346        let c2 = hexid('6');
347
348        let mut trees: HashMap<String, Vec<u8>> = HashMap::new();
349        let mut t1 = Vec::new();
350        t1.extend_from_slice(b"100644 a\0");
351        t1.extend_from_slice(&hex::decode(&blob1).unwrap());
352        trees.insert(tree1.clone(), t1);
353
354        let mut t2 = Vec::new();
355        t2.extend_from_slice(b"100644 a\0");
356        t2.extend_from_slice(&hex::decode(&blob1).unwrap());
357        t2.extend_from_slice(b"100644 b\0");
358        t2.extend_from_slice(&hex::decode(&blob2).unwrap());
359        trees.insert(tree2.clone(), t2);
360
361        let ordinal: HashMap<String, u32> = [
362            (blob1, 0u32),
363            (blob2, 1),
364            (tree1, 2),
365            (tree2, 3),
366            (c1.clone(), 4),
367            (c2.clone(), 5),
368        ]
369        .into_iter()
370        .collect();
371
372        let commits = assign_generations(vec![
373            CommitNode {
374                oid: c1.clone(),
375                parents: vec![],
376                tree: Some(hexid('3')),
377                committer_time: Some(1),
378                generation: 0,
379            },
380            CommitNode {
381                oid: c2.clone(),
382                parents: vec![c1.clone()],
383                tree: Some(hexid('4')),
384                committer_time: Some(2),
385                generation: 0,
386            },
387        ]);
388        (commits, ordinal, trees)
389    }
390
391    #[test]
392    fn bitmap_contains_exactly_the_reachable_objects() {
393        let (commits, ordinal, trees) = tiny_repo();
394        let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
395        let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
396
397        let by_commit: HashMap<&str, &RoaringBitmap> =
398            entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
399
400        // c1 reaches: blob1(0), tree1(2), c1(4).
401        let b1: Vec<u32> = by_commit[hexid('5').as_str()].iter().collect();
402        assert_eq!(b1, vec![0, 2, 4], "c1 must not reach blob2/tree2/c2");
403
404        // c2 reaches everything.
405        let b2: Vec<u32> = by_commit[hexid('6').as_str()].iter().collect();
406        assert_eq!(b2, vec![0, 1, 2, 3, 4, 5]);
407    }
408
409    #[test]
410    fn want_minus_have_is_an_andnot() {
411        let (commits, ordinal, trees) = tiny_repo();
412        let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
413        let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 16 });
414        let by: HashMap<&str, &RoaringBitmap> =
415            entries.iter().map(|e| (e.commit.as_str(), &e.bitmap)).collect();
416
417        let want = by[hexid('6').as_str()].clone();
418        let have = by[hexid('5').as_str()].clone();
419        let delta: Vec<u32> = (want - have).iter().collect();
420        // Exactly the objects the second commit introduced: blob2(1), tree2(3), c2(5).
421        assert_eq!(delta, vec![1, 3, 5]);
422    }
423
424    #[test]
425    fn selection_takes_tips_first_and_respects_the_cap() {
426        let (commits, ordinal, trees) = tiny_repo();
427        let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
428        let entries = build_reach(&commits, &facts, ReachPolicy { max_commits: 1 });
429        assert_eq!(entries.len(), 1);
430        assert_eq!(entries[0].commit, hexid('6'), "the tip, not the root");
431    }
432
433    #[test]
434    fn batch_roundtrips_through_arrow_ipc() {
435        let (commits, ordinal, trees) = tiny_repo();
436        let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
437        let entries = build_reach(&commits, &facts, ReachPolicy::default());
438        let batch = build_reach_batch(&entries).unwrap();
439        let mut buf = Vec::new();
440        {
441            let mut w = znippy_common::arrow::ipc::writer::StreamWriter::try_new(
442                &mut buf,
443                &reach_schema(),
444            )
445            .unwrap();
446            w.write(&batch).unwrap();
447            w.finish().unwrap();
448        }
449        let back = decode_reach(&buf).unwrap();
450        assert_eq!(back, entries);
451    }
452
453    #[test]
454    fn a_corrupt_tree_cycle_terminates() {
455        // t -> t (self-referential): must not hang or blow the stack.
456        let t = hexid('a');
457        let mut payload = Vec::new();
458        payload.extend_from_slice(b"40000 self\0");
459        payload.extend_from_slice(&hex::decode(&t).unwrap());
460        let trees: HashMap<String, Vec<u8>> = [(t.clone(), payload)].into_iter().collect();
461        let ordinal: HashMap<String, u32> = [(t.clone(), 0u32), (hexid('b'), 1)].into_iter().collect();
462        let facts = ObjectFacts { ordinal: &ordinal, trees: &trees, oid_len: 20 };
463        let commits = assign_generations(vec![CommitNode {
464            oid: hexid('b'),
465            parents: vec![],
466            tree: Some(t),
467            committer_time: None,
468            generation: 0,
469        }]);
470        let entries = build_reach(&commits, &facts, ReachPolicy::default());
471        assert_eq!(entries.len(), 1);
472        let bits: Vec<u32> = entries[0].bitmap.iter().collect();
473        assert_eq!(bits, vec![0, 1]);
474    }
475}