Skip to main content

grit_lib/
pack_geometry.rs

1//! Pack geometry for `git repack --geometric` (factor-based progression).
2//!
3//! Mirrors the split logic in Git's `repack-geometry.c`: packs are weighted by
4//! object count from their index, sorted ascending, then a split index separates
5//! packs that will be rolled into a new pack from those retained.
6
7use std::path::Path;
8
9use crate::error::{Error, Result};
10use crate::pack::read_local_pack_indexes;
11
12/// One local pack considered for geometric repacking.
13#[derive(Debug, Clone)]
14pub struct GeometricPack {
15    /// `pack-<hex>` stem (no `.pack` / `.idx` suffix), matching `pack-objects` stdin lines.
16    pub stem: String,
17    /// Number of objects in the pack index.
18    pub object_count: usize,
19    /// Modification time of the `.pack` file (seconds), used for include-pack ordering.
20    pub mtime_secs: u64,
21    /// Whether this pack lives in the repository's primary object directory rather than an alternate.
22    pub is_local: bool,
23}
24
25/// Split packs into "roll up" vs "keep" using Git's geometric progression rules.
26#[must_use]
27pub fn compute_geometry_split(weights: &[usize], split_factor: i32) -> usize {
28    let sf = split_factor.max(1) as u64;
29    let pack_nr = weights.len();
30    if pack_nr == 0 {
31        return 0;
32    }
33
34    // Packs are sorted ascending by weight (caller's responsibility).
35    // Match `repack-geometry.c`: compare `pack[i]` vs `pack[i-1]` for i = pack_nr-1 .. 1.
36    let mut split = 0usize;
37    let mut found = false;
38    for idx in (1..pack_nr).rev() {
39        let ours = weights[idx] as u64;
40        let prev = weights[idx - 1] as u64;
41        if ours < sf.saturating_mul(prev) {
42            split = idx;
43            found = true;
44            break;
45        }
46    }
47    if found {
48        split += 1;
49    }
50
51    let mut total_size: u64 = 0;
52    for j in 0..split {
53        total_size = total_size.saturating_add(weights[j] as u64);
54    }
55
56    let mut j = split;
57    while j < pack_nr {
58        let ours = weights[j] as u64;
59        if ours < sf.saturating_mul(total_size) {
60            split += 1;
61            total_size = total_size.saturating_add(ours);
62            j += 1;
63        } else {
64            break;
65        }
66    }
67
68    split
69}
70
71/// Load eligible non-promisor packs from `objects_dir/pack` for geometry.
72///
73/// Skips packs with a `.keep` file unless `pack_kept_objects` is set, and skips
74/// basenames listed in `keep_pack_names` (full `pack-*.pack` filename or basename).
75pub fn collect_geometry_packs(
76    objects_dir: &Path,
77    pack_kept_objects: bool,
78    keep_pack_names: &[String],
79) -> Result<Vec<GeometricPack>> {
80    let pack_dir = objects_dir.join("pack");
81    let indexes = read_local_pack_indexes(objects_dir)?;
82    let mut out = Vec::new();
83
84    for idx in indexes {
85        let pack_name = idx
86            .pack_path
87            .file_name()
88            .and_then(|s| s.to_str())
89            .map(str::to_owned)
90            .ok_or_else(|| Error::CorruptObject("invalid pack path".to_owned()))?;
91
92        if !pack_name.starts_with("pack-") || !pack_name.ends_with(".pack") {
93            continue;
94        }
95
96        if keep_pack_names.iter().any(|k| {
97            k == &pack_name
98                || k.strip_prefix("pack/").unwrap_or(k.as_str()) == pack_name
99                || Path::new(k).file_name().and_then(|s| s.to_str()) == Some(pack_name.as_str())
100        }) {
101            continue;
102        }
103
104        let stem = pack_name
105            .strip_suffix(".pack")
106            .unwrap_or(pack_name.as_str())
107            .to_string();
108
109        let keep_path = pack_dir.join(format!("{stem}.keep"));
110        if keep_path.is_file() && !pack_kept_objects {
111            continue;
112        }
113
114        if pack_dir.join(format!("{stem}.promisor")).is_file() {
115            continue;
116        }
117
118        let mtime_secs = std::fs::metadata(&idx.pack_path)
119            .map(|m| {
120                m.modified()
121                    .ok()
122                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
123                    .map(|d| d.as_secs())
124                    .unwrap_or(0)
125            })
126            .unwrap_or(0);
127
128        out.push(GeometricPack {
129            stem,
130            object_count: idx.entries.len(),
131            mtime_secs,
132            is_local: true,
133        });
134    }
135
136    out.sort_by_key(|a| a.object_count);
137    Ok(out)
138}
139
140/// Promisor packs only (sibling `.promisor` marker), for a second geometry pass.
141pub fn collect_promisor_geometry_packs(
142    objects_dir: &Path,
143    pack_kept_objects: bool,
144    keep_pack_names: &[String],
145) -> Result<Vec<GeometricPack>> {
146    let pack_dir = objects_dir.join("pack");
147    let indexes = read_local_pack_indexes(objects_dir)?;
148    let mut out = Vec::new();
149
150    for idx in indexes {
151        let pack_name = idx
152            .pack_path
153            .file_name()
154            .and_then(|s| s.to_str())
155            .map(str::to_owned)
156            .ok_or_else(|| Error::CorruptObject("invalid pack path".to_owned()))?;
157
158        if !pack_name.starts_with("pack-") || !pack_name.ends_with(".pack") {
159            continue;
160        }
161
162        if keep_pack_names.iter().any(|k| {
163            k == &pack_name
164                || k.strip_prefix("pack/").unwrap_or(k.as_str()) == pack_name
165                || Path::new(k).file_name().and_then(|s| s.to_str()) == Some(pack_name.as_str())
166        }) {
167            continue;
168        }
169
170        let stem = pack_name
171            .strip_suffix(".pack")
172            .unwrap_or(pack_name.as_str())
173            .to_string();
174
175        let keep_path = pack_dir.join(format!("{stem}.keep"));
176        if keep_path.is_file() && !pack_kept_objects {
177            continue;
178        }
179
180        if !pack_dir.join(format!("{stem}.promisor")).is_file() {
181            continue;
182        }
183
184        let mtime_secs = std::fs::metadata(&idx.pack_path)
185            .map(|m| {
186                m.modified()
187                    .ok()
188                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
189                    .map(|d| d.as_secs())
190                    .unwrap_or(0)
191            })
192            .unwrap_or(0);
193
194        out.push(GeometricPack {
195            stem,
196            object_count: idx.entries.len(),
197            mtime_secs,
198            is_local: true,
199        });
200    }
201
202    out.sort_by_key(|a| a.object_count);
203    Ok(out)
204}
205
206/// Preferred pack stem (largest retained non-promisor pack), for MIDX `--preferred-pack`.
207#[must_use]
208pub fn preferred_pack_stem_after_split(packs: &[GeometricPack], split: usize) -> Option<String> {
209    if split >= packs.len() {
210        return None;
211    }
212    // Largest local pack in the retained suffix: rightmost after ascending sort.
213    packs
214        .iter()
215        .skip(split)
216        .rev()
217        .find(|p| p.is_local)
218        .map(|p| p.stem.clone())
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn progression_intact_split_is_zero() {
227        // 3, 6, 12 with factor 2 forms a progression.
228        let w = vec![3, 6, 12];
229        assert_eq!(compute_geometry_split(&w, 2), 0);
230    }
231
232    #[test]
233    fn duplicate_small_packs_roll_up() {
234        // 3, 3, 6 — progression broken between 3 and 3; rollup extends through 6.
235        let w = vec![3, 3, 6];
236        assert_eq!(compute_geometry_split(&w, 2), 3);
237    }
238}