Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-store/src/object.rs

//! **The git object decoder that pairs with [`GitServe::read`].**
//!
//! [`GitServe::read`] answers `(ObjType, Vec<u8>)`: the *resolved* type, and
//! the object's **undecorated payload** — no `"commit 1234\0"` header, no zlib
//! frame, no delta. What those bytes mean is not the storage backend's
//! business and the contract does not say; it is git's object format, and this
//! module is where gunnar reads it.
//!
//! # Why this is not `gix-object`
//!
//! It was, until 2026-08-10. `gix-object` is a complete, correct object model —
//! and it is also seven crates (`gix-object`, `gix-actor`, `gix-date`,
//! `gix-error`, `gix-hashtable`, `gix-utils`, `gix-validate`) in every build of
//! the appliance, for four grammars gunnar reads and never writes. What gunnar
//! actually asks of it is small enough to list:
//!
//! * a commit's `tree` and its `parent`s — the traversal, once per commit in
//!   the repository;
//! * a commit's committer timestamp — negotiation's ordering, and nothing else
//!   depends on it being right;
//! * a tag's `object` — peeling;
//! * a tree's entries as `(oid, kind)` — the closure walk;
//! * the whole of a commit, once, for the control plane's `ListCommits`.
//!
//! That is this file. It is a **reader**: nothing here encodes an object, and
//! nothing here validates one beyond what it must to answer the question asked
//! of it. Object *writing* stays in `gunnar-pack`, on the client side of the
//! fence, where `gix-object` is still the right answer.
//!
//! # What "tolerant" means here
//!
//! Two deliberate leniencies, both matching what the call sites already relied
//! on:
//!
//! * **An unparsable signature time is `0`, not an error.** A commit with a
//!   broken committer date costs the walk its ordering for that one commit; it
//!   is not a reason to fail a clone. `graph.rs`'s `commit_facts_into` said so
//!   in a comment before this module existed, and `control_plane.rs` spelled it
//!   `.unwrap_or_default()`.
//! * **Unknown headers are skipped, including `gpgsig`'s continuation lines.**
//!   A continuation line begins with a space, so it can never be mistaken for a
//!   `parent ` or a `committer ` line, and the scanner does not have to
//!   understand folding to be safe against it.
//! * **A commit or tag with no blank line is all headers and no message.**
//!   `gix-object` rejects that object outright; this module reads it. Nothing
//!   here writes an object, so no malformation can be inherited from the
//!   lenient reading, and on the serve path the bytes are copied into the pack
//!   verbatim whatever the decoder concluded. Refusing would turn one odd
//!   object in a repository's history into a failed clone of the whole
//!   repository. `tests/object_decode.rs` pins the divergence in both
//!   directions, so it cannot drift unnoticed.
//!
//! Everything else is an error: a missing `tree`, a short or non-hex oid, a
//! tree entry that runs off the end of the buffer, a mode that is not octal.
//!
//! [`GitServe::read`]: git_storage_trait::GitServe::read

use gix_hash::{Kind as HashKind, ObjectId};

/// The four **resolved** git object types.
///
/// The contract's [`ObjType`] has six codes — these four plus the two delta
/// encodings, which are a property of how an object was *stored* and cannot
/// come back out of `read` or `header`. [`Kind::resolved`] is the one place
/// that conversion happens, and it refuses the delta codes rather than
/// silently mapping them onto something.
///
/// [`ObjType`]: git_storage_trait::ObjType
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Kind {
    /// A directory listing.
    Tree,
    /// File content.
    Blob,
    /// A commit.
    Commit,
    /// An annotated tag.
    Tag,
}

impl Kind {
    /// The contract's resolved type as an object kind, or `None` for a delta
    /// code.
    ///
    /// `None` means the backend broke its own contract: `GitServe::read` and
    /// `GitServe::header` are defined to answer the resolved type. The caller
    /// decides what to say about that, because only the caller knows which
    /// object it was asking for.
    pub fn resolved(ty: git_storage_trait::ObjType) -> Option<Self> {
        use git_storage_trait::ObjType;
        Some(match ty {
            ObjType::Commit => Kind::Commit,
            ObjType::Tree => Kind::Tree,
            ObjType::Blob => Kind::Blob,
            ObjType::Tag => Kind::Tag,
            _ => return None,
        })
    }

    /// The same four names git writes into a loose object header and a tag's
    /// `type` line.
    pub fn as_str(self) -> &'static str {
        match self {
            Kind::Tree => "tree",
            Kind::Blob => "blob",
            Kind::Commit => "commit",
            Kind::Tag => "tag",
        }
    }

    /// Parse the spelling `as_str` produces.
    pub fn from_bytes(b: &[u8]) -> Option<Self> {
        Some(match b {
            b"tree" => Kind::Tree,
            b"blob" => Kind::Blob,
            b"commit" => Kind::Commit,
            b"tag" => Kind::Tag,
            _ => return None,
        })
    }
}

impl std::fmt::Display for Kind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

impl From<Kind> for git_storage_trait::ObjType {
    fn from(k: Kind) -> Self {
        use git_storage_trait::ObjType;
        match k {
            Kind::Commit => ObjType::Commit,
            Kind::Tree => ObjType::Tree,
            Kind::Blob => ObjType::Blob,
            Kind::Tag => ObjType::Tag,
        }
    }
}

/// What a tree entry's mode says the entry *is*.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntryKind {
    /// A subtree.
    Tree,
    /// A regular file.
    Blob,
    /// A regular file with the execute bit.
    BlobExecutable,
    /// A symbolic link.
    Link,
    /// A gitlink: a commit id naming an object in *another* repository.
    Commit,
}

/// One decoded tree entry.
///
/// The filename is deliberately **not** here. The callers this type was written
/// for walk a tree for its `(oid, kind)` pairs — the closure, the type index,
/// the blob filter — and none of them names a path, so carrying the name would
/// have cost an allocation per entry for nothing.
///
/// A caller that *does* need names — `Refs.Diff` reports paths — takes
/// [`NamedTreeEntry`] from [`TreeIter::named`] instead. It borrows the name out
/// of the caller's own buffer, so it still allocates nothing; what it costs is
/// a lifetime, which is why it is a second type rather than two more fields
/// here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TreeEntry {
    /// The object this entry points at.
    pub oid: ObjectId,
    /// What the mode says it is.
    pub kind: EntryKind,
}

/// A [`TreeEntry`] with the name it is filed under and its raw mode.
///
/// The name is a **borrow of the tree object's own bytes**: git stores it
/// inline, so there is nothing to copy and no encoding to decide. It is `[u8]`
/// and not `str` for the same reason [`Signature`]'s fields are raw — git does
/// not say a path is UTF-8, and this module is not the place that gets to
/// pretend otherwise.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NamedTreeEntry<'a> {
    /// The object this entry points at.
    pub oid: ObjectId,
    /// What the mode says it is.
    pub kind: EntryKind,
    /// The entry's name within its tree. Never contains `/` and never empty.
    pub name: &'a [u8],
    /// The mode exactly as written, e.g. `0o100644`.
    ///
    /// [`EntryKind`] is the *interpretation*; this is the value. A diff has to
    /// report a file that only changed its execute bit, and the interpretation
    /// alone cannot tell `100644` from `100755` after the fact.
    pub mode: u32,
}

/// A name, an address, and a moment: git's `author` / `committer` / `tagger`.
///
/// The two byte fields are **raw**. git does not say a name is UTF-8 and this
/// module does not decide it is; the control plane lossily converts at its own
/// boundary, which is where a lossy conversion belongs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signature {
    /// The name, as written, without the trailing space before `<`.
    pub name: Vec<u8>,
    /// The address, without the surrounding angle brackets.
    pub email: Vec<u8>,
    /// Seconds since the unix epoch, or `0` if the line had no readable time.
    pub seconds: i64,
    /// The timezone offset in **seconds** east of UTC, or `0`.
    pub offset_seconds: i32,
}

/// A whole commit, decoded.
///
/// Built only by [`commit`], which exists for the control plane's
/// `ListCommits`. The traversal does not use it: [`commit_links`] and
/// [`commit_facts`] stop reading as soon as they have their answer, and on a
/// million-commit walk that difference is the point.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
    /// The root tree.
    pub tree: ObjectId,
    /// The parents, in order.
    pub parents: Vec<ObjectId>,
    /// Who wrote it.
    pub author: Signature,
    /// Who committed it.
    pub committer: Signature,
    /// Everything after the blank line that ends the headers.
    pub message: Vec<u8>,
}

/// What a tag object points at.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tag {
    /// The `object` line.
    pub target: ObjectId,
    /// The `type` line, if it named one of the four kinds.
    pub target_kind: Option<Kind>,
}

// ───────────────────────────────────────────────────────────────────────────
// The error
// ───────────────────────────────────────────────────────────────────────────

/// A git object that does not decode.
///
/// One type for all four grammars, because every caller does the same thing
/// with it: names the oid it was reading and reports a corrupt object. The
/// message says which field failed, which is the part a human needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodeError {
    kind: Kind,
    what: &'static str,
}

impl DecodeError {
    fn new(kind: Kind, what: &'static str) -> Self {
        DecodeError { kind, what }
    }

    /// Which grammar was being read.
    pub fn object_kind(&self) -> Kind {
        self.kind
    }
}

impl std::fmt::Display for DecodeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "malformed {}: {}", self.kind, self.what)
    }
}

impl std::error::Error for DecodeError {}

/// The result of every decode in this module.
pub type DecodeResult<T> = std::result::Result<T, DecodeError>;

// ───────────────────────────────────────────────────────────────────────────
// Header-line scanning, shared by commit and tag
// ───────────────────────────────────────────────────────────────────────────

/// Split `bytes` at the blank line that ends the header block.
///
/// Returns `(headers, body)`. A commit or tag with no blank line is all
/// headers and an empty body, which is what git's own readers do with a
/// message-less object.
fn split_headers(bytes: &[u8]) -> (&[u8], &[u8]) {
    let mut i = 0;
    while i < bytes.len() {
        match memchr(b'\n', &bytes[i..]) {
            // A `\n` immediately followed by another `\n`: the blank line.
            Some(rel) => {
                let nl = i + rel;
                if bytes.get(nl + 1) == Some(&b'\n') {
                    return (&bytes[..nl + 1], &bytes[nl + 2..]);
                }
                i = nl + 1;
            }
            None => break,
        }
    }
    (bytes, &[])
}

/// Iterate the `\n`-terminated lines of a header block, without their `\n`.
struct HeaderLines<'a> {
    rest: &'a [u8],
}

impl<'a> Iterator for HeaderLines<'a> {
    type Item = &'a [u8];

    fn next(&mut self) -> Option<&'a [u8]> {
        if self.rest.is_empty() {
            return None;
        }
        match memchr(b'\n', self.rest) {
            Some(nl) => {
                let line = &self.rest[..nl];
                self.rest = &self.rest[nl + 1..];
                Some(line)
            }
            None => {
                let line = self.rest;
                self.rest = &[];
                Some(line)
            }
        }
    }
}

fn header_lines(headers: &[u8]) -> HeaderLines<'_> {
    HeaderLines { rest: headers }
}

/// `bytes[..]`'s first `needle`, as an index.
///
/// A three-line loop rather than the `memchr` crate: this module's whole
/// argument is that it costs the appliance no dependencies, and the buffers it
/// scans are one object each. `slice::iter().position()` compiles to a
/// vectorised byte search on every target gunnar builds for.
#[inline]
fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
    haystack.iter().position(|&b| b == needle)
}

/// `line` with `prefix` removed, if it is there.
#[inline]
fn strip<'a>(line: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
    line.strip_prefix(prefix)
}

/// Decode one hex oid, rejecting the wrong length as firmly as a bad digit.
#[inline]
fn hex_oid(field: &[u8], hash: HashKind, kind: Kind, what: &'static str) -> DecodeResult<ObjectId> {
    if field.len() != hash.len_in_hex() {
        return Err(DecodeError::new(kind, what));
    }
    ObjectId::from_hex(field).map_err(|_| DecodeError::new(kind, what))
}

// ───────────────────────────────────────────────────────────────────────────
// Signatures
// ───────────────────────────────────────────────────────────────────────────

/// `Name <email> 1723200000 +0200`.
///
/// The split is on the **last** `>` in the line, not the first: a name may
/// contain angle brackets and git does not escape them, so scanning forwards
/// finds the wrong one. Everything after that `>` is `<seconds> <offset>`, and
/// either may be missing — see the module docs on why that is a `0` and not an
/// error.
fn signature(line: &[u8]) -> Signature {
    let close = line.iter().rposition(|&b| b == b'>');
    let (name_email, tail) = match close {
        Some(at) => (&line[..at], &line[at + 1..]),
        None => (line, &line[line.len()..]),
    };
    let open = name_email.iter().position(|&b| b == b'<');
    let (name, email) = match open {
        Some(at) => (&name_email[..at], &name_email[at + 1..]),
        None => (name_email, &name_email[name_email.len()..]),
    };

    let mut fields = tail.split(|&b| b == b' ').filter(|f| !f.is_empty());
    let seconds = fields
        .next()
        .and_then(|f| std::str::from_utf8(f).ok())
        .and_then(|s| s.parse::<i64>().ok())
        .unwrap_or(0);
    let offset_seconds = fields.next().map(tz_offset).unwrap_or(0);

    Signature {
        name: trim_ascii_end(name).to_vec(),
        email: email.to_vec(),
        seconds,
        offset_seconds,
    }
}

/// `+0200` / `-0730` as seconds east of UTC.
///
/// The minutes field is **two digits taken as minutes**, not a base-100 number:
/// `-0730` is minus seven and a half hours, and reading it as `-730 * 60` is
/// the classic way to be twelve hours wrong twice a year.
fn tz_offset(field: &[u8]) -> i32 {
    let (sign, digits) = match field.first() {
        Some(b'-') => (-1, &field[1..]),
        Some(b'+') => (1, &field[1..]),
        _ => (1, field),
    };
    if digits.len() != 4 || !digits.iter().all(|b| b.is_ascii_digit()) {
        return 0;
    }
    let hours = (digits[0] - b'0') as i32 * 10 + (digits[1] - b'0') as i32;
    let minutes = (digits[2] - b'0') as i32 * 10 + (digits[3] - b'0') as i32;
    sign * (hours * 3600 + minutes * 60)
}

#[inline]
fn trim_ascii_end(mut b: &[u8]) -> &[u8] {
    while let Some((&last, rest)) = b.split_last() {
        if last.is_ascii_whitespace() {
            b = rest;
        } else {
            break;
        }
    }
    b
}

// ───────────────────────────────────────────────────────────────────────────
// Commits
// ───────────────────────────────────────────────────────────────────────────

/// **A commit's tree and parents, and nothing else read.**
///
/// The hot one: this runs once per commit in the repository on every clone
/// that is not served from a bitmap. git's commit grammar is `tree`, then
/// `parent`*, then `author` — so the scan stops at the first line that is not
/// a parent, and the two signatures, the encoding, the extra headers and the
/// message are never looked at.
///
/// `parents` is cleared and refilled. It belongs to the caller so that a walk
/// is one allocation and not one per commit.
pub fn commit_links(
    bytes: &[u8],
    hash: HashKind,
    parents: &mut Vec<ObjectId>,
) -> DecodeResult<ObjectId> {
    let (headers, _) = split_headers(bytes);
    let mut lines = header_lines(headers);

    let tree = lines
        .next()
        .and_then(|l| strip(l, b"tree "))
        .ok_or_else(|| DecodeError::new(Kind::Commit, "no tree header"))?;
    let tree = hex_oid(tree, hash, Kind::Commit, "tree header")?;

    parents.clear();
    for line in lines {
        match strip(line, b"parent ") {
            Some(field) => parents.push(hex_oid(field, hash, Kind::Commit, "parent header")?),
            None => break,
        }
    }
    Ok(tree)
}

/// [`commit_links`], plus the committer's timestamp.
///
/// Negotiation orders its walk by this. It costs one more pass over the header
/// lines and exactly one signature split; the four other headers are still
/// never parsed.
///
/// A commit whose committer line has no readable time gets `0`, which costs
/// that one commit its place in the ordering and nothing else. `SLOP` in
/// `negotiate.rs` absorbs precisely this.
pub fn commit_facts(
    bytes: &[u8],
    hash: HashKind,
    parents: &mut Vec<ObjectId>,
) -> DecodeResult<(ObjectId, i64)> {
    let (headers, _) = split_headers(bytes);
    let mut lines = header_lines(headers);

    let tree = lines
        .next()
        .and_then(|l| strip(l, b"tree "))
        .ok_or_else(|| DecodeError::new(Kind::Commit, "no tree header"))?;
    let tree = hex_oid(tree, hash, Kind::Commit, "tree header")?;

    parents.clear();
    let mut committed_at = 0;
    let mut in_parents = true;
    for line in lines {
        if in_parents {
            if let Some(field) = strip(line, b"parent ") {
                parents.push(hex_oid(field, hash, Kind::Commit, "parent header")?);
                continue;
            }
            in_parents = false;
        }
        if let Some(field) = strip(line, b"committer ") {
            committed_at = signature(field).seconds;
            break;
        }
    }
    Ok((tree, committed_at))
}

/// The whole commit: both signatures, the parents, the tree and the message.
///
/// The control plane's `ListCommits` and nothing else. Everything on the serve
/// path uses [`commit_links`] or [`commit_facts`].
pub fn commit(bytes: &[u8], hash: HashKind) -> DecodeResult<Commit> {
    let (headers, body) = split_headers(bytes);
    let mut tree = None;
    let mut parents = Vec::new();
    let mut author = None;
    let mut committer = None;

    for line in header_lines(headers) {
        if let Some(field) = strip(line, b"tree ") {
            if tree.is_none() {
                tree = Some(hex_oid(field, hash, Kind::Commit, "tree header")?);
            }
        } else if let Some(field) = strip(line, b"parent ") {
            parents.push(hex_oid(field, hash, Kind::Commit, "parent header")?);
        } else if let Some(field) = strip(line, b"author ") {
            if author.is_none() {
                author = Some(signature(field));
            }
        } else if let Some(field) = strip(line, b"committer ") {
            if committer.is_none() {
                committer = Some(signature(field));
            }
        }
    }

    Ok(Commit {
        tree: tree.ok_or_else(|| DecodeError::new(Kind::Commit, "no tree header"))?,
        parents,
        author: author.ok_or_else(|| DecodeError::new(Kind::Commit, "no author header"))?,
        committer: committer
            .ok_or_else(|| DecodeError::new(Kind::Commit, "no committer header"))?,
        message: body.to_vec(),
    })
}

// ───────────────────────────────────────────────────────────────────────────
// Tags
// ───────────────────────────────────────────────────────────────────────────

/// A tag's `object` and `type` lines.
///
/// Peeling needs the target; the type is returned because it is free once the
/// line is in hand, and `None` there means the tag named a type git does not
/// have, which is the tag's problem and not the peeler's.
pub fn tag(bytes: &[u8], hash: HashKind) -> DecodeResult<Tag> {
    let (headers, _) = split_headers(bytes);
    let mut target = None;
    let mut target_kind = None;

    for line in header_lines(headers) {
        if let Some(field) = strip(line, b"object ") {
            if target.is_none() {
                target = Some(hex_oid(field, hash, Kind::Tag, "object header")?);
            }
        } else if let Some(field) = strip(line, b"type ") {
            if target_kind.is_none() {
                target_kind = Kind::from_bytes(field);
            }
        }
    }

    Ok(Tag {
        target: target.ok_or_else(|| DecodeError::new(Kind::Tag, "no object header"))?,
        target_kind,
    })
}

/// The one line of a tag that peeling reads.
pub fn tag_target(bytes: &[u8], hash: HashKind) -> DecodeResult<ObjectId> {
    tag(bytes, hash).map(|t| t.target)
}

// ───────────────────────────────────────────────────────────────────────────
// Trees
// ───────────────────────────────────────────────────────────────────────────

/// `<mode> <name>\0<raw oid>`, repeated to the end of the buffer.
///
/// Nothing is allocated: the iterator holds the caller's buffer and copies one
/// oid per entry into the yielded [`TreeEntry`].
pub struct TreeIter<'a> {
    inner: NamedTreeIter<'a>,
}

impl<'a> TreeIter<'a> {
    /// Read `bytes` as a tree object's payload.
    pub fn from_bytes(bytes: &'a [u8], hash: HashKind) -> Self {
        TreeIter {
            inner: NamedTreeIter::from_bytes(bytes, hash),
        }
    }

    /// The same walk, yielding each entry's **name and raw mode** as well.
    ///
    /// Not a second parser: [`TreeIter`] is this one with the two extra fields
    /// dropped, so a tree either decodes the same way for both or for neither
    /// (one implementation).
    pub fn named(self) -> NamedTreeIter<'a> {
        self.inner
    }
}

impl Iterator for TreeIter<'_> {
    type Item = DecodeResult<TreeEntry>;

    fn next(&mut self) -> Option<DecodeResult<TreeEntry>> {
        Some(self.inner.next()?.map(|entry| TreeEntry {
            oid: entry.oid,
            kind: entry.kind,
        }))
    }
}

/// [`TreeIter`], with the name and the raw mode kept.
///
/// The one decoder for git's tree grammar in this crate; see [`TreeIter::named`].
pub struct NamedTreeIter<'a> {
    rest: &'a [u8],
    hash_len: usize,
    failed: bool,
}

impl<'a> NamedTreeIter<'a> {
    /// Read `bytes` as a tree object's payload.
    pub fn from_bytes(bytes: &'a [u8], hash: HashKind) -> Self {
        NamedTreeIter {
            rest: bytes,
            hash_len: hash.len_in_bytes(),
            failed: false,
        }
    }
}

impl<'a> Iterator for NamedTreeIter<'a> {
    type Item = DecodeResult<NamedTreeEntry<'a>>;

    fn next(&mut self) -> Option<DecodeResult<NamedTreeEntry<'a>>> {
        if self.failed || self.rest.is_empty() {
            return None;
        }
        match self.entry() {
            Ok(entry) => Some(Ok(entry)),
            Err(err) => {
                // One bad entry ends the tree. Resynchronising inside a
                // corrupt buffer would mean guessing where the next record
                // starts, and a guess that lands mid-oid yields entries that
                // look perfectly well-formed.
                self.failed = true;
                Some(Err(err))
            }
        }
    }
}

impl<'a> NamedTreeIter<'a> {
    fn entry(&mut self) -> DecodeResult<NamedTreeEntry<'a>> {
        let bad = |what| DecodeError::new(Kind::Tree, what);

        let space =
            memchr(b' ', self.rest).ok_or_else(|| bad("an entry with no mode delimiter"))?;
        let mode =
            octal(&self.rest[..space]).ok_or_else(|| bad("an entry whose mode is not octal"))?;
        let after_mode = &self.rest[space + 1..];
        let nul = memchr(0, after_mode).ok_or_else(|| bad("an entry with no name terminator"))?;
        let name = &after_mode[..nul];
        if name.is_empty() {
            return Err(bad("an entry with an empty name"));
        }
        let after_name = &after_mode[nul + 1..];
        if after_name.len() < self.hash_len {
            return Err(bad("an entry whose oid runs off the end"));
        }
        let (raw, rest) = after_name.split_at(self.hash_len);
        self.rest = rest;

        Ok(NamedTreeEntry {
            // Length-checked one line up, which is the only way the conversion
            // can fail.
            oid: ObjectId::from_bytes_or_panic(raw),
            kind: entry_kind(mode),
            name,
            mode,
        })
    }
}

/// The entry a tree holds under exactly `name`, with its kind — or `None`.
///
/// [`TreeIter`] deliberately drops names (its callers walk edges), and the log
/// path filter is the caller that cannot: "did this commit change `src/lib.rs`"
/// is answered by comparing what NAME an id sits under across two trees. Same
/// parse, same corruption rule — a malformed entry is an error, never a
/// resynchronised guess. Byte comparison, no UTF-8 assumption: git names are
/// bytes and so is the needle.
pub fn tree_entry_named(
    bytes: &[u8],
    hash: HashKind,
    name: &[u8],
) -> DecodeResult<Option<TreeEntry>> {
    let bad = |what| DecodeError::new(Kind::Tree, what);
    let hash_len = hash.len_in_bytes();
    let mut rest = bytes;
    while !rest.is_empty() {
        let space = memchr(b' ', rest).ok_or_else(|| bad("an entry with no mode delimiter"))?;
        let mode = octal(&rest[..space]).ok_or_else(|| bad("an entry whose mode is not octal"))?;
        let after_mode = &rest[space + 1..];
        let nul = memchr(0, after_mode).ok_or_else(|| bad("an entry with no name terminator"))?;
        let entry_name = &after_mode[..nul];
        let after_name = &after_mode[nul + 1..];
        if after_name.len() < hash_len {
            return Err(bad("an entry whose oid runs off the end"));
        }
        let (raw, tail) = after_name.split_at(hash_len);
        if entry_name == name {
            return Ok(Some(TreeEntry {
                oid: ObjectId::from_bytes_or_panic(raw),
                kind: entry_kind(mode),
            }));
        }
        rest = tail;
    }
    Ok(None)
}

/// Up to six ASCII octal digits.
///
/// Bounded at six because git's modes are, and because an unbounded loop over
/// a corrupt buffer is how a decoder turns a bad object into a hang.
fn octal(field: &[u8]) -> Option<u32> {
    if field.is_empty() || field.len() > 6 {
        return None;
    }
    let mut mode = 0u32;
    for &b in field {
        let d = b.wrapping_sub(b'0');
        if d > 7 {
            return None;
        }
        mode = (mode << 3) | d as u32;
    }
    Some(mode)
}

/// git's `S_IFMT` split of a tree entry mode.
///
/// The `0o140000` arm is not a type: it is the socket bit, which git never
/// writes and `gix-object` folds onto a tree. Anything that is not a known
/// format is a gitlink, which is also what git itself concludes.
fn entry_kind(mode: u32) -> EntryKind {
    const IFMT: u32 = 0o170000;
    match mode & IFMT {
        0o100000 => {
            if mode & 0o100 == 0o100 {
                EntryKind::BlobExecutable
            } else {
                EntryKind::Blob
            }
        }
        0o120000 => EntryKind::Link,
        0o040000 | 0o140000 => EntryKind::Tree,
        _ => EntryKind::Commit,
    }
}