Skip to main content

git_policy_cli/
object_abbreviation.rs

1//! What:
2//!  The abbreviated name Git prints for an object (`git commit`'s `[main 30b2211]`, `git log --oneline`), computed in
3//!  process: the length from `core.abbrev` or from the number of packed objects, then lengthened until no other object
4//!  shares the name.
5//! Why:
6//!  A fixed seven characters turns ambiguous as a repository grows (Git itself prints ten in a repository of some hundred
7//!  thousand objects), and a person may copy the name. Git's rules are `odb_find_abbrev_len` (`odb.c`) for the length and
8//!  `find_abbrev_len_for_pack` (`odb/source-packed.c`) and `odb_source_loose_find_abbrev_len` (`odb/source-loose.c`) for
9//!  the uniqueness, all read from the Git 2.56.0 sources. The count is the approximate one: the objects of the pack
10//!  indexes (a multi-pack-index counts for the packs it covers), loose objects not counted, alternates included. Reading
11//!  the indexes needs no Git process; a shape this reader does not model (an incremental multi-pack-index chain, a
12//!  version 1 pack index with a name it cannot read) answers nothing and the caller asks Git.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const short = abbreviate({ objectDirectory, setting: settingOf(config['core.abbrev']), name }); // undefined: ask Git
17//! ```
18
19#![cfg(unix)]
20
21use super::repo_read_odb::alternates_of;
22use std::fs::File;
23use std::os::unix::fs::FileExt;
24use std::path::{Path, PathBuf};
25
26/// Git's `FALLBACK_DEFAULT_ABBREV`: the shortest automatic length.
27pub const FALLBACK_ABBREVIATION: usize = 7;
28
29/// Git's `minimum_abbrev`: the shortest length `core.abbrev` may name.
30pub const MINIMUM_ABBREVIATION: i64 = 4;
31
32/// Git's `GIT_MAX_HEXSZ`: what a false word in `core.abbrev` asks for, which means the whole name.
33const LONGEST_NAME: usize = 64;
34
35/// The bytes of a version 2 pack index before the names: magic, version and the fanout table.
36const FANOUT_END: u64 = 8 + 256 * 4;
37
38/// The bytes of a version 1 pack index before its entries: the fanout table.
39const FANOUT_END_V1: u64 = 256 * 4;
40
41/// How a repository asks for the length of an abbreviation.
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum Setting {
44    /// `core.abbrev` is unset, `auto`, or unusable: the length follows the size of the repository.
45    Auto,
46    /// The shortest length `core.abbrev` asks for; the name is still lengthened until it is unique.
47    Length(usize),
48}
49
50/// What:
51///  The setting the value of `core.abbrev` stands for.
52/// Why:
53///   Git reads `auto` as the automatic length, a false word (or an empty value) as the whole name and a number from four up
54///  as that many characters; anything else makes Git report the key and keep the automatic length.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// settingOf(value) // Auto | { length }
59/// ```
60pub fn setting_of(value: Option<&[u8]>) -> Setting {
61    let Some(bytes) = value else { return Setting::Auto };
62    let Ok(text) = std::str::from_utf8(bytes) else { return Setting::Auto };
63    let lowered: String = text.trim().to_ascii_lowercase();
64    if lowered == "auto" {
65        return Setting::Auto;
66    }
67    if lowered.is_empty() || ["no", "false", "off"].contains(&lowered.as_str()) {
68        return Setting::Length(LONGEST_NAME);
69    }
70    return match lowered.parse::<i64>() {
71        Ok(number) if number >= MINIMUM_ABBREVIATION => Setting::Length(usize::try_from(number).unwrap_or(LONGEST_NAME)),
72        _ => Setting::Auto,
73    };
74}
75
76/// The most significant bit of a count, as Git's `msb`: the index of the highest set bit.
77fn highest_bit(count: u64) -> usize {
78    return if count == 0 { 0 } else { 63 - count.leading_zeros() as usize };
79}
80
81/// What:
82///  The length Git starts from for a repository of `count` packed objects.
83/// Why:
84///   About `2^bits` objects expect a collision at `2^(bits/2)`; four bits make a character, and small repositories keep the
85///  fallback.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// Math.max(7, Math.ceil((msb(count) + 1) / 2))
90/// ```
91pub fn automatic_length(count: u64) -> usize {
92    return (highest_bit(count) + 1).div_ceil(2).max(FALLBACK_ABBREVIATION);
93}
94
95/// The hexadecimal characters two names share from the start; equal names share all.
96fn shared_prefix(first: &[u8], second: &[u8]) -> usize {
97    let mut shared: usize = 0;
98    for (left, right) in first.iter().zip(second) {
99        if left == right {
100            shared += 2;
101            continue;
102        }
103        if left >> 4 == right >> 4 {
104            shared += 1;
105        }
106        break;
107    }
108    return shared;
109}
110
111/// A big-endian `u32` at `position`.
112fn be32(file: &File, position: u64) -> Option<u32> {
113    let mut bytes: [u8; 4] = [0; 4];
114    file.read_exact_at(&mut bytes, position).ok()?;
115    return Some(u32::from_be_bytes(bytes));
116}
117
118/// A big-endian `u64` at `position`.
119fn be64(file: &File, position: u64) -> Option<u64> {
120    let mut bytes: [u8; 8] = [0; 8];
121    file.read_exact_at(&mut bytes, position).ok()?;
122    return Some(u64::from_be_bytes(bytes));
123}
124
125/// A file holding object names in sorted order at a fixed stride.
126struct SortedNames {
127    file: File,
128    first: u64,
129    stride: u64,
130    count: u64,
131    length: usize,
132}
133
134impl SortedNames {
135    /// The `position`th name.
136    fn name(&self, position: u64) -> Option<Vec<u8>> {
137        let mut bytes: Vec<u8> = vec![0; self.length];
138        self.file.read_exact_at(bytes.as_mut_slice(), self.first + position * self.stride).ok()?;
139        return Some(bytes);
140    }
141
142    /// What:
143    ///  The longest shared prefix between `target` and any other name of the file.
144    /// Why:
145    ///   The names are sorted, so the closest ones are the two next to where `target` sits (or sat); a name equal to
146    ///  `target` is the object itself and is skipped.
147    ///
148    /// In TS you'd write (pseudocode):
149    /// ```ts
150    /// longestShared(names, target)
151    /// ```
152    fn longest_shared(&self, target: &[u8]) -> Option<usize> {
153        let mut low: u64 = 0;
154        let mut high: u64 = self.count;
155        let mut found: bool = false;
156        while low < high {
157            let middle: u64 = low + (high - low) / 2;
158            match self.name(middle)?.as_slice().cmp(target) {
159                std::cmp::Ordering::Equal => {
160                    found = true;
161                    low = middle;
162                    break;
163                }
164                std::cmp::Ordering::Less => low = middle + 1,
165                std::cmp::Ordering::Greater => high = middle,
166            }
167        }
168        let mut longest: usize = 0;
169        if low > 0 {
170            longest = longest.max(shared_prefix(self.name(low - 1)?.as_slice(), target));
171        }
172        let after: u64 = if found { low + 1 } else { low };
173        if after < self.count {
174            longest = longest.max(shared_prefix(self.name(after)?.as_slice(), target));
175        }
176        return Some(longest);
177    }
178}
179
180/// One pack index opened for its count and its names; `None` when it is not a plain index.
181fn open_index(path: &Path, length: usize) -> Option<SortedNames> {
182    let file: File = File::open(path).ok()?;
183    let mut magic: [u8; 8] = [0; 8];
184    file.read_exact_at(&mut magic, 0).ok()?;
185    if magic == *b"\xfftOc\0\0\0\x02" {
186        let count: u64 = u64::from(be32(&file, 8 + 255 * 4)?);
187        return Some(SortedNames { file, first: FANOUT_END, stride: length as u64, count, length });
188    }
189    // A version 1 index has no header: the fanout comes first, and each entry is a four-byte offset and then the name.
190    let count: u64 = u64::from(be32(&file, 255 * 4)?);
191    return Some(SortedNames { file, first: FANOUT_END_V1 + 4, stride: 4 + length as u64, count, length });
192}
193
194/// The chunk identifiers of a multi-pack-index that are read here.
195const CHUNK_FANOUT: u32 = 0x4f49_4446;
196const CHUNK_NAMES: u32 = 0x4f49_444c;
197const CHUNK_PACKS: u32 = 0x504e_414d;
198
199/// The covering multi-pack-index of one object directory: its names and the pack indexes it covers.
200struct Midx {
201    names: SortedNames,
202    covered: Vec<String>,
203}
204
205/// What:
206///  The multi-pack-index file of a pack directory, when there is one this reader models.
207/// Why:
208///   A pack the index covers is counted and searched through it, not on its own, as Git does. A chain of incremental
209///  layers is not modelled.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// readMidx(packDirectory, length) // undefined: none; null: not modelled
214/// ```
215fn read_midx(pack_directory: &Path, length: usize) -> Result<Option<Midx>, ()> {
216    if pack_directory.join("multi-pack-index.d").exists() {
217        return Err(());
218    }
219    let path: PathBuf = pack_directory.join("multi-pack-index");
220    let Ok(file) = File::open(&path) else { return Ok(None) };
221    let mut header: [u8; 12] = [0; 12];
222    file.read_exact_at(&mut header, 0).map_err(|_| {})?;
223    // Signature, version, object format, chunk count, number of base files, number of packs.
224    if &header[..4] != b"MIDX" || header[4] != 1 || header[7] != 0 {
225        return Err(());
226    }
227    let chunks: u64 = u64::from(header[6]);
228    let packs: u64 = u64::from(u32::from_be_bytes([header[8], header[9], header[10], header[11]]));
229    let mut fanout_chunk: Option<u64> = None;
230    let mut names_chunk: Option<u64> = None;
231    let mut packs_chunk: Option<(u64, u64)> = None;
232    let mut previous: Option<(u32, u64)> = None;
233    for index in 0..=chunks {
234        let at: u64 = 12 + index * 12;
235        let id: u32 = be32(&file, at).ok_or(())?;
236        let offset: u64 = be64(&file, at + 4).ok_or(())?;
237        if let Some((earlier, start)) = previous {
238            match earlier {
239                CHUNK_FANOUT => fanout_chunk = Some(start),
240                CHUNK_NAMES => names_chunk = Some(start),
241                CHUNK_PACKS => packs_chunk = Some((start, offset)),
242                _ => {}
243            }
244        }
245        previous = Some((id, offset));
246    }
247    let (fanout_at, names_at, (packs_start, packs_end)) = (fanout_chunk.ok_or(())?, names_chunk.ok_or(())?, packs_chunk.ok_or(())?);
248    let count: u64 = u64::from(be32(&file, fanout_at + 255 * 4).ok_or(())?);
249    let mut listed: Vec<u8> = vec![0; usize::try_from(packs_end.saturating_sub(packs_start)).map_err(|_| {})?];
250    file.read_exact_at(listed.as_mut_slice(), packs_start).map_err(|_| {})?;
251    let covered: Vec<String> = listed
252        .split(|byte| { return *byte == 0; })
253        .filter(|name| { return !name.is_empty(); })
254        .map(|name| { return String::from_utf8_lossy(name).into_owned(); })
255        .take(usize::try_from(packs).map_err(|_| {})?)
256        .collect();
257    return Ok(Some(Midx { names: SortedNames { file, first: names_at, stride: length as u64, count, length }, covered }));
258}
259
260/// The pack indexes and the multi-pack-index of one object directory.
261struct Packed {
262    midx: Option<Midx>,
263    indexes: Vec<SortedNames>,
264}
265
266/// Opens what a root's pack directory holds; `Err` when a shape is not modelled.
267fn open_packed(root: &Path, length: usize) -> Result<Packed, ()> {
268    let directory: PathBuf = root.join("pack");
269    let listing = match std::fs::read_dir(&directory) {
270        Ok(listing) => listing,
271        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Packed { midx: None, indexes: Vec::new() }),
272        Err(_) => return Err(()),
273    };
274    let midx: Option<Midx> = read_midx(directory.as_path(), length)?;
275    let mut indexes: Vec<SortedNames> = Vec::new();
276    for entry in listing {
277        let path: PathBuf = entry.map_err(|_| {})?.path();
278        let Some(name) = path.file_name().and_then(|name| { return name.to_str(); }).map(str::to_string) else { continue };
279        if !name.ends_with(".idx") || midx.as_ref().is_some_and(|covering| { return covering.covered.contains(&name); }) {
280            continue;
281        }
282        indexes.push(open_index(path.as_path(), length).ok_or(())?);
283    }
284    return Ok(Packed { midx, indexes });
285}
286
287/// The longest prefix `name` shares with a loose object of the root (the object itself excluded).
288fn loose_shared(root: &Path, hex: &str) -> Option<usize> {
289    let (directory, rest): (&str, &str) = hex.split_at(2);
290    let listing = match std::fs::read_dir(root.join(directory)) {
291        Ok(listing) => listing,
292        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
293        Err(_) => return None,
294    };
295    let mut longest: usize = 0;
296    for entry in listing {
297        let file_name = entry.ok()?.file_name();
298        let Some(other) = file_name.to_str() else { continue };
299        if other.len() != rest.len() || !other.bytes().all(|byte| { return byte.is_ascii_hexdigit(); }) || other == rest {
300            continue;
301        }
302        let shared: usize = rest.bytes().zip(other.bytes()).take_while(|(left, right)| { return left == right; }).count();
303        longest = longest.max(2 + shared);
304    }
305    return Some(longest);
306}
307
308/// The bytes of a lower-case hexadecimal name.
309fn bytes_of(hex: &str) -> Option<Vec<u8>> {
310    if hex.len() % 2 != 0 || !hex.bytes().all(|byte| { return byte.is_ascii_hexdigit(); }) {
311        return None;
312    }
313    return (0..hex.len() / 2).map(|at| { return u8::from_str_radix(&hex[at * 2..at * 2 + 2], 16).ok(); }).collect();
314}
315
316/// What:
317///  The abbreviation of `hex` that Git prints: at least the length the setting asks for, and longer until no other object
318///  of the repository or its alternates starts with the same characters.
319/// Why:
320///   The answer is Git's own without starting Git. Nothing is answered when the object directory, an alternate or a pack
321///  index has a shape this reader does not model, so the caller can ask Git.
322///
323/// In TS you'd write (pseudocode):
324/// ```ts
325/// abbreviate({ objectDirectory, setting, name }) // 'main' commit 30b2211...
326/// ```
327pub fn abbreviate(object_directory: &Path, setting: Setting, hex: &str) -> Option<String> {
328    let target: Vec<u8> = bytes_of(hex)?;
329    if hex != hex.to_ascii_lowercase() {
330        return None;
331    }
332    let own: PathBuf = std::fs::canonicalize(object_directory).ok()?;
333    let mut roots: Vec<PathBuf> = vec![own.clone()];
334    alternates_of(own.as_path(), 0, &mut roots)?;
335    let name_length: usize = target.len();
336    let mut opened: Vec<(PathBuf, Packed)> = Vec::new();
337    for root in &roots {
338        opened.push((root.clone(), open_packed(root, name_length).ok()?));
339    }
340    let mut length: usize = match setting {
341        Setting::Length(asked) => asked,
342        Setting::Auto => {
343            let counted: u64 = opened
344                .iter()
345                .map(|(_, packed)| { return packed.midx.as_ref().map_or(0, |midx| { return midx.names.count; }) + packed.indexes.iter().map(|index| { return index.count; }).sum::<u64>(); })
346                .sum();
347            automatic_length(counted)
348        }
349    };
350    if length >= hex.len() || length == 0 {
351        return Some(hex.to_string());
352    }
353    for (root, packed) in &opened {
354        let mut longest: usize = loose_shared(root.as_path(), hex)?;
355        for names in packed.midx.iter().map(|midx| { return &midx.names; }).chain(packed.indexes.iter()) {
356            longest = longest.max(names.longest_shared(&target)?);
357        }
358        if longest >= length {
359            length = longest + 1;
360        }
361    }
362    return Some(hex.get(..length.min(hex.len()))?.to_string());
363}
364
365/// Abbreviation controls stay out of the release executable.
366#[cfg(test)]
367#[path = "object_abbreviation_tests.rs"]
368mod tests;