gunnar-store 1.6.0

The gunnar server's storage seam: RepoStore (which repository), SecretStore (what has no git equivalent), and the one conformance suite both GitOps backends run
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
//! **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,
    }
}