Skip to main content

grit_lib/objects/
core.rs

1//! Git object model: object IDs, kinds, and in-memory representations.
2//!
3//! # Object ID
4//!
5//! [`ObjectId`] is a 20-byte SHA-1 digest.  It implements `Display` as
6//! lowercase hex, `FromStr` from a 40-character hex string, and the standard
7//! ordering traits so it can be used as a map key.
8//!
9//! # Object Kind
10//!
11//! [`ObjectKind`] represents the four Git object types: blob, tree, commit,
12//! and tag.  The raw header byte-slice is parsed with [`ObjectKind::from_bytes`].
13//!
14//! # Parsed objects
15//!
16//! [`Object`] bundles a kind and its raw (decompressed, header-stripped) byte
17//! content.  Higher-level parsed forms (e.g. [`TreeEntry`], [`CommitData`])
18//! live in this module and are produced by fallible `TryFrom<&Object>`
19//! conversions.
20
21use std::fmt;
22use std::str::FromStr;
23
24use crate::commit_encoding;
25use crate::error::{Error, Result};
26
27/// A Git hash algorithm.
28///
29/// Git supports two object-id hash functions: the historical SHA-1 (20-byte
30/// digests, 40 hex chars) and the newer SHA-256 (32-byte digests, 64 hex
31/// chars). A repository's algorithm is recorded in `extensions.objectformat`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
33pub enum HashAlgo {
34    /// SHA-1 — 20-byte digests.
35    #[default]
36    Sha1,
37    /// SHA-256 — 32-byte digests.
38    Sha256,
39}
40
41impl HashAlgo {
42    /// The raw digest length in bytes (20 for SHA-1, 32 for SHA-256).
43    #[must_use]
44    pub const fn len(self) -> usize {
45        match self {
46            Self::Sha1 => 20,
47            Self::Sha256 => 32,
48        }
49    }
50
51    /// Always false — every hash algorithm has a non-zero digest length.
52    #[must_use]
53    pub const fn is_empty(self) -> bool {
54        false
55    }
56
57    /// The hex-encoded digest length (40 for SHA-1, 64 for SHA-256).
58    #[must_use]
59    pub const fn hex_len(self) -> usize {
60        self.len() * 2
61    }
62
63    /// The lowercase name as written in `extensions.objectformat`.
64    #[must_use]
65    pub const fn name(self) -> &'static str {
66        match self {
67            Self::Sha1 => "sha1",
68            Self::Sha256 => "sha256",
69        }
70    }
71
72    /// The `oid_version` byte used in `.idx`/multi-pack-index headers
73    /// (SHA-1 → 1, SHA-256 → 2).
74    #[must_use]
75    pub const fn oid_version(self) -> u8 {
76        match self {
77            Self::Sha1 => 1,
78            Self::Sha256 => 2,
79        }
80    }
81
82    /// Parse from the name used in `extensions.objectformat` config.
83    #[must_use]
84    pub fn from_name(name: &str) -> Option<Self> {
85        match name.trim() {
86            v if v.eq_ignore_ascii_case("sha1") => Some(Self::Sha1),
87            v if v.eq_ignore_ascii_case("sha256") => Some(Self::Sha256),
88            _ => None,
89        }
90    }
91
92    /// The algorithm implied by a raw digest length, if recognised.
93    #[must_use]
94    pub const fn from_len(len: usize) -> Option<Self> {
95        match len {
96            20 => Some(Self::Sha1),
97            32 => Some(Self::Sha256),
98            _ => None,
99        }
100    }
101}
102
103/// Error when converting an on-disk hash-version byte to [`HashAlgo`].
104#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
105#[error("unknown Git hash version byte {0} (expected 1 for SHA-1 or 2 for SHA-256)")]
106pub struct HashVersionError(pub u8);
107
108impl From<HashAlgo> for u8 {
109    fn from(algo: HashAlgo) -> Self {
110        algo.oid_version()
111    }
112}
113
114impl TryFrom<u8> for HashAlgo {
115    type Error = HashVersionError;
116
117    fn try_from(version: u8) -> std::result::Result<Self, Self::Error> {
118        match version {
119            1 => Ok(Self::Sha1),
120            2 => Ok(Self::Sha256),
121            other => Err(HashVersionError(other)),
122        }
123    }
124}
125
126/// Maximum raw digest length across supported hash algorithms (SHA-256).
127const MAX_OID_LEN: usize = 32;
128
129/// Canonical SHA-1 empty tree digest (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`).
130const WELL_KNOWN_EMPTY_TREE_CANON: [u8; 20] = [
131    0x4b, 0x82, 0x5d, 0xc6, 0x42, 0xcb, 0x6e, 0xb9, 0xa0, 0x60, 0xe5, 0x4b, 0xf8, 0xd6, 0x92, 0x88,
132    0xfb, 0xee, 0x49, 0x04,
133];
134/// Legacy typo empty tree digest still referenced by some tests (`…899d69f7c6948d4`).
135const WELL_KNOWN_EMPTY_TREE_LEGACY: [u8; 20] = [
136    0x4b, 0x82, 0x5d, 0xc6, 0x42, 0xcb, 0x6e, 0xb9, 0xa0, 0x60, 0xe5, 0x4b, 0xf8, 0x99, 0xd6, 0x9f,
137    0x7c, 0x69, 0x48, 0xd4,
138];
139/// SHA-256 empty tree digest (`6ef19b41…decc5321`).
140const EMPTY_TREE_SHA256: [u8; 32] = [
141    0x6e, 0xf1, 0x9b, 0x41, 0x22, 0x5c, 0x53, 0x69, 0xf1, 0xc1, 0x04, 0xd4, 0x5d, 0x8d, 0x85, 0xef,
142    0xa9, 0xb0, 0x57, 0xb5, 0x3b, 0x14, 0xb4, 0xb9, 0xb9, 0x39, 0xdd, 0x74, 0xde, 0xcc, 0x53, 0x21,
143];
144/// SHA-1 digest of the empty blob (`e69de29bb2d1d6434b8b29ae775ad8c2e48c5391`).
145const EMPTY_BLOB_SHA1: [u8; 20] = [
146    0xe6, 0x9d, 0xe2, 0x9b, 0xb2, 0xd1, 0xd6, 0x43, 0x4b, 0x8b, 0x29, 0xae, 0x77, 0x5a, 0xd8, 0xc2,
147    0xe4, 0x8c, 0x53, 0x91,
148];
149/// SHA-256 digest of the empty blob (`473a0f4c…303721813`).
150const EMPTY_BLOB_SHA256: [u8; 32] = [
151    0x47, 0x3a, 0x0f, 0x4c, 0x3b, 0xe8, 0xa9, 0x36, 0x81, 0xa2, 0x67, 0xe3, 0xb1, 0xe9, 0xa7, 0xdc,
152    0xda, 0x11, 0x85, 0x43, 0x6f, 0xe1, 0x41, 0xf7, 0x74, 0x91, 0x20, 0xa3, 0x03, 0x72, 0x18, 0x13,
153];
154
155/// A Git object identifier: a SHA-1 (20-byte) or SHA-256 (32-byte) digest.
156///
157/// The digest is stored in a fixed 32-byte buffer with an explicit length;
158/// bytes beyond `len` are always zero, so the derived `Eq`/`Ord`/`Hash`
159/// remain correct. The hash algorithm is inferred from the length.
160///
161/// # Examples
162///
163/// ```
164/// use grit_lib::objects::ObjectId;
165///
166/// # fn main() -> grit_lib::error::Result<()> {
167/// let id = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?;
168/// assert_eq!(id.to_hex(), "4b825dc642cb6eb9a060e54bf8d69288fbee4904");
169/// # Ok(())
170/// # }
171/// ```
172#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
173pub struct ObjectId {
174    bytes: [u8; MAX_OID_LEN],
175    len: u8,
176}
177
178impl ObjectId {
179    /// The all-zero SHA-1 object id (Git's "null" OID).
180    ///
181    /// Used for index placeholders such as intent-to-add entries and for
182    /// special cases in plumbing output. For an algorithm-specific null OID
183    /// (e.g. 64 zeros in a SHA-256 repo) use [`ObjectId::null`].
184    #[must_use]
185    pub const fn zero() -> Self {
186        Self {
187            bytes: [0u8; MAX_OID_LEN],
188            len: 20,
189        }
190    }
191
192    /// The all-zero ("null") object id for a given hash algorithm.
193    #[must_use]
194    pub const fn null(algo: HashAlgo) -> Self {
195        Self {
196            bytes: [0u8; MAX_OID_LEN],
197            len: algo.len() as u8,
198        }
199    }
200
201    /// Construct from a raw digest slice (20 bytes for SHA-1, 32 for SHA-256).
202    ///
203    /// # Errors
204    ///
205    /// Returns [`Error::InvalidObjectId`] when `bytes` is not a recognised
206    /// digest length.
207    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
208        if HashAlgo::from_len(bytes.len()).is_none() {
209            return Err(Error::InvalidObjectId(hex::encode(bytes)));
210        }
211        let mut buf = [0u8; MAX_OID_LEN];
212        buf[..bytes.len()].copy_from_slice(bytes);
213        Ok(Self {
214            bytes: buf,
215            len: bytes.len() as u8,
216        })
217    }
218
219    /// Raw digest bytes (20 or 32 bytes depending on the hash algorithm).
220    #[must_use]
221    pub fn as_bytes(&self) -> &[u8] {
222        &self.bytes[..self.len as usize]
223    }
224
225    /// The hash algorithm this OID belongs to.
226    #[must_use]
227    pub fn algo(&self) -> HashAlgo {
228        HashAlgo::from_len(self.len as usize).unwrap_or(HashAlgo::Sha1)
229    }
230
231    /// Check if this is the null (all-zero) object ID.
232    #[must_use]
233    pub fn is_zero(&self) -> bool {
234        self.as_bytes().iter().all(|&b| b == 0)
235    }
236
237    /// Number of leading hex digits this id shares with `other`.
238    #[must_use]
239    pub(crate) fn common_prefix_nibbles(&self, other: &Self) -> usize {
240        let (a, b) = (self.as_bytes(), other.as_bytes());
241        match a.iter().zip(b).position(|(x, y)| x != y) {
242            Some(i) => i * 2 + usize::from((a[i] ^ b[i]) & 0xf0 == 0),
243            None => a.len().min(b.len()) * 2,
244        }
245    }
246
247    /// Whether this OID is the empty blob for its hash algorithm (SHA-1 or SHA-256).
248    #[must_use]
249    pub(crate) fn is_empty_blob(&self) -> bool {
250        let bytes = self.as_bytes();
251        bytes == EMPTY_BLOB_SHA1 || bytes == EMPTY_BLOB_SHA256
252    }
253
254    /// Whether this OID is Git's well-known empty tree (canonical or legacy typo hash).
255    #[must_use]
256    pub(crate) fn is_well_known_empty_tree(&self) -> bool {
257        self.is_canonical_empty_tree()
258            || (self.as_bytes().len() == HashAlgo::Sha1.len()
259                && self.as_bytes() == WELL_KNOWN_EMPTY_TREE_LEGACY)
260    }
261
262    /// Whether this OID is the empty tree of a repository using `algo`, including the legacy
263    /// SHA-1 typo id.
264    #[must_use]
265    pub(crate) fn is_empty_tree_for(&self, algo: HashAlgo) -> bool {
266        match algo {
267            HashAlgo::Sha1 => self.is_well_known_empty_tree(),
268            HashAlgo::Sha256 => self.as_bytes() == EMPTY_TREE_SHA256,
269        }
270    }
271
272    /// Whether this OID is the canonical SHA-1 empty tree (`4b825dc6…4904`).
273    #[must_use]
274    pub(crate) fn is_canonical_empty_tree(&self) -> bool {
275        self.as_bytes().len() == HashAlgo::Sha1.len()
276            && self.as_bytes() == WELL_KNOWN_EMPTY_TREE_CANON
277    }
278
279    /// Canonical SHA-1 empty tree object id (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`).
280    ///
281    /// Returns the well-known digest without writing a loose object. On SHA-256
282    /// repositories use [`Odb::empty_tree_oid`](crate::odb::Odb::empty_tree_oid)
283    /// instead.
284    #[must_use]
285    pub const fn empty_tree() -> Self {
286        let mut bytes = [0u8; MAX_OID_LEN];
287        let mut i = 0;
288        while i < WELL_KNOWN_EMPTY_TREE_CANON.len() {
289            bytes[i] = WELL_KNOWN_EMPTY_TREE_CANON[i];
290            i += 1;
291        }
292        Self { bytes, len: 20 }
293    }
294
295    /// Append `/<xx>/<rest-of-hex>` for this OID under `path` without heap hex strings.
296    pub(crate) fn push_loose_relative(&self, path: &mut std::path::PathBuf) {
297        const HEX: &[u8; 16] = b"0123456789abcdef";
298        let bytes = self.as_bytes();
299        let prefix = [
300            HEX[(bytes[0] >> 4) as usize],
301            HEX[(bytes[0] & 0xf) as usize],
302        ];
303        // SAFETY: `prefix` and `suffix` are ASCII hex digits only.
304        path.push(unsafe { std::str::from_utf8_unchecked(&prefix) });
305        let suffix_len = (bytes.len() - 1) * 2;
306        let mut suffix = [0u8; 62];
307        for (i, &b) in bytes[1..].iter().enumerate() {
308            suffix[i * 2] = HEX[(b >> 4) as usize];
309            suffix[i * 2 + 1] = HEX[(b & 0xf) as usize];
310        }
311        path.push(unsafe { std::str::from_utf8_unchecked(&suffix[..suffix_len]) });
312    }
313
314    /// Build `<objects-dir>/<xx>/<rest>` for this OID without intermediate hex [`String`]s.
315    #[must_use]
316    pub(crate) fn loose_path_in(&self, objects_dir: &std::path::Path) -> std::path::PathBuf {
317        let mut path = objects_dir.to_path_buf();
318        self.push_loose_relative(&mut path);
319        path
320    }
321
322    /// Lowercase hex representation (40 or 64 characters).
323    #[must_use]
324    pub fn to_hex(&self) -> String {
325        self.hex_digits().as_str().to_owned()
326    }
327
328    /// The lowercase hex digits of this id, without allocating.
329    fn hex_digits(&self) -> HexDigits {
330        const DIGITS: &[u8; 16] = b"0123456789abcdef";
331        let mut buf = [0u8; MAX_OID_LEN * 2];
332        for (pair, &byte) in buf.as_chunks_mut::<2>().0.iter_mut().zip(self.as_bytes()) {
333            *pair = [
334                DIGITS[usize::from(byte >> 4)],
335                DIGITS[usize::from(byte & 0x0f)],
336            ];
337        }
338        HexDigits {
339            buf,
340            len: usize::from(self.len) * 2,
341        }
342    }
343
344    /// The two-character directory prefix used by the loose object store.
345    ///
346    /// Returns the first two hex chars (e.g. `"ab"` for `"ab3f…"`).
347    #[must_use]
348    pub fn loose_prefix(&self) -> String {
349        hex::encode(&self.bytes[..1])
350    }
351
352    /// Parse an object ID from a hex string (40 chars for SHA-1, 64 for
353    /// SHA-256).
354    ///
355    /// # Errors
356    ///
357    /// Returns [`Error::InvalidObjectId`] if the string is not a valid hex OID.
358    pub fn from_hex(s: &str) -> Result<Self> {
359        Self::decode_hex(s.as_bytes()).ok_or_else(|| Error::InvalidObjectId(s.to_owned()))
360    }
361
362    /// Parse a fixed-width lowercase/uppercase ASCII hex OID (40 chars for SHA-1).
363    ///
364    /// # Errors
365    ///
366    /// Returns [`Error::InvalidObjectId`] when `bytes` is not exactly 40 hex digits.
367    pub fn from_hex_ascii(bytes: &[u8]) -> Result<Self> {
368        if bytes.len() != HashAlgo::Sha1.hex_len() {
369            return Err(Error::InvalidObjectId(
370                String::from_utf8_lossy(bytes).into_owned(),
371            ));
372        }
373        Self::decode_hex(bytes)
374            .ok_or_else(|| Error::InvalidObjectId(String::from_utf8_lossy(bytes).into_owned()))
375    }
376
377    /// Decode 40 or 64 ASCII hex digits (either case); `None` for any other input.
378    ///
379    /// Ref listings and tree walks parse ids in bulk, so this avoids per-digit
380    /// branching and allocation: invalid digits are folded into one check at the end.
381    fn decode_hex(hex: &[u8]) -> Option<Self> {
382        let len = hex.len() / 2;
383        if !Self::is_hex_len(hex.len()) {
384            return None;
385        }
386        let mut bytes = [0u8; MAX_OID_LEN];
387        let mut invalid = 0u8;
388        for (out, pair) in bytes.iter_mut().zip(hex.as_chunks::<2>().0) {
389            let hi = HEX_DECODE[usize::from(pair[0])];
390            let lo = HEX_DECODE[usize::from(pair[1])];
391            invalid |= hi | lo;
392            *out = (hi << 4) | lo;
393        }
394        (invalid & HEX_INVALID == 0).then_some(Self {
395            bytes,
396            len: len as u8,
397        })
398    }
399
400    /// The suffix used as the filename inside the loose prefix dir (the digest
401    /// minus its first byte: 38 hex chars for SHA-1, 62 for SHA-256).
402    #[must_use]
403    pub fn loose_suffix(&self) -> String {
404        hex::encode(&self.bytes[1..self.len as usize])
405    }
406
407    /// Whether `s` is a full-length hex OID for a supported hash algorithm
408    /// (40 hex chars for SHA-1 or 64 for SHA-256), case-insensitive.
409    #[must_use]
410    pub fn is_full_hex(s: &str) -> bool {
411        (s.len() == HashAlgo::Sha1.hex_len() || s.len() == HashAlgo::Sha256.hex_len())
412            && s.bytes().all(|b| b.is_ascii_hexdigit())
413    }
414
415    /// Whether `len` is a valid full hex-OID length (40 or 64).
416    #[must_use]
417    pub const fn is_hex_len(len: usize) -> bool {
418        len == HashAlgo::Sha1.hex_len() || len == HashAlgo::Sha256.hex_len()
419    }
420
421    /// Whether `len` is a valid loose-object filename suffix length, i.e. a full
422    /// hex OID minus its first byte (38 for SHA-1, 62 for SHA-256).
423    #[must_use]
424    pub const fn is_loose_suffix_len(len: usize) -> bool {
425        len == HashAlgo::Sha1.hex_len() - 2 || len == HashAlgo::Sha256.hex_len() - 2
426    }
427}
428
429impl std::hash::Hash for ObjectId {
430    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
431        state.write(self.as_bytes());
432    }
433}
434
435impl fmt::Display for ObjectId {
436    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
437        f.write_str(self.hex_digits().as_str())
438    }
439}
440
441/// Stack buffer holding an [`ObjectId`]'s hex digits.
442struct HexDigits {
443    buf: [u8; MAX_OID_LEN * 2],
444    len: usize,
445}
446
447impl HexDigits {
448    fn as_str(&self) -> &str {
449        std::str::from_utf8(&self.buf[..self.len]).unwrap_or_default()
450    }
451}
452
453impl fmt::Debug for ObjectId {
454    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455        write!(f, "ObjectId({})", self.to_hex())
456    }
457}
458
459#[cfg(feature = "serde-models")]
460impl serde::Serialize for ObjectId {
461    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
462    where
463        S: serde::Serializer,
464    {
465        serializer.serialize_str(&self.to_hex())
466    }
467}
468
469/// Marker bit in [`HEX_DECODE`] for bytes that are not hex digits.
470const HEX_INVALID: u8 = 0x10;
471
472/// Nibble value of each ASCII hex digit; every other byte maps to [`HEX_INVALID`].
473const HEX_DECODE: [u8; 256] = {
474    let mut table = [HEX_INVALID; 256];
475    let mut i = 0u8;
476    while i < 10 {
477        table[(b'0' + i) as usize] = i;
478        i += 1;
479    }
480    let mut i = 0u8;
481    while i < 6 {
482        table[(b'a' + i) as usize] = 10 + i;
483        table[(b'A' + i) as usize] = 10 + i;
484        i += 1;
485    }
486    table
487};
488
489impl FromStr for ObjectId {
490    type Err = Error;
491
492    fn from_str(s: &str) -> Result<Self> {
493        Self::from_hex(s)
494    }
495}
496
497/// A Git file mode (`100644`, `100755`, `120000`, `160000`, `040000`), or
498/// [`FileMode::ABSENT`] for the missing side of an add or delete.
499///
500/// Displays and serializes as the six-digit octal text Git prints in raw diff
501/// output, so the zero mode reads `000000`.
502///
503/// # Examples
504///
505/// ```
506/// use grit_lib::objects::FileMode;
507///
508/// assert_eq!(FileMode::EXECUTABLE.to_string(), "100755");
509/// assert_eq!(FileMode::ABSENT.to_string(), "000000");
510/// assert_eq!(FileMode::new(0o120000), FileMode::SYMLINK);
511/// assert!(FileMode::SYMLINK.is_symlink());
512/// ```
513#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
514pub struct FileMode(u32);
515
516impl FileMode {
517    /// No entry on this side (an add's old side, a delete's new side).
518    pub const ABSENT: Self = Self(0);
519    /// Regular, non-executable file.
520    pub const REGULAR: Self = Self(0o100644);
521    /// Executable file.
522    pub const EXECUTABLE: Self = Self(0o100755);
523    /// Symbolic link.
524    pub const SYMLINK: Self = Self(0o120000);
525    /// Submodule commit (gitlink).
526    pub const GITLINK: Self = Self(0o160000);
527    /// Tree (directory).
528    pub const TREE: Self = Self(0o040000);
529
530    /// Wrap raw mode bits as stored in a tree entry or the index.
531    #[must_use]
532    pub const fn new(bits: u32) -> Self {
533        Self(bits)
534    }
535
536    /// The raw mode bits.
537    #[must_use]
538    pub const fn bits(self) -> u32 {
539        self.0
540    }
541
542    /// Whether this is [`Self::ABSENT`].
543    #[must_use]
544    pub const fn is_absent(self) -> bool {
545        self.0 == 0
546    }
547
548    /// The octal text, or `None` for [`Self::ABSENT`] (the missing side of an add or delete).
549    #[must_use]
550    pub fn octal_if_present(self) -> Option<String> {
551        (!self.is_absent()).then(|| self.to_string())
552    }
553
554    /// Whether the object-type bits name a symbolic link.
555    #[must_use]
556    pub const fn is_symlink(self) -> bool {
557        self.0 & 0o170000 == 0o120000
558    }
559
560    /// Whether the object-type bits name a gitlink (submodule commit).
561    #[must_use]
562    pub const fn is_gitlink(self) -> bool {
563        self.0 & 0o170000 == 0o160000
564    }
565}
566
567impl From<u32> for FileMode {
568    fn from(bits: u32) -> Self {
569        Self(bits)
570    }
571}
572
573impl From<FileMode> for u32 {
574    fn from(mode: FileMode) -> Self {
575        mode.0
576    }
577}
578
579impl fmt::Display for FileMode {
580    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
581        write!(f, "{:06o}", self.0)
582    }
583}
584
585impl fmt::Debug for FileMode {
586    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
587        write!(f, "FileMode({:06o})", self.0)
588    }
589}
590
591#[cfg(feature = "serde-models")]
592impl serde::Serialize for FileMode {
593    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
594    where
595        S: serde::Serializer,
596    {
597        serializer.collect_str(self)
598    }
599}
600
601/// A parsed abbreviated object id: the leading hex digits ("nibbles") of a digest.
602///
603/// Parse the user's hex once with [`ObjectIdPrefix::from_hex`], then test candidates
604/// with [`ObjectIdPrefix::matches`], which compares raw digest bytes without allocating.
605/// Sorted backends (pack indexes, key-value stores) can instead scan the inclusive id
606/// range [`ObjectIdPrefix::lowest`]..=[`ObjectIdPrefix::highest`], which holds exactly
607/// the ids that match.
608///
609/// # Examples
610///
611/// ```
612/// use grit_lib::objects::{HashAlgo, ObjectId, ObjectIdPrefix};
613///
614/// # fn main() -> grit_lib::error::Result<()> {
615/// let id = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?;
616/// let prefix = ObjectIdPrefix::from_hex("4B825", HashAlgo::Sha1)?;
617/// assert_eq!(prefix.nibbles(), 5);
618/// assert!(prefix.matches(&id));
619/// assert!(!ObjectIdPrefix::from_hex("4b826", HashAlgo::Sha1)?.matches(&id));
620/// assert!(prefix.lowest() <= id && id <= prefix.highest());
621/// # Ok(())
622/// # }
623/// ```
624#[derive(Clone, Copy, PartialEq, Eq)]
625pub struct ObjectIdPrefix {
626    /// Prefix digits packed two per byte; the low nibble of an odd-length tail and
627    /// every byte past it are zero.
628    bytes: [u8; MAX_OID_LEN],
629    nibbles: u8,
630    algo: HashAlgo,
631}
632
633impl ObjectIdPrefix {
634    /// Parse `hex` (either case, possibly empty) as a prefix of `algo` object ids.
635    ///
636    /// The empty prefix matches every id.
637    ///
638    /// # Errors
639    ///
640    /// Returns [`Error::InvalidObjectId`] when `hex` contains a non-hex character or is
641    /// longer than a full `algo` id ([`HashAlgo::hex_len`]).
642    pub fn from_hex(hex: &str, algo: HashAlgo) -> Result<Self> {
643        let digits = hex.as_bytes();
644        if digits.len() > algo.hex_len() {
645            return Err(Error::InvalidObjectId(hex.to_owned()));
646        }
647        let mut bytes = [0u8; MAX_OID_LEN];
648        let mut invalid = 0u8;
649        for (i, &digit) in digits.iter().enumerate() {
650            let nibble = HEX_DECODE[usize::from(digit)];
651            invalid |= nibble;
652            let shift = if i.is_multiple_of(2) { 4 } else { 0 };
653            bytes[i / 2] |= (nibble & 0x0f) << shift;
654        }
655        if invalid & HEX_INVALID != 0 {
656            return Err(Error::InvalidObjectId(hex.to_owned()));
657        }
658        Ok(Self {
659            bytes,
660            nibbles: digits.len() as u8,
661            algo,
662        })
663    }
664
665    /// Number of hex digits in the prefix.
666    #[must_use]
667    pub fn nibbles(&self) -> usize {
668        usize::from(self.nibbles)
669    }
670
671    /// Whether the prefix has no digits (and so matches every id).
672    #[must_use]
673    pub fn is_empty(&self) -> bool {
674        self.nibbles == 0
675    }
676
677    /// Hash algorithm the prefix was parsed for.
678    #[must_use]
679    pub fn algo(&self) -> HashAlgo {
680        self.algo
681    }
682
683    /// Whether `oid` starts with this prefix.
684    ///
685    /// Compares raw digest bytes (masking the last byte for an odd digit count), so the
686    /// check never allocates. An id shorter than the prefix never matches.
687    #[must_use]
688    pub fn matches(&self, oid: &ObjectId) -> bool {
689        self.matches_digest(oid.as_bytes())
690    }
691
692    /// Like [`Self::matches`] for a raw digest slice (for example a pack index entry).
693    #[must_use]
694    pub(crate) fn matches_digest(&self, digest: &[u8]) -> bool {
695        let whole = self.nibbles() / 2;
696        let Some(head) = digest.get(..whole) else {
697            return false;
698        };
699        if head != &self.bytes[..whole] {
700            return false;
701        }
702        if self.nibbles.is_multiple_of(2) {
703            return true;
704        }
705        digest
706            .get(whole)
707            .is_some_and(|&b| b & 0xf0 == self.bytes[whole])
708    }
709
710    /// The smallest `algo` id that starts with this prefix (remaining digits all `0`).
711    #[must_use]
712    pub fn lowest(&self) -> ObjectId {
713        ObjectId {
714            bytes: self.bytes,
715            len: self.algo.len() as u8,
716        }
717    }
718
719    /// The largest `algo` id that starts with this prefix (remaining digits all `f`).
720    #[must_use]
721    pub fn highest(&self) -> ObjectId {
722        let mut bytes = self.bytes;
723        let whole = self.nibbles() / 2;
724        if !self.nibbles.is_multiple_of(2) {
725            bytes[whole] |= 0x0f;
726        }
727        let first_free = whole + usize::from(self.nibbles % 2);
728        bytes[first_free..self.algo.len()].fill(0xff);
729        ObjectId {
730            bytes,
731            len: self.algo.len() as u8,
732        }
733    }
734}
735
736impl fmt::Display for ObjectIdPrefix {
737    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
738        let hex = hex::encode(&self.bytes[..self.nibbles().div_ceil(2)]);
739        f.write_str(&hex[..self.nibbles()])
740    }
741}
742
743impl fmt::Debug for ObjectIdPrefix {
744    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
745        write!(f, "ObjectIdPrefix({self})")
746    }
747}
748
749/// The four Git object types.
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
751pub enum ObjectKind {
752    /// A raw file snapshot.
753    Blob,
754    /// A directory listing.
755    Tree,
756    /// A snapshot with metadata and parentage.
757    Commit,
758    /// An annotated tag.
759    Tag,
760}
761
762impl ObjectKind {
763    /// Parse from the ASCII keyword used in Git object headers.
764    ///
765    /// # Errors
766    ///
767    /// Returns [`Error::UnknownObjectType`] for unrecognised strings.
768    pub fn from_bytes(b: &[u8]) -> Result<Self> {
769        match b {
770            b"blob" => Ok(Self::Blob),
771            b"tree" => Ok(Self::Tree),
772            b"commit" => Ok(Self::Commit),
773            b"tag" => Ok(Self::Tag),
774            other => Err(Error::UnknownObjectType(
775                String::from_utf8_lossy(other).into_owned(),
776            )),
777        }
778    }
779
780    /// Parse the `type` field on an annotated tag object (Git `type_from_string_gently` rules).
781    ///
782    /// The tag header line is `type <typename>\n` where `typename` must match a known object type
783    /// keyword **exactly** (no extra characters, no strict prefix of a longer keyword).
784    #[must_use]
785    pub fn from_tag_type_field(line: &[u8]) -> Option<Self> {
786        fn keyword_matches(canonical: &[u8], field: &[u8]) -> bool {
787            if field.is_empty() {
788                return false;
789            }
790            for (i, &bc) in field.iter().enumerate() {
791                let sc = canonical.get(i).copied().unwrap_or(0);
792                if sc != bc {
793                    return false;
794                }
795            }
796            canonical.get(field.len()).copied().unwrap_or(0) == 0
797        }
798
799        const NAMES: &[(ObjectKind, &[u8])] = &[
800            (ObjectKind::Blob, b"blob"),
801            (ObjectKind::Tree, b"tree"),
802            (ObjectKind::Commit, b"commit"),
803            (ObjectKind::Tag, b"tag"),
804        ];
805        for &(kind, name) in NAMES {
806            if keyword_matches(name, line) {
807                return Some(kind);
808            }
809        }
810        None
811    }
812
813    /// The ASCII keyword for this kind (used in object headers).
814    #[must_use]
815    pub fn as_str(&self) -> &'static str {
816        match self {
817            Self::Blob => "blob",
818            Self::Tree => "tree",
819            Self::Commit => "commit",
820            Self::Tag => "tag",
821        }
822    }
823}
824
825impl fmt::Display for ObjectKind {
826    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
827        f.write_str(self.as_str())
828    }
829}
830
831impl FromStr for ObjectKind {
832    type Err = Error;
833
834    fn from_str(s: &str) -> Result<Self> {
835        Self::from_bytes(s.as_bytes())
836    }
837}
838
839/// Kind and uncompressed size of a Git object without loading its payload.
840#[derive(Debug, Clone, Copy, PartialEq, Eq)]
841pub struct ObjectInfo {
842    /// Object type after resolving any pack delta chain.
843    pub kind: ObjectKind,
844    /// Uncompressed byte length of the object body (after the header NUL).
845    pub size: u64,
846}
847
848/// A decompressed, header-stripped Git object.
849#[derive(Debug, Clone)]
850pub struct Object {
851    /// The type of this object.
852    pub kind: ObjectKind,
853    /// Raw byte content (everything after the NUL in the header).
854    pub data: Vec<u8>,
855}
856
857/// Borrowed view of an object's kind and uncompressed payload.
858///
859/// Produced by [`crate::odb::Odb::read_into`](crate::odb::Odb::read_into) and
860/// [`crate::odb::store::ObjectStore::read_into`](crate::odb::store::ObjectStore::read_into);
861/// the payload bytes live in the caller's buffer.
862#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub struct ObjectRef<'a> {
864    /// Object type after resolving any pack delta chain.
865    pub kind: ObjectKind,
866    /// Uncompressed payload (header stripped).
867    pub data: &'a [u8],
868}
869
870impl<'a> ObjectRef<'a> {
871    /// Borrow `buf` as an object payload with the given `kind`.
872    #[must_use]
873    pub fn from_buf(kind: ObjectKind, buf: &'a [u8]) -> Self {
874        Self { kind, data: buf }
875    }
876}
877
878impl Object {
879    /// Construct a new object from its kind and raw data.
880    #[must_use]
881    pub fn new(kind: ObjectKind, data: Vec<u8>) -> Self {
882        Self { kind, data }
883    }
884
885    /// Borrowed view of this object's kind and payload.
886    #[must_use]
887    pub fn as_ref(&self) -> ObjectRef<'_> {
888        ObjectRef {
889            kind: self.kind,
890            data: self.data.as_slice(),
891        }
892    }
893}
894
895/// A single entry in a Git tree object.
896#[derive(Debug, Clone, PartialEq, Eq)]
897pub struct TreeEntry {
898    /// Unix file mode (e.g. `0o100644` for a regular file, `0o040000` for a tree).
899    pub mode: u32,
900    /// Entry name (file or directory name only, no path separators).
901    pub name: Vec<u8>,
902    /// The object ID of the blob or sub-tree.
903    pub oid: ObjectId,
904}
905
906impl TreeEntry {
907    /// Format the mode as Git does: no leading zero, minimal digits.
908    ///
909    /// Git uses `"40000"` for trees (not `"040000"`), and `"100644"` for blobs.
910    #[must_use]
911    pub fn mode_str(&self) -> String {
912        // Git omits the leading zero for tree mode
913        if self.mode == 0o040000 {
914            "40000".to_owned()
915        } else {
916            format!("{:o}", self.mode)
917        }
918    }
919}
920
921/// A single tree entry borrowing name bytes from the tree object payload.
922#[derive(Debug, Clone, Copy, PartialEq, Eq)]
923pub struct TreeEntryRef<'a> {
924    /// Unix file mode (e.g. `0o100644` for a regular file, `0o040000` for a tree).
925    pub mode: u32,
926    /// Entry name (file or directory name only, no path separators).
927    pub name: &'a [u8],
928    /// The object ID of the blob or sub-tree.
929    pub oid: ObjectId,
930}
931
932impl TreeEntryRef<'_> {
933    /// Allocate an owning [`TreeEntry`] with the same mode, name, and OID.
934    #[must_use]
935    pub fn to_owned(&self) -> TreeEntry {
936        TreeEntry {
937            mode: self.mode,
938            name: self.name.to_vec(),
939            oid: self.oid,
940        }
941    }
942}
943
944/// Iterator over tree entries borrowing from the tree object's raw payload.
945///
946/// Construct with [`Self::with_oid_len`] when the hash width is known, or
947/// [`Self::infer_oid_len`] to detect SHA-1 vs SHA-256 like [`parse_tree`].
948#[derive(Debug, Clone)]
949pub struct TreeRefIter<'a> {
950    data: &'a [u8],
951    pos: usize,
952    oid_len: usize,
953    /// Set after the first parse error so callers do not see endless `Err` items.
954    failed: bool,
955}
956
957impl<'a> TreeRefIter<'a> {
958    /// Iterate `data` assuming each entry ends with an OID of `oid_len` bytes.
959    #[must_use]
960    pub fn with_oid_len(data: &'a [u8], oid_len: usize) -> Self {
961        Self {
962            data,
963            pos: 0,
964            oid_len,
965            failed: false,
966        }
967    }
968
969    /// Detect OID width and return an iterator (SHA-1 first, then SHA-256).
970    ///
971    /// # Errors
972    ///
973    /// Returns [`Error::CorruptObject`] when the blob does not parse as a tree.
974    pub fn infer_oid_len(data: &'a [u8]) -> Result<Self> {
975        match try_parse_tree_oid_len(data, HashAlgo::Sha1.len()) {
976            Ok(()) => Ok(Self::with_oid_len(data, HashAlgo::Sha1.len())),
977            Err(sha1_err) => match try_parse_tree_oid_len(data, HashAlgo::Sha256.len()) {
978                Ok(()) => Ok(Self::with_oid_len(data, HashAlgo::Sha256.len())),
979                Err(_) => Err(sha1_err),
980            },
981        }
982    }
983
984    fn next_entry(&mut self) -> Result<Option<TreeEntryRef<'a>>> {
985        parse_tree_entry_at(self.data, &mut self.pos, self.oid_len)
986    }
987}
988
989/// Parse one tree entry and advance `pos`. Returns `Ok(None)` when `pos` is at end of `data`.
990fn parse_tree_entry_at<'a>(
991    data: &'a [u8],
992    pos: &mut usize,
993    oid_len: usize,
994) -> Result<Option<TreeEntryRef<'a>>> {
995    if *pos >= data.len() {
996        return Ok(None);
997    }
998    let sp = data[*pos..]
999        .iter()
1000        .position(|&b| b == b' ')
1001        .ok_or_else(|| Error::CorruptObject("tree entry missing space".to_owned()))?;
1002    let mode_bytes = &data[*pos..*pos + sp];
1003    let mode = std::str::from_utf8(mode_bytes)
1004        .ok()
1005        .and_then(|s| u32::from_str_radix(s, 8).ok())
1006        .ok_or_else(|| {
1007            Error::CorruptObject(format!(
1008                "invalid tree mode: {}",
1009                String::from_utf8_lossy(mode_bytes)
1010            ))
1011        })?;
1012    *pos += sp + 1;
1013
1014    let nul = data[*pos..]
1015        .iter()
1016        .position(|&b| b == 0)
1017        .ok_or_else(|| Error::CorruptObject("tree entry missing NUL".to_owned()))?;
1018    let name = &data[*pos..*pos + nul];
1019    *pos += nul + 1;
1020
1021    if *pos + oid_len > data.len() {
1022        return Err(Error::CorruptObject("tree entry truncated SHA".to_owned()));
1023    }
1024    let oid = ObjectId::from_bytes(&data[*pos..*pos + oid_len])?;
1025    *pos += oid_len;
1026
1027    Ok(Some(TreeEntryRef { mode, name, oid }))
1028}
1029
1030impl<'a> Iterator for TreeRefIter<'a> {
1031    type Item = Result<TreeEntryRef<'a>>;
1032
1033    fn next(&mut self) -> Option<Self::Item> {
1034        if self.failed || self.pos >= self.data.len() {
1035            return None;
1036        }
1037        match self.next_entry() {
1038            Ok(Some(entry)) => Some(Ok(entry)),
1039            Ok(None) => None,
1040            Err(err) => {
1041                self.failed = true;
1042                self.pos = self.data.len();
1043                Some(Err(err))
1044            }
1045        }
1046    }
1047}
1048
1049/// Walk `data` once to verify it parses as a tree with the given OID width (no entry allocation).
1050fn try_parse_tree_oid_len(data: &[u8], oid_len: usize) -> Result<()> {
1051    let mut pos = 0usize;
1052    while parse_tree_entry_at(data, &mut pos, oid_len)?.is_some() {}
1053    Ok(())
1054}
1055
1056fn collect_tree_with_oid_len(data: &[u8], oid_len: usize) -> Result<Vec<TreeEntry>> {
1057    let mut pos = 0usize;
1058    let mut entries = Vec::with_capacity(data.len() / 32);
1059    while let Some(entry) = parse_tree_entry_at(data, &mut pos, oid_len)? {
1060        entries.push(TreeEntry {
1061            mode: entry.mode,
1062            name: entry.name.to_vec(),
1063            oid: entry.oid,
1064        });
1065    }
1066    Ok(entries)
1067}
1068
1069/// Parse the raw data of a tree object into its entries.
1070///
1071/// # Format
1072///
1073/// Each entry is `"<mode> <name>\0<20-byte-sha1>"` concatenated with no
1074/// separator between entries.
1075///
1076/// # Errors
1077///
1078/// Returns [`Error::CorruptObject`] if the data is malformed.
1079pub fn parse_tree(data: &[u8]) -> Result<Vec<TreeEntry>> {
1080    match collect_tree_with_oid_len(data, HashAlgo::Sha1.len()) {
1081        Ok(entries) => Ok(entries),
1082        Err(sha1_err) => {
1083            collect_tree_with_oid_len(data, HashAlgo::Sha256.len()).map_err(|_| sha1_err)
1084        }
1085    }
1086}
1087
1088/// Parse a tree blob assuming a fixed raw OID width (`oid_len` bytes).
1089///
1090/// Returns an error if the data does not parse cleanly into whole entries of
1091/// that width (used by [`parse_tree`] to detect the hash algorithm).
1092pub fn parse_tree_with_oid_len(data: &[u8], oid_len: usize) -> Result<Vec<TreeEntry>> {
1093    collect_tree_with_oid_len(data, oid_len)
1094}
1095
1096/// Build the raw bytes of a tree object from a slice of entries.
1097///
1098/// Entries **must** already be sorted in Git tree order (see [`tree_entry_cmp`]),
1099/// with unique, valid names and modes; nothing here checks that. To write a tree
1100/// from entries in any order with validation, use
1101/// [`Objects::write_tree`](crate::objects::Objects::write_tree).
1102#[must_use]
1103pub fn serialize_tree(entries: &[TreeEntry]) -> Vec<u8> {
1104    let len = entries
1105        .iter()
1106        .map(|e| 8 + e.name.len() + e.oid.as_bytes().len())
1107        .sum();
1108    let mut out = Vec::with_capacity(len);
1109    for e in entries {
1110        push_tree_mode(&mut out, e.mode);
1111        out.push(b' ');
1112        out.extend_from_slice(&e.name);
1113        out.push(0);
1114        out.extend_from_slice(e.oid.as_bytes());
1115    }
1116    out
1117}
1118
1119/// Append `mode` in octal without leading zeros, as [`TreeEntry::mode_str`] formats it.
1120fn push_tree_mode(out: &mut Vec<u8>, mode: u32) {
1121    let mut digits = [0u8; 11];
1122    let mut start = digits.len();
1123    let mut rest = mode;
1124    loop {
1125        start -= 1;
1126        digits[start] = b'0' + (rest & 7) as u8;
1127        rest >>= 3;
1128        if rest == 0 {
1129            break;
1130        }
1131    }
1132    out.extend_from_slice(&digits[start..]);
1133}
1134
1135/// Git's tree-entry sort comparator.
1136///
1137/// Trees are sorted byte-by-byte by `"<name>"` for blobs and `"<name>/"` for
1138/// sub-trees, so a directory `foo` sorts after a file `foo-bar` but before
1139/// `fooz`.  This matches `base_name_compare` in `tree.c`.
1140///
1141/// # Parameters
1142///
1143/// - `a_name`: name bytes of the first entry
1144/// - `a_is_tree`: whether the first entry is a sub-tree (`mode == 0o040000`)
1145/// - `b_name`: name bytes of the second entry
1146/// - `b_is_tree`: whether the second entry is a sub-tree
1147#[must_use]
1148pub fn tree_entry_cmp(
1149    a_name: &[u8],
1150    a_is_tree: bool,
1151    b_name: &[u8],
1152    b_is_tree: bool,
1153) -> std::cmp::Ordering {
1154    let a_trailer = if a_is_tree { b'/' } else { 0u8 };
1155    let b_trailer = if b_is_tree { b'/' } else { 0u8 };
1156
1157    let min_len = a_name.len().min(b_name.len());
1158    let cmp = a_name[..min_len].cmp(&b_name[..min_len]);
1159    if cmp != std::cmp::Ordering::Equal {
1160        return cmp;
1161    }
1162    // Names share a prefix; compare the next character (or trailer).
1163    let ac = a_name.get(min_len).copied().unwrap_or(a_trailer);
1164    let bc = b_name.get(min_len).copied().unwrap_or(b_trailer);
1165    ac.cmp(&bc)
1166}
1167
1168/// Parse an octal tree-entry mode without going through `str`.
1169fn parse_tree_mode_octal(bytes: &[u8]) -> Option<u32> {
1170    if bytes.is_empty() || bytes.len() > 7 {
1171        return None;
1172    }
1173    bytes.iter().try_fold(0u32, |acc, &b| match b {
1174        b'0'..=b'7' => Some(acc * 8 + u32::from(b - b'0')),
1175        _ => None,
1176    })
1177}
1178
1179/// Find one tree entry by name without building a full [`TreeEntry`] vector.
1180///
1181/// Scans entries in stored order and returns the first whose name equals `name`
1182/// (file or directory). Because Git sorts entries by `name` (with a `/` trailer for
1183/// directories), the scan stops at the first entry that sorts after `name/`, so only
1184/// the prefix of the tree up to the match is decoded; corruption after that point is
1185/// not reported.
1186///
1187/// # Errors
1188///
1189/// [`Error::CorruptObject`] when an entry scanned before the stop point is malformed.
1190pub(crate) fn find_tree_entry_by_name_in_data(
1191    data: &[u8],
1192    oid_len: usize,
1193    name: &[u8],
1194    name_is_tree: bool,
1195) -> Result<Option<TreeEntry>> {
1196    let mut pos = 0usize;
1197    while pos < data.len() {
1198        let rest = &data[pos..];
1199        let sp = memchr::memchr(b' ', rest)
1200            .ok_or_else(|| Error::CorruptObject("tree entry missing space".to_owned()))?;
1201        let mode = parse_tree_mode_octal(&rest[..sp])
1202            .ok_or_else(|| Error::CorruptObject("invalid tree mode".to_owned()))?;
1203        let name_start = sp + 1;
1204        let nul = memchr::memchr(0, &rest[name_start..])
1205            .ok_or_else(|| Error::CorruptObject("tree entry missing NUL".to_owned()))?;
1206        let entry_name = &rest[name_start..name_start + nul];
1207        let oid_start = name_start + nul + 1;
1208        let oid_end = oid_start + oid_len;
1209        if oid_end > rest.len() {
1210            return Err(Error::CorruptObject("tree entry truncated SHA".to_owned()));
1211        }
1212        if entry_name == name {
1213            return Ok(Some(TreeEntry {
1214                mode,
1215                name: entry_name.to_vec(),
1216                oid: ObjectId::from_bytes(&rest[oid_start..oid_end])?,
1217            }));
1218        }
1219        if tree_entry_cmp(entry_name, mode == 0o040000, name, name_is_tree)
1220            == std::cmp::Ordering::Greater
1221        {
1222            return Ok(None);
1223        }
1224        pos += oid_end;
1225    }
1226    Ok(None)
1227}
1228
1229/// Like [`find_tree_entry_by_name_in_data`], trying directory ordering first then file.
1230pub(crate) fn find_tree_entry_by_name_dir_or_file_in_data(
1231    data: &[u8],
1232    oid_len: usize,
1233    name: &[u8],
1234) -> Result<Option<TreeEntry>> {
1235    if let Some(entry) = find_tree_entry_by_name_in_data(data, oid_len, name, true)? {
1236        if entry.mode == 0o040000 {
1237            return Ok(Some(entry));
1238        }
1239    }
1240    find_tree_entry_by_name_in_data(data, oid_len, name, false)
1241}
1242
1243/// Parsed representation of a commit object.
1244///
1245/// This is the read-side shape (it round-trips stored bytes exactly). To create a
1246/// new commit, build a [`NewCommit`](crate::objects::NewCommit) and pass it to
1247/// [`Objects::write_commit`](crate::objects::Objects::write_commit).
1248#[derive(Debug, Clone, PartialEq, Eq)]
1249pub struct CommitData {
1250    /// The tree this commit points to.
1251    pub tree: ObjectId,
1252    /// Parent commit IDs (zero or more).
1253    pub parents: Vec<ObjectId>,
1254    /// Author field decoded to Unicode (using `encoding` when present, else UTF-8).
1255    pub author: String,
1256    /// Committer field decoded to Unicode.
1257    pub committer: String,
1258    /// Exact `author` header payload bytes as stored in the object (after `author `).
1259    ///
1260    /// Empty means treat [`Self::author`] as UTF-8 when serializing (new commits).
1261    pub author_raw: Vec<u8>,
1262    /// Exact `committer` header payload bytes as stored in the object.
1263    pub committer_raw: Vec<u8>,
1264    /// Optional encoding override (e.g. `"UTF-8"`).
1265    pub encoding: Option<String>,
1266    /// Commit message (everything after the blank line).
1267    pub message: String,
1268    /// Optional raw message bytes for non-UTF-8 commit messages.
1269    /// When set, `serialize_commit` uses these bytes instead of `message`.
1270    #[doc = "Optional raw message bytes for non-UTF-8 messages."]
1271    pub raw_message: Option<Vec<u8>>,
1272    /// Raw bytes for headers not represented in the fields above (`gpgsig`, `mergetag`, …),
1273    /// including each line's trailing `\n` and any continuation lines (leading space).
1274    ///
1275    /// [`serialize_commit`] always emits `tree`, `parent`, `author`, `committer`, and `encoding`
1276    /// from the structured fields, then appends these bytes before the blank line and message.
1277    pub extra_headers: Vec<u8>,
1278}
1279
1280/// First paragraph of a commit message, folded like Git `%(contents:subject)`.
1281///
1282/// Non-blank lines in the first paragraph are joined with a single space. Each line keeps
1283/// its leading whitespace and internal spacing; only trailing whitespace on each line is
1284/// removed before joining. The paragraph ends at the first blank line. An empty message
1285/// yields an empty string.
1286#[must_use]
1287pub fn commit_subject_pretty(message: &str) -> String {
1288    let mut parts = Vec::new();
1289    for line in message.lines() {
1290        if line.trim().is_empty() {
1291            break;
1292        }
1293        parts.push(line.trim_end());
1294    }
1295    parts.join(" ")
1296}
1297
1298/// Subject from commit object bytes (see [`commit_subject_pretty`]).
1299///
1300/// # Errors
1301///
1302/// Returns [`Error::CorruptObject`] when the commit headers are malformed.
1303pub fn read_commit_subject_pretty(data: &[u8]) -> Result<String> {
1304    let message_start = data.windows(2).position(|w| w == b"\n\n").map(|p| p + 2);
1305    let Some(message_start) = message_start else {
1306        return Ok(String::new());
1307    };
1308    let body = std::str::from_utf8(data.get(message_start..).unwrap_or_default()).unwrap_or("");
1309    Ok(commit_subject_pretty(body))
1310}
1311
1312/// First line of the commit message (subject), honoring the `encoding` header.
1313///
1314/// Scans headers only; does not require a full [`parse_commit`] parse.
1315///
1316/// # Errors
1317///
1318/// Returns [`Error::CorruptObject`] when commit headers are malformed.
1319pub fn read_commit_subject_line(data: &[u8]) -> Result<String> {
1320    let mut pos = 0usize;
1321    let mut encoding: Option<String> = None;
1322    while pos < data.len() {
1323        let line_start = pos;
1324        let mut line_end = pos;
1325        while line_end < data.len() && data[line_end] != b'\n' {
1326            line_end += 1;
1327        }
1328        let line = &data[line_start..line_end];
1329        let after_nl = line_end.saturating_add(1);
1330        if line.is_empty() {
1331            let body = data.get(after_nl..).unwrap_or_default();
1332            let message = commit_encoding::decode_bytes(encoding.as_deref(), body);
1333            return Ok(message
1334                .lines()
1335                .find(|l| !l.trim().is_empty())
1336                .map(str::trim)
1337                .unwrap_or("(no subject)")
1338                .to_owned());
1339        }
1340        if line.first() == Some(&b' ') {
1341            pos = after_nl;
1342            continue;
1343        }
1344        if let Some(rest) = line.strip_prefix(b"encoding ") {
1345            encoding = Some(
1346                std::str::from_utf8(rest)
1347                    .map_err(|_| Error::CorruptObject("encoding is not UTF-8".to_owned()))?
1348                    .trim()
1349                    .to_owned(),
1350            );
1351        }
1352        pos = after_nl;
1353    }
1354    Ok("(no subject)".to_owned())
1355}
1356
1357pub(crate) fn parse_oid_field(value: &[u8]) -> Result<ObjectId> {
1358    let s = std::str::from_utf8(value)
1359        .map_err(|_| Error::CorruptObject("commit header object id is not UTF-8".to_owned()))?;
1360    s.trim()
1361        .parse::<ObjectId>()
1362        .map_err(|_| Error::CorruptObject("invalid object id in commit header".to_owned()))
1363}
1364
1365/// Parse `tree` and `parent` headers from a possibly truncated leading slice of a commit.
1366///
1367/// `prefix` may be cut anywhere (for example a fixed-size inflate of the object start).
1368/// Returns `Some` only when the slice provably holds the complete parent list: the `tree`
1369/// line, every `parent` line, and the start of a following line that cannot be another
1370/// `parent` header. Returns `None` when the slice ends inside that region or the headers are
1371/// not in canonical form; parse the whole object with
1372/// [`parse_commit_tree_and_parents_lenient`] instead, which then yields the same result
1373/// whenever this returns `Some`.
1374pub(crate) fn parse_commit_tree_and_parents_prefix(
1375    prefix: &[u8],
1376    hex_len: usize,
1377) -> Option<(ObjectId, Vec<ObjectId>)> {
1378    canonical_commit_tree_and_parents(prefix, hex_len, false)
1379}
1380
1381/// Parse `tree` and `parent` headers of a whole commit object the way Git's commit parser
1382/// does: a leading `tree` line followed by any number of `parent` lines.
1383///
1384/// Nothing after the parent list is inspected, so a commit missing `author` or `committer`
1385/// (or with other malformed headers) still yields its tree and parents. Returns `None` when
1386/// the first line is not a valid `tree` header or a `parent` line is malformed.
1387pub(crate) fn parse_commit_tree_and_parents_lenient(
1388    data: &[u8],
1389    hex_len: usize,
1390) -> Option<(ObjectId, Vec<ObjectId>)> {
1391    canonical_commit_tree_and_parents(data, hex_len, true)
1392}
1393
1394fn canonical_commit_tree_and_parents(
1395    data: &[u8],
1396    hex_len: usize,
1397    complete: bool,
1398) -> Option<(ObjectId, Vec<ObjectId>)> {
1399    fn header_oid<'a>(rest: &'a [u8], key: &[u8], hex_len: usize) -> Option<(ObjectId, &'a [u8])> {
1400        let line = rest.strip_prefix(key)?;
1401        if line.len() <= hex_len || line[hex_len] != b'\n' {
1402            return None;
1403        }
1404        let oid = ObjectId::from_hex(std::str::from_utf8(&line[..hex_len]).ok()?).ok()?;
1405        Some((oid, &line[hex_len + 1..]))
1406    }
1407    const PARENT: &[u8] = b"parent ";
1408    let (tree, mut rest) = header_oid(data, b"tree ", hex_len)?;
1409    let mut parents = Vec::new();
1410    loop {
1411        if !complete && rest.len() < PARENT.len() && PARENT.starts_with(rest) {
1412            return None;
1413        }
1414        if !rest.starts_with(PARENT) {
1415            return Some((tree, parents));
1416        }
1417        let (parent, next) = header_oid(rest, PARENT, hex_len)?;
1418        parents.push(parent);
1419        rest = next;
1420    }
1421}
1422
1423/// Parse the raw data of a commit object.
1424///
1425/// # Errors
1426///
1427/// Returns [`Error::CorruptObject`] if required headers are missing.
1428pub fn parse_commit(data: &[u8]) -> Result<CommitData> {
1429    Ok(super::CommitRef::parse(data)?.to_owned())
1430}
1431
1432/// Value after `prefix` on the first header line that starts with `prefix`, scanning until a blank
1433/// line (Git tag headers). Returns `None` if no such line exists before the body.
1434#[must_use]
1435pub fn tag_header_field(data: &[u8], prefix: &[u8]) -> Option<String> {
1436    let mut pos = 0usize;
1437    while pos < data.len() {
1438        let rest = &data[pos..];
1439        let nl = rest.iter().position(|&b| b == b'\n');
1440        let line = if let Some(i) = nl { &rest[..i] } else { rest };
1441        if line.is_empty() {
1442            break;
1443        }
1444        if let Some(after) = line.strip_prefix(prefix) {
1445            return Some(String::from_utf8_lossy(after).trim().to_owned());
1446        }
1447        pos += line.len().saturating_add(nl.map(|_| 1).unwrap_or(0));
1448        if nl.is_none() {
1449            break;
1450        }
1451    }
1452    None
1453}
1454
1455/// OID from the first `object <hex>` line in the tag header block, if hex parses.
1456#[must_use]
1457pub fn tag_object_line_oid(data: &[u8]) -> Option<ObjectId> {
1458    let s = tag_header_field(data, b"object ")?;
1459    s.parse().ok()
1460}
1461
1462/// Parsed representation of an annotated tag object.
1463#[derive(Debug, Clone)]
1464pub struct TagData {
1465    /// The object this tag points to.
1466    pub object: ObjectId,
1467    /// The type of the tagged object (e.g. `"commit"`).
1468    pub object_type: String,
1469    /// The short tag name (without `refs/tags/` prefix).
1470    pub tag: String,
1471    /// The tagger identity and timestamp (raw Git format).
1472    pub tagger: Option<String>,
1473    /// Extra header lines after `tagger` and before the blank line (raw bytes, LF-terminated).
1474    pub extra_headers: Vec<u8>,
1475    /// The tag message (everything after the blank line).
1476    pub message: String,
1477}
1478
1479/// Parse the raw data of a tag object.
1480///
1481/// # Errors
1482///
1483/// Returns [`Error::CorruptObject`] if required headers are missing or malformed.
1484///
1485/// When the header block ends at EOF without a blank line, the message is empty (Git accepts this).
1486pub fn parse_tag(data: &[u8]) -> Result<TagData> {
1487    use super::extra_headers::next_header_block;
1488
1489    #[derive(Clone, Copy)]
1490    enum Continuation {
1491        None,
1492        Extra,
1493    }
1494
1495    let mut pos = 0usize;
1496    let mut object = None;
1497    let mut object_type = None;
1498    let mut tag_name = None;
1499    let mut tagger = None;
1500    let mut extra_headers = Vec::new();
1501    let mut cont = Continuation::None;
1502    let mut past_tagger = false;
1503
1504    while pos < data.len() {
1505        let line_start = pos;
1506        let mut line_end = pos;
1507        while line_end < data.len() && data[line_end] != b'\n' {
1508            line_end += 1;
1509        }
1510        let line = &data[line_start..line_end];
1511        let after_nl = (line_end + 1).min(data.len());
1512        if line.is_empty() {
1513            let body = data.get(after_nl..).unwrap_or_default();
1514            let message = std::str::from_utf8(body)
1515                .map_err(|_| Error::CorruptObject("tag message is not valid UTF-8".to_owned()))?
1516                .to_owned();
1517            return Ok(TagData {
1518                object: object
1519                    .ok_or_else(|| Error::CorruptObject("tag missing object header".to_owned()))?,
1520                object_type: object_type
1521                    .ok_or_else(|| Error::CorruptObject("tag missing type header".to_owned()))?,
1522                tag: tag_name
1523                    .ok_or_else(|| Error::CorruptObject("tag missing tag header".to_owned()))?,
1524                tagger,
1525                extra_headers,
1526                message,
1527            });
1528        }
1529
1530        if past_tagger {
1531            let (_, block_end, _) = next_header_block(data, line_start);
1532            extra_headers.extend_from_slice(&data[line_start..block_end]);
1533            pos = block_end;
1534            continue;
1535        }
1536
1537        if line.first() == Some(&b' ') {
1538            match cont {
1539                Continuation::Extra => {
1540                    extra_headers.extend_from_slice(&data[line_start..after_nl]);
1541                }
1542                Continuation::None => {}
1543            }
1544            pos = after_nl;
1545            continue;
1546        }
1547
1548        let key_end = line
1549            .iter()
1550            .position(|&b| b == b' ')
1551            .ok_or_else(|| Error::CorruptObject("malformed tag header line".to_owned()))?;
1552        let key = &line[..key_end];
1553        let rest = line.get(key_end + 1..).unwrap_or_default();
1554
1555        match key {
1556            b"object" => {
1557                let line_str = std::str::from_utf8(rest).map_err(|_| {
1558                    Error::CorruptObject("tag object line is not valid UTF-8".to_owned())
1559                })?;
1560                object = Some(line_str.trim().parse::<ObjectId>()?);
1561                cont = Continuation::None;
1562            }
1563            b"type" => {
1564                let typ = std::str::from_utf8(rest).map_err(|_| {
1565                    Error::CorruptObject("tag type line is not valid UTF-8".to_owned())
1566                })?;
1567                let typ = typ.trim();
1568                if ObjectKind::from_tag_type_field(typ.as_bytes()).is_none() {
1569                    return Err(Error::CorruptObject(format!(
1570                        "invalid 'type' value in tag: {typ}"
1571                    )));
1572                }
1573                object_type = Some(typ.to_owned());
1574                cont = Continuation::None;
1575            }
1576            b"tag" => {
1577                let line_str = std::str::from_utf8(rest).map_err(|_| {
1578                    Error::CorruptObject("tag name line is not valid UTF-8".to_owned())
1579                })?;
1580                tag_name = Some(line_str.trim().to_owned());
1581                cont = Continuation::None;
1582            }
1583            b"tagger" => {
1584                tagger = Some(
1585                    std::str::from_utf8(rest)
1586                        .map_err(|_| {
1587                            Error::CorruptObject("tag tagger line is not valid UTF-8".to_owned())
1588                        })?
1589                        .to_owned(),
1590                );
1591                past_tagger = true;
1592                cont = Continuation::None;
1593            }
1594            _ => {
1595                cont = Continuation::Extra;
1596                extra_headers.extend_from_slice(&data[line_start..after_nl]);
1597            }
1598        }
1599        pos = after_nl;
1600    }
1601
1602    Ok(TagData {
1603        object: object
1604            .ok_or_else(|| Error::CorruptObject("tag missing object header".to_owned()))?,
1605        object_type: object_type
1606            .ok_or_else(|| Error::CorruptObject("tag missing type header".to_owned()))?,
1607        tag: tag_name.ok_or_else(|| Error::CorruptObject("tag missing tag header".to_owned()))?,
1608        tagger,
1609        extra_headers,
1610        message: String::new(),
1611    })
1612}
1613
1614/// Serialize a [`TagData`] into the raw bytes suitable for storage as a tag object.
1615///
1616/// The caller is responsible for supplying a correctly-formatted `tagger` string
1617/// (including timestamp and timezone) when present.
1618#[must_use]
1619pub fn serialize_tag(t: &TagData) -> Vec<u8> {
1620    let mut out = Vec::new();
1621    out.extend_from_slice(format!("object {}\n", t.object).as_bytes());
1622    out.extend_from_slice(format!("type {}\n", t.object_type).as_bytes());
1623    out.extend_from_slice(format!("tag {}\n", t.tag).as_bytes());
1624    if let Some(ref tagger) = t.tagger {
1625        out.extend_from_slice(format!("tagger {tagger}\n").as_bytes());
1626    }
1627    out.extend_from_slice(&t.extra_headers);
1628    out.push(b'\n');
1629    let msg = t.message.trim_end_matches('\n');
1630    if !msg.is_empty() {
1631        out.extend_from_slice(msg.as_bytes());
1632        out.push(b'\n');
1633    }
1634    out
1635}
1636
1637/// Serialize a [`CommitData`] into the raw bytes suitable for storage.
1638///
1639/// The caller is responsible for supplying a correctly-formatted `author` and
1640/// `committer` string (including timestamp and timezone). To create a commit from
1641/// typed identities instead, use [`Objects::write_commit`](crate::objects::Objects::write_commit)
1642/// with a [`NewCommit`](crate::objects::NewCommit).
1643///
1644/// The message body is written exactly as given: `git commit` and `git commit-tree -m`
1645/// supply a trailing LF; `git commit-tree` reading from stdin or `-F` does not add one.
1646#[must_use]
1647pub fn serialize_commit(c: &CommitData) -> Vec<u8> {
1648    let mut out = Vec::new();
1649    out.extend_from_slice(format!("tree {}\n", c.tree).as_bytes());
1650    for p in &c.parents {
1651        out.extend_from_slice(format!("parent {p}\n").as_bytes());
1652    }
1653    out.extend_from_slice(b"author ");
1654    if c.author_raw.is_empty() {
1655        out.extend_from_slice(c.author.as_bytes());
1656    } else {
1657        out.extend_from_slice(&c.author_raw);
1658    }
1659    out.push(b'\n');
1660    out.extend_from_slice(b"committer ");
1661    if c.committer_raw.is_empty() {
1662        out.extend_from_slice(c.committer.as_bytes());
1663    } else {
1664        out.extend_from_slice(&c.committer_raw);
1665    }
1666    out.push(b'\n');
1667    if let Some(enc) = &c.encoding {
1668        out.extend_from_slice(format!("encoding {enc}\n").as_bytes());
1669    }
1670    out.extend_from_slice(&c.extra_headers);
1671    out.push(b'\n');
1672    if let Some(raw) = &c.raw_message {
1673        out.extend_from_slice(raw);
1674    } else if !c.message.is_empty() {
1675        out.extend_from_slice(c.message.as_bytes());
1676    }
1677    out
1678}
1679
1680#[cfg(test)]
1681mod object_id_prefix_tests {
1682    use super::*;
1683
1684    const ID: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
1685
1686    fn id() -> ObjectId {
1687        ObjectId::from_hex(ID).unwrap()
1688    }
1689
1690    fn prefix(hex: &str) -> ObjectIdPrefix {
1691        ObjectIdPrefix::from_hex(hex, HashAlgo::Sha1).unwrap()
1692    }
1693
1694    #[test]
1695    fn matches_every_length_of_the_id_and_rejects_a_changed_last_digit() {
1696        let id = id();
1697        for n in 0..=ID.len() {
1698            let p = prefix(&ID[..n]);
1699            assert_eq!(p.nibbles(), n);
1700            assert_eq!(p.is_empty(), n == 0);
1701            assert!(p.matches(&id), "{n} digits");
1702            assert_eq!(p.to_string(), ID[..n]);
1703            if n > 0 {
1704                let mut changed = ID[..n].to_owned();
1705                let last = changed.pop().unwrap();
1706                changed.push(if last == '0' { '1' } else { '0' });
1707                assert!(!prefix(&changed).matches(&id), "{changed}");
1708            }
1709        }
1710    }
1711
1712    #[test]
1713    fn odd_prefix_ignores_the_low_nibble_of_its_last_byte() {
1714        let p = prefix("4b8");
1715        assert!(p.matches(&ObjectId::from_hex("4b80000000000000000000000000000000000000").unwrap()));
1716        assert!(p.matches(&ObjectId::from_hex("4b8fffffffffffffffffffffffffffffffffffff").unwrap()));
1717        assert!(
1718            !p.matches(&ObjectId::from_hex("4b90000000000000000000000000000000000000").unwrap())
1719        );
1720    }
1721
1722    #[test]
1723    fn parsing_is_case_insensitive_and_validates() {
1724        assert_eq!(prefix("4B825DC"), prefix("4b825dc"));
1725        assert!(matches!(
1726            ObjectIdPrefix::from_hex("4b8g", HashAlgo::Sha1),
1727            Err(Error::InvalidObjectId(_))
1728        ));
1729        assert!(matches!(
1730            ObjectIdPrefix::from_hex(&format!("{ID}0"), HashAlgo::Sha1),
1731            Err(Error::InvalidObjectId(_))
1732        ));
1733        let sha256 = "a".repeat(64);
1734        assert_eq!(
1735            ObjectIdPrefix::from_hex(&sha256, HashAlgo::Sha256)
1736                .unwrap()
1737                .nibbles(),
1738            64
1739        );
1740        assert_eq!(prefix("").algo(), HashAlgo::Sha1);
1741    }
1742
1743    #[test]
1744    fn longer_prefix_never_matches_a_shorter_digest() {
1745        let long = ObjectIdPrefix::from_hex(&format!("{ID}0000"), HashAlgo::Sha256).unwrap();
1746        assert!(!long.matches(&id()));
1747        assert!(ObjectIdPrefix::from_hex(ID, HashAlgo::Sha256)
1748            .unwrap()
1749            .matches(&id()));
1750    }
1751
1752    #[test]
1753    fn bounds_are_the_first_and_last_matching_ids() {
1754        let p = prefix("4b8");
1755        assert_eq!(
1756            p.lowest().to_hex(),
1757            "4b80000000000000000000000000000000000000"
1758        );
1759        assert_eq!(
1760            p.highest().to_hex(),
1761            "4b8fffffffffffffffffffffffffffffffffffff"
1762        );
1763        let even = prefix("4b82");
1764        assert_eq!(
1765            even.lowest().to_hex(),
1766            "4b82000000000000000000000000000000000000"
1767        );
1768        assert_eq!(
1769            even.highest().to_hex(),
1770            "4b82ffffffffffffffffffffffffffffffffffff"
1771        );
1772        assert_eq!(prefix("").lowest(), ObjectId::null(HashAlgo::Sha1));
1773        assert_eq!(prefix("").highest().to_hex(), "f".repeat(40));
1774        assert_eq!(prefix(ID).lowest(), id());
1775        assert_eq!(prefix(ID).highest(), id());
1776        let sha256 = ObjectIdPrefix::from_hex("abc", HashAlgo::Sha256).unwrap();
1777        assert_eq!(sha256.highest().to_hex(), format!("abc{}", "f".repeat(61)));
1778        assert_eq!(format!("{sha256:?}"), "ObjectIdPrefix(abc)");
1779    }
1780
1781    #[test]
1782    fn common_prefix_nibbles_counts_shared_leading_digits() {
1783        let a = id();
1784        let hex = |s: &str| ObjectId::from_hex(s).unwrap();
1785        assert_eq!(a.common_prefix_nibbles(&a), 40);
1786        assert_eq!(
1787            a.common_prefix_nibbles(&hex("4b825dc642cb6eb9a060e54bf8d69288fbee4905")),
1788            39
1789        );
1790        assert_eq!(
1791            a.common_prefix_nibbles(&hex("4b815dc642cb6eb9a060e54bf8d69288fbee4904")),
1792            3
1793        );
1794        assert_eq!(
1795            a.common_prefix_nibbles(&hex("4b925dc642cb6eb9a060e54bf8d69288fbee4904")),
1796            2
1797        );
1798        assert_eq!(
1799            a.common_prefix_nibbles(&hex("5b825dc642cb6eb9a060e54bf8d69288fbee4904")),
1800            0
1801        );
1802        assert_eq!(
1803            a.common_prefix_nibbles(&hex("4c825dc642cb6eb9a060e54bf8d69288fbee4904")),
1804            1
1805        );
1806    }
1807}
1808
1809#[cfg(test)]
1810mod commit_parse_tests {
1811    use super::*;
1812
1813    #[test]
1814    fn empty_tree_constant_is_canonical_sha1_digest() {
1815        assert_eq!(
1816            ObjectId::empty_tree().to_hex(),
1817            "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
1818        );
1819    }
1820
1821    #[test]
1822    fn is_empty_tree_for_matches_hashed_empty_tree() {
1823        for algo in [HashAlgo::Sha1, HashAlgo::Sha256] {
1824            let hashed =
1825                crate::hash::hash_object(algo, ObjectKind::Tree, b"").expect("hash empty tree");
1826            assert!(hashed.is_empty_tree_for(algo), "{algo:?}");
1827            let other = match algo {
1828                HashAlgo::Sha1 => HashAlgo::Sha256,
1829                HashAlgo::Sha256 => HashAlgo::Sha1,
1830            };
1831            assert!(!hashed.is_empty_tree_for(other), "{algo:?}");
1832            let empty_blob =
1833                crate::hash::hash_object(algo, ObjectKind::Blob, b"").expect("hash empty blob");
1834            assert!(!empty_blob.is_empty_tree_for(algo), "{algo:?}");
1835        }
1836        let legacy = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf899d69f7c6948d4").expect("hex");
1837        assert!(legacy.is_empty_tree_for(HashAlgo::Sha1));
1838        assert!(!legacy.is_empty_tree_for(HashAlgo::Sha256));
1839    }
1840
1841    #[test]
1842    fn serialize_tree_mode_matches_mode_str() {
1843        for mode in [
1844            0,
1845            0o7,
1846            0o100644,
1847            0o100755,
1848            0o120000,
1849            0o040000,
1850            0o160000,
1851            u32::MAX,
1852        ] {
1853            let mut out = Vec::new();
1854            push_tree_mode(&mut out, mode);
1855            let entry = TreeEntry {
1856                mode,
1857                name: Vec::new(),
1858                oid: ObjectId::empty_tree(),
1859            };
1860            assert_eq!(out, entry.mode_str().into_bytes(), "mode {mode:o}");
1861        }
1862    }
1863
1864    #[test]
1865    fn is_empty_blob_matches_both_hash_algorithms() {
1866        for algo in [HashAlgo::Sha1, HashAlgo::Sha256] {
1867            let oid = algo.hash_object(ObjectKind::Blob, b"").unwrap();
1868            assert!(oid.is_empty_blob(), "{algo:?} empty blob {}", oid.to_hex());
1869            let other = algo.hash_object(ObjectKind::Blob, b"x").unwrap();
1870            assert!(!other.is_empty_blob());
1871        }
1872        assert!(!ObjectId::empty_tree().is_empty_blob());
1873        assert!(!ObjectId::zero().is_empty_blob());
1874    }
1875
1876    #[test]
1877    fn commit_subject_pretty_folds_first_paragraph() {
1878        assert_eq!(commit_subject_pretty("hello\nworld\n\nbody"), "hello world");
1879        assert_eq!(commit_subject_pretty(""), "");
1880        assert_eq!(
1881            commit_subject_pretty("  hello   world\tthere  \n  next\t line  \n\nbody\n"),
1882            "  hello   world\tthere   next\t line"
1883        );
1884    }
1885
1886    #[test]
1887    fn commit_ref_matches_parse_commit_headers() {
1888        let raw = concat!(
1889            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
1890            "parent 1111111111111111111111111111111111111111\n",
1891            "author A <a@example.com> 1 +0000\n",
1892            "committer C <c@example.com> 2 +0000\n",
1893            "\n",
1894            "ignored body\n",
1895        );
1896        let full = parse_commit(raw.as_bytes()).expect("parse");
1897        let link = crate::objects::CommitRef::parse(raw.as_bytes()).expect("CommitRef");
1898        assert_eq!(link.tree(), full.tree);
1899        assert_eq!(link.parents(), full.parents.as_slice());
1900        assert_eq!(link.author(), full.author);
1901        assert_eq!(link.committer(), full.committer);
1902    }
1903
1904    #[test]
1905    fn parse_commit_skips_multiline_gpgsig_continuation() {
1906        let raw = concat!(
1907            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
1908            "author A U Thor <author@example.com> 1 +0000\n",
1909            "committer C O Mitter <committer@example.com> 1 +0000\n",
1910            "gpgsig -----BEGIN PGP SIGNATURE-----\n",
1911            " abcdef\n",
1912            " -----END PGP SIGNATURE-----\n",
1913            "\n",
1914            "msg\n",
1915        );
1916        let c = parse_commit(raw.as_bytes()).expect("parse signed commit");
1917        assert_eq!(c.tree.to_hex(), "4b825dc642cb6eb9a060e54bf8d69288fbee4904");
1918        assert_eq!(c.message, "msg\n");
1919        assert_eq!(serialize_commit(&c), raw.as_bytes());
1920    }
1921
1922    #[test]
1923    fn hex_parsing_accepts_both_cases_and_lengths_and_rejects_the_rest() {
1924        let sha1 = "0123456789abcdefABCDEF0123456789abcdef01";
1925        let oid = ObjectId::from_hex(sha1).unwrap();
1926        assert_eq!(oid.algo(), HashAlgo::Sha1);
1927        assert_eq!(oid.to_hex(), sha1.to_ascii_lowercase());
1928        assert_eq!(ObjectId::from_hex_ascii(sha1.as_bytes()).unwrap(), oid);
1929        assert_eq!(sha1.parse::<ObjectId>().unwrap(), oid);
1930
1931        let sha256 = "f".repeat(64);
1932        let oid = sha256.parse::<ObjectId>().unwrap();
1933        assert_eq!(oid.algo(), HashAlgo::Sha256);
1934        assert_eq!(oid.as_bytes(), [0xff; 32]);
1935        assert!(ObjectId::from_hex_ascii(sha256.as_bytes()).is_err());
1936
1937        for bad in [
1938            "",
1939            &sha1[..39],
1940            &format!("{sha1}0"),
1941            "g123456789abcdef0123456789abcdef01234567",
1942            "0123456789abcdef0123456789abcdef0123456 ",
1943            "0123456789abcdef0123456789abcdef012345/7",
1944            "0123456789abcdef0123456789abcdef012345:7",
1945            "0123456789abcdef0123456789abcdef012345`7",
1946            "0123456789abcdef0123456789abcdef012345G7",
1947        ] {
1948            assert!(
1949                matches!(ObjectId::from_hex(bad), Err(Error::InvalidObjectId(s)) if s == bad),
1950                "{bad:?}"
1951            );
1952            assert!(bad.parse::<ObjectId>().is_err(), "{bad:?}");
1953            assert!(ObjectId::from_hex_ascii(bad.as_bytes()).is_err(), "{bad:?}");
1954        }
1955        let mut non_ascii = sha1.as_bytes().to_vec();
1956        non_ascii[3] = 0xb0;
1957        assert!(ObjectId::from_hex_ascii(&non_ascii).is_err());
1958    }
1959
1960    #[test]
1961    fn hex_and_display_cover_every_byte_for_both_lengths() {
1962        let all: Vec<u8> = (0..=255).collect();
1963        for width in [20, 32] {
1964            for chunk in all.chunks(width).filter(|c| c.len() == width) {
1965                let oid = ObjectId::from_bytes(chunk).unwrap();
1966                let expected: String = chunk.iter().map(|b| format!("{b:02x}")).collect();
1967                assert_eq!(oid.to_hex(), expected);
1968                assert_eq!(oid.to_string(), expected);
1969                assert_eq!(format!("{oid:?}"), format!("ObjectId({expected})"));
1970                assert_eq!(ObjectId::from_hex(&expected).unwrap(), oid);
1971            }
1972        }
1973    }
1974
1975    #[test]
1976    fn serialize_commit_applies_public_field_edits_after_parse() {
1977        let raw = concat!(
1978            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
1979            "author A <a@example.com> 1 +0000\n",
1980            "committer C <c@example.com> 1 +0000\n",
1981            "\n",
1982            "msg\n",
1983        );
1984        let mut commit = parse_commit(raw.as_bytes()).expect("parse");
1985        let zero = ObjectId::from_hex("0000000000000000000000000000000000000000").unwrap();
1986        commit.tree = zero;
1987        let serialized = serialize_commit(&commit);
1988        let tree_line = format!("tree {}\n", zero.to_hex());
1989        assert!(
1990            serialized.starts_with(tree_line.as_bytes()),
1991            "serialize_commit ignored the updated public tree field"
1992        );
1993    }
1994
1995    #[test]
1996    fn parse_commit_preserves_gpgsig_mergetag_bytes_exactly() {
1997        let raw = concat!(
1998            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
1999            "author A <a@example.com> 1 +0000\n",
2000            "committer C <c@example.com> 1 +0000\n",
2001            "encoding ISO-8859-1\n",
2002            "gpgsig -----BEGIN PGP SIGNATURE-----\n",
2003            " line2\n",
2004            " -----END PGP SIGNATURE-----\n",
2005            "mergetag object 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
2006            " type commit\n",
2007            " tag merge-tag\n",
2008            " tagger T <t@example.com> 1 +0000\n",
2009            " -----BEGIN PGP SIGNATURE-----\n",
2010            " inner\n",
2011            " -----END PGP SIGNATURE-----\n",
2012            "\n",
2013            "msg-only\n",
2014        );
2015        let parsed = parse_commit(raw.as_bytes()).expect("parse");
2016        assert_eq!(serialize_commit(&parsed), raw.as_bytes());
2017    }
2018
2019    #[test]
2020    fn tree_entry_mode_str_and_tag_object_line_oid() {
2021        let oid = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap();
2022        let entry = TreeEntry {
2023            mode: 0o040000,
2024            name: b"dir".to_vec(),
2025            oid,
2026        };
2027        assert_eq!(entry.mode_str(), "40000");
2028        let tag_body = format!("object {}\ntype commit\ntag t\n\n", oid.to_hex());
2029        assert_eq!(tag_object_line_oid(tag_body.as_bytes()), Some(oid));
2030    }
2031
2032    #[test]
2033    fn parse_commit_non_commit_bytes_does_not_panic() {
2034        let tree_bytes = b"100644 blob 0123456789012345678901234567890123456789\tf";
2035        let err = parse_commit(tree_bytes).expect_err("tree bytes are not a commit");
2036        assert!(matches!(err, Error::CorruptObject(_)));
2037    }
2038
2039    #[test]
2040    fn parse_tree_with_sha256_oid_width() {
2041        let oid_bytes = [0x6eu8; 32];
2042        let oid = ObjectId::from_bytes(&oid_bytes).expect("oid");
2043        let mut raw = b"100644 file\0".to_vec();
2044        raw.extend_from_slice(oid.as_bytes());
2045        let entries = parse_tree_with_oid_len(&raw, HashAlgo::Sha256.len()).expect("tree");
2046        assert_eq!(entries.len(), 1);
2047        assert_eq!(entries[0].oid, oid);
2048    }
2049
2050    #[test]
2051    fn parse_commit_tree_and_parents_prefix_matches_full_parse_or_declines() -> Result<()> {
2052        let tree = "1111111111111111111111111111111111111111";
2053        let p1 = "2222222222222222222222222222222222222222";
2054        let p2 = "3333333333333333333333333333333333333333";
2055        let raw = format!(
2056            "tree {tree}\nparent {p1}\nparent {p2}\nauthor A <a@x> 1 +0000\n\
2057             committer C <c@x> 2 +0000\n\nmessage\n"
2058        );
2059        let commit = parse_commit(raw.as_bytes())?;
2060        let full = (commit.tree, commit.parents);
2061        assert_eq!(full.1.len(), 2);
2062        assert_eq!(
2063            parse_commit_tree_and_parents_lenient(raw.as_bytes(), HashAlgo::Sha1.hex_len()),
2064            Some(full.clone())
2065        );
2066        let hex_len = HashAlgo::Sha1.hex_len();
2067        let header_end = raw.find("author").expect("author line");
2068        for cut in 0..=raw.len() {
2069            let got = parse_commit_tree_and_parents_prefix(&raw.as_bytes()[..cut], hex_len);
2070            if cut > header_end {
2071                assert_eq!(got.as_ref(), Some(&full), "cut at {cut}");
2072            } else {
2073                assert_eq!(
2074                    got, None,
2075                    "cut at {cut} must not claim a complete parent list"
2076                );
2077            }
2078        }
2079        Ok(())
2080    }
2081
2082    #[test]
2083    fn commit_tree_and_parents_prefix_never_disagrees_with_whole_object() {
2084        let tree = "tree 1111111111111111111111111111111111111111\n";
2085        let parent = "parent 2222222222222222222222222222222222222222\n";
2086        let parent2 = "parent 3333333333333333333333333333333333333333\n";
2087        let bodies = [
2088            format!("{tree}{parent}author A <a@x> 1 +0000\n\nmessage\n"),
2089            format!("{tree}{parent}{parent2}committer C <c@x> 2 +0000\n\nno author\n"),
2090            format!("{tree}{parent}"),
2091            format!("{tree}{parent}{parent2}"),
2092            format!("{tree}author A <a@x> 1 +0000\n{parent}\nlate parent is ignored\n"),
2093            format!("{tree}{parent}not a header\n"),
2094            format!("{tree}parent zz\n\n"),
2095            format!("{tree}{parent}parent 2222"),
2096            format!("author A <a@x> 1 +0000\n{tree}\n"),
2097        ];
2098        let hex_len = HashAlgo::Sha1.hex_len();
2099        for body in &bodies {
2100            let whole = parse_commit_tree_and_parents_lenient(body.as_bytes(), hex_len);
2101            for cut in 0..=body.len() {
2102                if let Some(got) =
2103                    parse_commit_tree_and_parents_prefix(&body.as_bytes()[..cut], hex_len)
2104                {
2105                    assert_eq!(Some(got), whole, "cut {cut} of {body:?}");
2106                }
2107            }
2108        }
2109        let parents_of = |body: &str| {
2110            parse_commit_tree_and_parents_lenient(body.as_bytes(), hex_len).map(|(_, p)| p.len())
2111        };
2112        assert_eq!(parents_of(&bodies[1]), Some(2), "missing author is fine");
2113        assert_eq!(
2114            parents_of(&bodies[3]),
2115            Some(2),
2116            "object may end after parents"
2117        );
2118        assert_eq!(
2119            parents_of(&bodies[4]),
2120            Some(0),
2121            "only leading parents count"
2122        );
2123        assert_eq!(parents_of(&bodies[6]), None, "malformed parent line");
2124        assert_eq!(parents_of(&bodies[7]), None, "truncated parent line");
2125        assert_eq!(parents_of(&bodies[8]), None, "tree must come first");
2126    }
2127
2128    #[test]
2129    fn parse_commit_tree_and_parents_prefix_handles_root_and_sha256() -> Result<()> {
2130        let root = b"tree 1111111111111111111111111111111111111111\nauthor A <a@x> 1 +0000\n";
2131        let (tree, parents) =
2132            parse_commit_tree_and_parents_prefix(root, HashAlgo::Sha1.hex_len()).expect("root");
2133        assert_eq!(tree.to_hex(), "1111111111111111111111111111111111111111");
2134        assert!(parents.is_empty());
2135
2136        let t = "a".repeat(64);
2137        let p = "b".repeat(64);
2138        let raw = format!("tree {t}\nparent {p}\ncommitter C <c@x> 2 +0000\n");
2139        let (tree, parents) =
2140            parse_commit_tree_and_parents_prefix(raw.as_bytes(), HashAlgo::Sha256.hex_len())
2141                .expect("sha256");
2142        assert_eq!(tree.to_hex(), t);
2143        assert_eq!(parents.len(), 1);
2144        assert_eq!(parents[0].to_hex(), p);
2145
2146        assert!(parse_commit_tree_and_parents_prefix(raw.as_bytes(), 40).is_none());
2147        assert!(parse_commit_tree_and_parents_prefix(b"parent x\n", 40).is_none());
2148        assert!(parse_commit_tree_and_parents_prefix(
2149            b"tree zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\nauthor\n",
2150            40
2151        )
2152        .is_none());
2153        Ok(())
2154    }
2155
2156    #[test]
2157    fn object_id_loose_path_helpers() {
2158        let oid = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap();
2159        assert_eq!(oid.loose_prefix(), "4b");
2160        assert_eq!(oid.loose_suffix().len(), 38);
2161        assert!(ObjectId::is_hex_len(40));
2162        assert!(ObjectId::is_loose_suffix_len(38));
2163    }
2164
2165    #[test]
2166    fn tree_ref_iter_yields_at_most_one_error() {
2167        let bad = b"100644 incomplete";
2168        let mut iter = TreeRefIter::with_oid_len(bad, HashAlgo::Sha1.len());
2169        assert!(iter.next().unwrap().is_err());
2170        assert!(iter.next().is_none());
2171    }
2172
2173    #[test]
2174    fn tree_ref_iter_public_api_smoke() {
2175        let data = serialize_tree(&[TreeEntry {
2176            mode: 0o100644,
2177            name: b"a".to_vec(),
2178            oid: ObjectId::from_hex("0123456789012345678901234567890123456789").unwrap(),
2179        }]);
2180        let mut iter = TreeRefIter::infer_oid_len(&data).expect("infer");
2181        let entry = iter.next().unwrap().expect("entry");
2182        assert_eq!(entry.name, b"a");
2183        let _owned = entry.to_owned();
2184        let _vec = parse_tree(&data).expect("parse");
2185        let _ = TreeRefIter::with_oid_len(&data, HashAlgo::Sha1.len());
2186    }
2187
2188    #[test]
2189    fn find_tree_entry_by_name_in_data_locates_directory_in_large_tree() -> Result<()> {
2190        let dummy = ObjectId::from_hex("0123456789012345678901234567890123456789")?;
2191        let mut entries: Vec<TreeEntry> = (1..=60)
2192            .map(|i| TreeEntry {
2193                mode: 0o100644,
2194                name: format!("f{i}.txt").into_bytes(),
2195                oid: dummy,
2196            })
2197            .collect();
2198        entries.push(TreeEntry {
2199            mode: 0o040000,
2200            name: b"sub".to_vec(),
2201            oid: dummy,
2202        });
2203        entries.sort_by(|a, b| {
2204            tree_entry_cmp(&a.name, a.mode == 0o040000, &b.name, b.mode == 0o040000)
2205        });
2206        let data = serialize_tree(&entries);
2207        assert!(entries.len() >= 48);
2208        let found = find_tree_entry_by_name_in_data(&data, HashAlgo::Sha1.len(), b"sub", true)?
2209            .expect("sub directory in wide tree");
2210        assert_eq!(found.mode, 0o040000);
2211        assert_eq!(found.name, b"sub");
2212        Ok(())
2213    }
2214
2215    fn sorted_tree_data(entries: &mut [TreeEntry]) -> Vec<u8> {
2216        entries.sort_by(|a, b| {
2217            tree_entry_cmp(&a.name, a.mode == 0o040000, &b.name, b.mode == 0o040000)
2218        });
2219        serialize_tree(entries)
2220    }
2221
2222    #[test]
2223    fn find_tree_entry_by_name_in_data_matches_parse_tree_for_every_name() -> Result<()> {
2224        let oid_a = ObjectId::from_hex("1111111111111111111111111111111111111111")?;
2225        let oid_b = ObjectId::from_hex("2222222222222222222222222222222222222222")?;
2226        let mut entries = vec![
2227            TreeEntry {
2228                mode: 0o040000,
2229                name: b"foo".to_vec(),
2230                oid: oid_a,
2231            },
2232            TreeEntry {
2233                mode: 0o100644,
2234                name: b"foo.c".to_vec(),
2235                oid: oid_b,
2236            },
2237            TreeEntry {
2238                mode: 0o100755,
2239                name: b"foo-bar".to_vec(),
2240                oid: oid_b,
2241            },
2242            TreeEntry {
2243                mode: 0o120000,
2244                name: b"link".to_vec(),
2245                oid: oid_a,
2246            },
2247            TreeEntry {
2248                mode: 0o160000,
2249                name: b"sub".to_vec(),
2250                oid: oid_b,
2251            },
2252            TreeEntry {
2253                mode: 0o100644,
2254                name: b"README".to_vec(),
2255                oid: oid_a,
2256            },
2257        ];
2258        let data = sorted_tree_data(&mut entries);
2259        for expected in parse_tree(&data)? {
2260            let name_is_tree = expected.mode == 0o040000;
2261            let found = find_tree_entry_by_name_in_data(
2262                &data,
2263                HashAlgo::Sha1.len(),
2264                &expected.name,
2265                name_is_tree,
2266            )?
2267            .expect("entry present");
2268            assert_eq!(found, expected);
2269        }
2270        for missing in [&b"fo"[..], b"foo/", b"foo.d", b"zzz", b"", b"AAA", b"lin"] {
2271            assert!(
2272                find_tree_entry_by_name_in_data(&data, HashAlgo::Sha1.len(), missing, false)?
2273                    .is_none(),
2274                "unexpected match for {:?}",
2275                String::from_utf8_lossy(missing)
2276            );
2277        }
2278        Ok(())
2279    }
2280
2281    #[test]
2282    fn find_tree_entry_by_name_in_data_stops_after_sorted_position() -> Result<()> {
2283        let oid = ObjectId::from_hex("3333333333333333333333333333333333333333")?;
2284        let mut entries = vec![
2285            TreeEntry {
2286                mode: 0o100644,
2287                name: b"a".to_vec(),
2288                oid,
2289            },
2290            TreeEntry {
2291                mode: 0o100644,
2292                name: b"c".to_vec(),
2293                oid,
2294            },
2295        ];
2296        let mut data = sorted_tree_data(&mut entries);
2297        data.extend_from_slice(b"garbage without separators");
2298        let found = find_tree_entry_by_name_in_data(&data, HashAlgo::Sha1.len(), b"a", false)?;
2299        assert_eq!(found.map(|e| e.name), Some(b"a".to_vec()));
2300        assert!(
2301            find_tree_entry_by_name_in_data(&data, HashAlgo::Sha1.len(), b"b", false)?.is_none()
2302        );
2303        assert!(matches!(
2304            find_tree_entry_by_name_in_data(&data, HashAlgo::Sha1.len(), b"d", false),
2305            Err(Error::CorruptObject(_))
2306        ));
2307        Ok(())
2308    }
2309
2310    #[test]
2311    fn find_tree_entry_by_name_in_data_rejects_bad_mode_and_truncated_oid() {
2312        let bad_mode = b"10x644 a\0aaaaaaaaaaaaaaaaaaaa";
2313        assert!(matches!(
2314            find_tree_entry_by_name_in_data(bad_mode, HashAlgo::Sha1.len(), b"a", false),
2315            Err(Error::CorruptObject(_))
2316        ));
2317        let truncated = b"100644 a\0short";
2318        assert!(matches!(
2319            find_tree_entry_by_name_in_data(truncated, HashAlgo::Sha1.len(), b"a", false),
2320            Err(Error::CorruptObject(_))
2321        ));
2322    }
2323
2324    #[test]
2325    fn find_tree_entry_by_name_in_data_reads_sha256_width() -> Result<()> {
2326        let oid = ObjectId::from_bytes(&[0x5au8; 32])?;
2327        let mut raw = b"100644 file\0".to_vec();
2328        raw.extend_from_slice(oid.as_bytes());
2329        let found = find_tree_entry_by_name_in_data(&raw, HashAlgo::Sha256.len(), b"file", false)?
2330            .expect("file");
2331        assert_eq!(found.oid, oid);
2332        assert_eq!(found.mode, 0o100644);
2333        Ok(())
2334    }
2335}