Skip to main content

sley_rev/
lib.rs

1pub mod bisect;
2pub mod graph;
3pub mod revlist;
4mod setup;
5
6use sley_config::GitConfig;
7use sley_core::{GitError, MissingObjectContext, ObjectFormat, ObjectId, Result};
8
9pub use setup::{
10    MatchedRef, NoWalkMode, PseudoRefResolver, RevisionOptions, RevisionOrder,
11    RevisionSetupContext, RevisionSymmetricRange, RevisionTip, SetupRevisions,
12    ambiguous_argument_error, ambiguous_argument_message, setup_revisions, setup_revisions_os,
13};
14pub use sley_core::BString;
15use sley_formats::CommitGraph;
16use sley_index::Index;
17use sley_object::{Commit, EncodedObject, ObjectType, Tag, TreeEntries};
18use sley_odb::{FileObjectDatabase, ObjectPrefixResolution, ObjectReader, repository_objects_dir};
19use sley_refs::{
20    FileRefStore, PackedRef, RefTarget, ReflogEntry, validate_ref_name_for_read,
21    validate_symref_target,
22};
23use std::collections::{HashMap, HashSet, VecDeque};
24use std::fs;
25use std::ops::Range;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28
29fn read_revision_object<R: ObjectReader>(reader: &R, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
30    reader
31        .read_object(oid)
32        .map_err(|err| with_missing_object_context(err, *oid, MissingObjectContext::RevisionWalk))
33}
34
35fn with_missing_object_context(
36    err: GitError,
37    oid: ObjectId,
38    context: MissingObjectContext,
39) -> GitError {
40    let kind = err
41        .not_found_kind()
42        .and_then(sley_core::NotFoundKind::missing_object_kind);
43    match kind {
44        Some(kind) => GitError::object_kind_not_found_in(oid, kind, context),
45        None => err,
46    }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct RevisionSpec {
51    pub raw: String,
52}
53
54impl RevisionSpec {
55    pub fn parse(raw: impl Into<String>) -> Result<Self> {
56        let raw = raw.into();
57        if raw.is_empty() {
58            return Err(GitError::InvalidFormat("empty revision spec".into()));
59        }
60        Ok(Self { raw })
61    }
62
63    pub fn borrowed(&self) -> Result<RevisionSpecRef<'_>> {
64        RevisionSpecRef::parse(&self.raw)
65    }
66}
67
68/// A borrowed, allocation-free classification of a revision spelling.
69///
70/// This is intentionally only a top-level parse for now: it separates the
71/// forms that change the resolver entry point (`:/text`, `:[stage:]path`, and
72/// `<rev>:<path>`) while leaving suffix chains like `^`, `~`, `^{tree}`, and
73/// `^{/text}` to the existing suffix resolver. Keeping the slices borrowed lets
74/// callers route a user-provided spec without copying, and it avoids the
75/// brittle "first colon wins" behavior that misclassified colons inside
76/// `^{/text}` and reflog selectors.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct RevisionSpecRef<'a> {
79    raw: &'a str,
80    kind: RevisionSpecKind<'a>,
81}
82
83impl<'a> RevisionSpecRef<'a> {
84    pub fn parse(raw: &'a str) -> Result<Self> {
85        if raw.is_empty() {
86            return Err(GitError::InvalidFormat("empty revision spec".into()));
87        }
88        let kind = if let Some(text) = raw.strip_prefix(":/") {
89            RevisionSpecKind::MessageSearch { text }
90        } else if let Some(rest) = raw.strip_prefix(':') {
91            let (stage, path) = parse_index_stage_path(rest);
92            RevisionSpecKind::IndexPath { stage, path }
93        } else if let Some((rev, path)) = split_top_level_rev_path(raw) {
94            RevisionSpecKind::TreePath { rev, path }
95        } else {
96            RevisionSpecKind::Revision { rev: raw }
97        };
98        Ok(Self { raw, kind })
99    }
100
101    pub fn raw(&self) -> &'a str {
102        self.raw
103    }
104
105    pub fn kind(&self) -> RevisionSpecKind<'a> {
106        self.kind
107    }
108
109    pub fn tree_path(&self) -> Option<(&'a str, &'a str)> {
110        match self.kind {
111            RevisionSpecKind::TreePath { rev, path } => Some((rev, path)),
112            _ => None,
113        }
114    }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum RevisionSpecKind<'a> {
119    MessageSearch { text: &'a str },
120    IndexPath { stage: u8, path: &'a str },
121    TreePath { rev: &'a str, path: &'a str },
122    Revision { rev: &'a str },
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct CommitRecord {
127    pub oid: ObjectId,
128    pub parents: Vec<ObjectId>,
129    pub commit: Commit,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ObjectDisambiguation {
134    Any,
135    Commit,
136    Commitish,
137    Tree,
138    Treeish,
139    Tag,
140    Blob,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum ShortObjectIdResolution {
145    Missing,
146    Unique(ObjectId),
147    Ambiguous(Vec<ObjectId>),
148}
149
150impl ShortObjectIdResolution {
151    pub fn into_result(self, prefix: &str) -> Result<ObjectId> {
152        match self {
153            Self::Unique(oid) => Ok(oid),
154            Self::Missing => Err(GitError::not_found(format!("revision {prefix}"))),
155            Self::Ambiguous(_) => Err(short_object_id_ambiguous_error(prefix)),
156        }
157    }
158}
159
160/// Lightweight commit-walk record: id, parents, and committer time only.
161///
162/// Unlike [`CommitRecord`] (which carries the whole parsed [`Commit`] and so
163/// forces a read+inflate of every commit object), this is sourced from the
164/// commit-graph when present — no object read — and falls back to the commit
165/// object only for commits the graph does not cover. Use it for traversals that
166/// need ancestry + date ordering but not the full commit (rev-list, log).
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct CommitMetadata {
169    pub oid: ObjectId,
170    pub parents: Vec<ObjectId>,
171    /// Committer time in seconds since the epoch (the value the commit-graph
172    /// records, identical to the object's committer line).
173    pub commit_time: i64,
174}
175
176/// Resolve a commit's root tree oid directly from the commit-graph, when a
177/// usable monolithic graph covers `oid`.
178///
179/// Commit-graphs are optional acceleration data, so a missing, unsupported, or
180/// corrupt graph is reported as `Ok(None)` and callers should fall back to
181/// reading the commit object for parity.
182pub fn commit_graph_tree_oid(
183    git_dir: &Path,
184    format: sley_core::ObjectFormat,
185    oid: &ObjectId,
186) -> Result<Option<ObjectId>> {
187    let mut graph = CommitGraphContext::load(git_dir, format);
188    match graph.direct_graph() {
189        DirectCommitGraph::Raw(graph) => graph.tree_oid(oid).or(Ok(None)),
190        DirectCommitGraph::Missing | DirectCommitGraph::Invalid(_) => Ok(None),
191    }
192}
193
194/// Terms that name the new/bad and old/good sides of an active bisect.
195///
196/// Git stores these as two LF-terminated lines in `$GIT_DIR/BISECT_TERMS`.
197/// Missing state means the default `bad`/`good` vocabulary. Commands that need
198/// to enumerate `refs/bisect/*` should use [`Self::is_bad_ref`] and
199/// [`Self::is_good_ref`] so custom terms stay centralized.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct BisectTerms {
202    pub bad: String,
203    pub good: String,
204}
205
206impl Default for BisectTerms {
207    fn default() -> Self {
208        Self {
209            bad: "bad".to_string(),
210            good: "good".to_string(),
211        }
212    }
213}
214
215impl BisectTerms {
216    pub fn is_bad_ref(&self, ref_name: &str) -> bool {
217        bisect_ref_matches_term(ref_name, &self.bad)
218    }
219
220    pub fn is_good_ref(&self, ref_name: &str) -> bool {
221        bisect_ref_matches_term(ref_name, &self.good)
222    }
223}
224
225pub fn read_bisect_terms(git_dir: impl AsRef<Path>) -> Result<BisectTerms> {
226    let path = git_dir.as_ref().join("BISECT_TERMS");
227    let contents = match fs::read_to_string(&path) {
228        Ok(contents) => contents,
229        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
230            return Ok(BisectTerms::default());
231        }
232        Err(err) => return Err(GitError::Io(err.to_string())),
233    };
234    let mut lines = contents.lines();
235    let bad = match lines.next() {
236        Some(line) => line.to_string(),
237        None => String::new(),
238    };
239    let good = match lines.next() {
240        Some(line) => line.to_string(),
241        None => String::new(),
242    };
243    Ok(BisectTerms { bad, good })
244}
245
246fn bisect_ref_matches_term(ref_name: &str, term: &str) -> bool {
247    ref_name
248        .strip_prefix("refs/bisect/")
249        .is_some_and(|name| name.starts_with(term))
250}
251
252pub fn resolve_revision(
253    git_dir: impl AsRef<Path>,
254    format: ObjectFormat,
255    rev: &str,
256) -> Result<ObjectId> {
257    let git_dir = git_dir.as_ref();
258    let db = FileObjectDatabase::from_git_dir(git_dir, format);
259    resolve_revision_with_reader(git_dir, format, &db, rev)
260}
261
262pub fn resolve_revision_with_reader<R: ObjectReader>(
263    git_dir: &Path,
264    format: ObjectFormat,
265    reader: &R,
266    rev: &str,
267) -> Result<ObjectId> {
268    resolve_revision_inner(
269        git_dir,
270        format,
271        reader,
272        rev,
273        None,
274        ObjectDisambiguation::Any,
275    )
276}
277
278/// Like [`resolve_revision_with_reader`], but resolves `@{upstream}` / `@{push}`
279/// against a caller-supplied effective config instead of re-reading
280/// `<git_dir>/config` blindly.
281///
282/// Callers that have already resolved the repository config — including
283/// `include`/`includeIf` directives and command-line `-c` / `GIT_CONFIG_*`
284/// overrides — pass it here so upstream resolution honours the same
285/// `branch.<name>.{remote,merge}` the rest of the command sees. When `config`
286/// is `None` (or the upstream path is reached without one), this falls back to an
287/// include-aware read of `<git_dir>/config`.
288pub fn resolve_revision_with_config<R: ObjectReader>(
289    git_dir: &Path,
290    format: ObjectFormat,
291    reader: &R,
292    rev: &str,
293    config: &GitConfig,
294) -> Result<ObjectId> {
295    resolve_revision_inner(
296        git_dir,
297        format,
298        reader,
299        rev,
300        Some(config),
301        ObjectDisambiguation::Any,
302    )
303}
304
305/// Resolve `rev` to an [`ObjectId`], preferring objects that satisfy
306/// `disambiguation` when (and only when) `rev` falls through to short
307/// object-id prefix resolution. Ref names always take precedence over a
308/// same-spelled short hex prefix, mirroring git's `get_oid_basic`
309/// (`repo_dwim_ref` is consulted before `get_short_oid`); the disambiguation
310/// flag only narrows the candidate set at the short-OID stage.
311pub fn resolve_revision_with_disambiguation(
312    git_dir: impl AsRef<Path>,
313    format: ObjectFormat,
314    rev: &str,
315    disambiguation: ObjectDisambiguation,
316) -> Result<ObjectId> {
317    let git_dir = git_dir.as_ref();
318    let db = FileObjectDatabase::from_git_dir(git_dir, format);
319    resolve_revision_inner(git_dir, format, &db, rev, None, disambiguation)
320}
321
322/// `commit-ish` variant of [`resolve_revision_with_reader`]: a ref still wins
323/// over a same-spelled short hex prefix, but an ambiguous bare prefix is
324/// narrowed to its commit-ish candidates (used by the revision walker setup so
325/// `cherry-pick <ref>` / `revert <ref>` honour ref precedence while keeping the
326/// commit-ish disambiguation for genuine bare-OID prefixes).
327pub fn resolve_revision_commitish_with_reader<R: ObjectReader>(
328    git_dir: &Path,
329    format: ObjectFormat,
330    reader: &R,
331    rev: &str,
332) -> Result<ObjectId> {
333    resolve_revision_inner(
334        git_dir,
335        format,
336        reader,
337        rev,
338        None,
339        ObjectDisambiguation::Commitish,
340    )
341}
342
343/// Like [`resolve_revision_commitish_with_reader`] but resolves `@{upstream}` /
344/// `@{push}` against a caller-supplied effective config. See
345/// [`resolve_revision_with_config`].
346pub fn resolve_revision_commitish_with_config<R: ObjectReader>(
347    git_dir: &Path,
348    format: ObjectFormat,
349    reader: &R,
350    rev: &str,
351    config: &GitConfig,
352) -> Result<ObjectId> {
353    resolve_revision_inner(
354        git_dir,
355        format,
356        reader,
357        rev,
358        Some(config),
359        ObjectDisambiguation::Commitish,
360    )
361}
362
363/// Commit-ish revision resolution that builds its own on-disk reader. See
364/// [`resolve_revision_commitish_with_reader`].
365pub fn resolve_revision_commitish(
366    git_dir: impl AsRef<Path>,
367    format: ObjectFormat,
368    rev: &str,
369) -> Result<ObjectId> {
370    let git_dir = git_dir.as_ref();
371    let db = FileObjectDatabase::from_git_dir(git_dir, format);
372    resolve_revision_commitish_with_reader(git_dir, format, &db, rev)
373}
374
375/// Resolve a revision expression to the full refname that names it, when the
376/// expression is ref-backed. This is the symbolic side of
377/// `resolve_revision_with_config`: it is used by callers such as
378/// `rev-parse --symbolic-full-name`, checkout, and branch deletion to keep
379/// `@{upstream}` selectors as refs instead of flattening them to object IDs.
380pub fn resolve_revision_symbolic_full_name(
381    git_dir: &Path,
382    format: ObjectFormat,
383    rev: &str,
384) -> Result<Option<String>> {
385    resolve_revision_symbolic_full_name_inner(git_dir, format, rev, None)
386}
387
388pub fn resolve_revision_symbolic_full_name_with_config(
389    git_dir: &Path,
390    format: ObjectFormat,
391    rev: &str,
392    config: &GitConfig,
393) -> Result<Option<String>> {
394    resolve_revision_symbolic_full_name_inner(git_dir, format, rev, Some(config))
395}
396
397fn resolve_revision_symbolic_full_name_inner(
398    git_dir: &Path,
399    format: ObjectFormat,
400    rev: &str,
401    config: Option<&GitConfig>,
402) -> Result<Option<String>> {
403    if rev.len() == format.hex_len() && rev.bytes().all(|byte| byte.is_ascii_hexdigit()) {
404        return Ok(None);
405    }
406    if let Some(name) = resolve_at_selector_ref_name(git_dir, format, rev, config)? {
407        return Ok(Some(name));
408    }
409    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
410    if rev == "HEAD" {
411        return refs.current_branch_ref();
412    }
413    if rev.starts_with("refs/") {
414        return Ok(refs.read_ref(rev)?.map(|_| rev.to_string()));
415    }
416    for candidate in [
417        format!("refs/{rev}"),
418        format!("refs/tags/{rev}"),
419        format!("refs/heads/{rev}"),
420        format!("refs/remotes/{rev}"),
421        format!("refs/remotes/{rev}/HEAD"),
422    ] {
423        if refs.read_ref(&candidate)?.is_some() {
424            return Ok(Some(candidate));
425        }
426    }
427    Err(GitError::not_found(format!("revision {rev}")))
428}
429
430fn resolve_revision_inner<R: ObjectReader>(
431    git_dir: &Path,
432    format: ObjectFormat,
433    reader: &R,
434    rev: &str,
435    config: Option<&GitConfig>,
436    disambiguation: ObjectDisambiguation,
437) -> Result<ObjectId> {
438    let parsed = RevisionSpecRef::parse(rev)?;
439    match parsed.kind() {
440        RevisionSpecKind::MessageSearch { text } => {
441            return search_commit_message_all(git_dir, format, reader, text);
442        }
443        RevisionSpecKind::IndexPath { stage, path } => {
444            return resolve_index_path(git_dir, format, reader, stage, path);
445        }
446        RevisionSpecKind::TreePath {
447            rev: rev_part,
448            path,
449        } => {
450            return resolve_rev_path(git_dir, format, reader, rev_part, path);
451        }
452        RevisionSpecKind::Revision { rev: _ } => {}
453    }
454    // `@`, `@{N}`, `<branch>@{N}`, `@{u}`/`@{upstream}`, `@{push}`, and `@{-N}` are
455    // resolved before the `^`/`~` suffix machinery so that a base like `HEAD@{1}^`
456    // first becomes the reflog value and only then has the parent suffix applied
457    // (the suffix splitter recurses back into this function on the `@{...}` base).
458    if let Some(oid) = resolve_at_selector(git_dir, format, rev, config)? {
459        return Ok(oid);
460    }
461    if let Some((base, suffix)) = split_revision_suffix(rev)? {
462        if base.is_empty() {
463            return Err(GitError::InvalidFormat(format!(
464                "revision {rev} has empty base"
465            )));
466        }
467        // Resolve the base through the ref-first path so a ref always wins over
468        // a same-spelled short hex prefix (e.g. `added^` must take `added` the
469        // ref, not a `added…` object prefix). The suffix dictates the type a
470        // bare prefix must satisfy, which only applies once ref lookup misses.
471        let base_disambiguation =
472            disambiguation_for_suffix(suffix).unwrap_or(ObjectDisambiguation::Any);
473        let base_oid =
474            resolve_revision_inner(git_dir, format, reader, base, config, base_disambiguation)?;
475        return apply_revision_suffix(git_dir, reader, format, &base_oid, suffix, rev);
476    }
477    resolve_revision_name(git_dir, format, rev, disambiguation)
478}
479
480fn disambiguation_for_suffix(suffix: RevisionSuffix<'_>) -> Option<ObjectDisambiguation> {
481    match suffix {
482        RevisionSuffix::Parent(_) | RevisionSuffix::FirstParent(_) | RevisionSuffix::Search(_) => {
483            Some(ObjectDisambiguation::Commitish)
484        }
485        RevisionSuffix::Peel(PeelKind::Object) => Some(ObjectDisambiguation::Any),
486        RevisionSuffix::Peel(PeelKind::AnyNonTag) => Some(ObjectDisambiguation::Any),
487        RevisionSuffix::Peel(PeelKind::Commit) => Some(ObjectDisambiguation::Commitish),
488        RevisionSuffix::Peel(PeelKind::Tree) => Some(ObjectDisambiguation::Treeish),
489        RevisionSuffix::Peel(PeelKind::Tag) => Some(ObjectDisambiguation::Tag),
490        RevisionSuffix::Peel(PeelKind::Blob) => Some(ObjectDisambiguation::Blob),
491    }
492}
493
494pub struct RevisionResolver<'a, R> {
495    git_dir: &'a Path,
496    format: ObjectFormat,
497    reader: &'a R,
498    config: Option<&'a GitConfig>,
499}
500
501impl<'a, R: ObjectReader> RevisionResolver<'a, R> {
502    pub fn new(git_dir: &'a Path, format: ObjectFormat, reader: &'a R) -> Self {
503        Self {
504            git_dir,
505            format,
506            reader,
507            config: None,
508        }
509    }
510
511    /// Attach a caller-resolved effective config so `@{upstream}` / `@{push}`
512    /// honour `include`/`includeIf` and `-c` / `GIT_CONFIG_*` overrides. See
513    /// [`resolve_revision_with_config`].
514    pub fn with_config(mut self, config: &'a GitConfig) -> Self {
515        self.config = Some(config);
516        self
517    }
518
519    pub fn resolve(&self, rev: &str) -> Result<ObjectId> {
520        resolve_revision_inner(
521            self.git_dir,
522            self.format,
523            self.reader,
524            rev,
525            self.config,
526            ObjectDisambiguation::Any,
527        )
528    }
529
530    pub fn peel_to_blob(&self, rev: &str) -> Result<ObjectId> {
531        let oid = self.resolve(rev)?;
532        peel_tags(self.reader, self.format, &oid)
533    }
534
535    pub fn peel_to_tree(&self, rev: &str) -> Result<ObjectId> {
536        let oid = self.resolve(rev)?;
537        peel_to_tree(self.reader, self.format, &oid)
538    }
539
540    pub fn peel_to_commit(&self, rev: &str) -> Result<ObjectId> {
541        let oid = self.resolve(rev)?;
542        peel_to_commit(self.reader, self.format, &oid)
543    }
544
545    pub fn resolve_path(&self, rev: &str, path: &str) -> Result<ResolvedTreePath> {
546        resolve_rev_path_entry(self.git_dir, self.format, self.reader, rev, path)
547    }
548
549    /// `<rev>:<path>` resolution that follows in-tree symlinks, as
550    /// `git cat-file --follow-symlinks` does. See
551    /// [`resolve_rev_path_follow_symlinks`].
552    pub fn resolve_path_follow_symlinks(&self, rev: &str, path: &str) -> SymlinkedTreePath {
553        resolve_rev_path_follow_symlinks(self.git_dir, self.format, self.reader, rev, path)
554    }
555}
556
557fn resolve_revision_name(
558    git_dir: &Path,
559    format: sley_core::ObjectFormat,
560    rev: &str,
561    disambiguation: ObjectDisambiguation,
562) -> Result<ObjectId> {
563    if rev.len() == format.hex_len() && rev.bytes().all(|byte| byte.is_ascii_hexdigit()) {
564        return ObjectId::from_hex(format, rev);
565    }
566    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
567    if let Some(oid) = resolve_revision_ref(&refs, rev)? {
568        return Ok(oid);
569    }
570    // Ref lookup missed: now a bare hex prefix may name an object. This is the
571    // single point where short object-id prefixes resolve, so ref names always
572    // win over a same-spelled prefix; `disambiguation` narrows the candidate
573    // set here (and only here), matching git's `get_short_oid`.
574    if rev.len() >= 4
575        && rev.len() < format.hex_len()
576        && rev.bytes().all(|byte| byte.is_ascii_hexdigit())
577    {
578        return resolve_short_object_id(git_dir, format, rev, disambiguation)?.into_result(rev);
579    }
580    // git's get_describe_name: `<tag>-<count>-g<hex>` (describe output) resolves
581    // to the abbreviated commit named by the trailing `-g<hex>`.
582    if let Some(oid) = resolve_describe_name(git_dir, format, rev)? {
583        return Ok(oid);
584    }
585    Err(GitError::not_found(format!("revision {rev}")))
586}
587
588pub fn short_object_id_ambiguous_error(prefix: &str) -> GitError {
589    GitError::InvalidObjectId(format!("short object ID {prefix} is ambiguous"))
590}
591
592pub fn is_short_object_id_ambiguous_error(err: &GitError) -> bool {
593    matches!(err, GitError::InvalidObjectId(msg) if msg.starts_with("short object ID ") && msg.ends_with(" is ambiguous"))
594}
595
596pub fn resolve_short_object_id(
597    git_dir: &Path,
598    format: ObjectFormat,
599    prefix: &str,
600    disambiguation: ObjectDisambiguation,
601) -> Result<ShortObjectIdResolution> {
602    let db = FileObjectDatabase::from_git_dir(git_dir, format);
603    resolve_short_object_id_with_reader(git_dir, format, &db, prefix, disambiguation)
604}
605
606pub fn object_ids_with_prefix(
607    git_dir: &Path,
608    format: ObjectFormat,
609    prefix: &str,
610) -> Result<Vec<ObjectId>> {
611    FileObjectDatabase::from_git_dir(git_dir, format).object_ids_with_prefix(prefix)
612}
613
614pub fn resolve_short_object_id_with_reader<R: ObjectReader>(
615    git_dir: &Path,
616    format: ObjectFormat,
617    reader: &R,
618    prefix: &str,
619    disambiguation: ObjectDisambiguation,
620) -> Result<ShortObjectIdResolution> {
621    let db = FileObjectDatabase::from_git_dir(git_dir, format);
622    let candidates = db.object_ids_with_prefix(prefix)?;
623    if candidates.is_empty() {
624        return Ok(ShortObjectIdResolution::Missing);
625    }
626    if disambiguation == ObjectDisambiguation::Any {
627        return Ok(match candidates.len() {
628            1 => ShortObjectIdResolution::Unique(candidates[0]),
629            _ => ShortObjectIdResolution::Ambiguous(candidates),
630        });
631    }
632    let mut accepted = Vec::new();
633    for oid in &candidates {
634        if short_object_id_matches_type(reader, format, oid, disambiguation) {
635            accepted.push(*oid);
636        }
637    }
638    Ok(match accepted.len() {
639        1 => ShortObjectIdResolution::Unique(accepted[0]),
640        0 => ShortObjectIdResolution::Ambiguous(candidates),
641        _ => ShortObjectIdResolution::Ambiguous(accepted),
642    })
643}
644
645fn short_object_id_matches_type<R: ObjectReader>(
646    reader: &R,
647    format: ObjectFormat,
648    oid: &ObjectId,
649    disambiguation: ObjectDisambiguation,
650) -> bool {
651    match disambiguation {
652        ObjectDisambiguation::Any => true,
653        ObjectDisambiguation::Commit => reader
654            .read_object(oid)
655            .is_ok_and(|object| object.object_type == ObjectType::Commit),
656        ObjectDisambiguation::Commitish => peel_to_commit(reader, format, oid).is_ok(),
657        ObjectDisambiguation::Tree => reader
658            .read_object(oid)
659            .is_ok_and(|object| object.object_type == ObjectType::Tree),
660        ObjectDisambiguation::Treeish => peel_to_tree(reader, format, oid).is_ok(),
661        ObjectDisambiguation::Tag => reader
662            .read_object(oid)
663            .is_ok_and(|object| object.object_type == ObjectType::Tag),
664        ObjectDisambiguation::Blob => peel_to_blob(reader, format, oid).is_ok(),
665    }
666}
667
668pub fn ambiguous_short_object_id_hint(
669    git_dir: &Path,
670    format: ObjectFormat,
671    prefix: &str,
672    disambiguation: ObjectDisambiguation,
673) -> Result<Vec<String>> {
674    let db = FileObjectDatabase::from_git_dir(git_dir, format);
675    let mut candidates = db.object_ids_with_prefix(prefix)?;
676    candidates.sort_by(|left, right| {
677        let left_type = ambiguous_candidate_type_for_sort(&db, left);
678        let right_type = ambiguous_candidate_type_for_sort(&db, right);
679        ambiguous_type_sort_key(left_type)
680            .cmp(&ambiguous_type_sort_key(right_type))
681            .then_with(|| left.to_hex().cmp(&right.to_hex()))
682    });
683    let mut out = Vec::new();
684    for oid in candidates {
685        if disambiguation != ObjectDisambiguation::Any
686            && !short_object_id_matches_type(&db, format, &oid, disambiguation)
687        {
688            continue;
689        }
690        out.push(ambiguous_short_object_id_line(&db, format, &oid)?);
691    }
692    if out.is_empty() && disambiguation != ObjectDisambiguation::Any {
693        for oid in db.object_ids_with_prefix(prefix)? {
694            out.push(ambiguous_short_object_id_line(&db, format, &oid)?);
695        }
696    }
697    Ok(out)
698}
699
700fn ambiguous_candidate_type_for_sort(
701    db: &FileObjectDatabase,
702    oid: &ObjectId,
703) -> Option<ObjectType> {
704    match db.read_object_header(oid) {
705        Ok(Some((object_type, _))) => Some(object_type),
706        Err(GitError::InvalidObject(message)) if message.starts_with("unable to unpack ") => {
707            eprintln!("error: {message}");
708            None
709        }
710        Ok(None) | Err(_) => None,
711    }
712}
713
714fn ambiguous_type_sort_key(object_type: Option<ObjectType>) -> u8 {
715    match object_type {
716        None => 0,
717        Some(ObjectType::Tag) => 1,
718        Some(ObjectType::Commit) => 2,
719        Some(ObjectType::Tree) => 3,
720        Some(ObjectType::Blob) => 4,
721    }
722}
723
724fn ambiguous_short_object_id_line(
725    db: &FileObjectDatabase,
726    format: ObjectFormat,
727    oid: &ObjectId,
728) -> Result<String> {
729    let abbrev = unique_object_abbrev(db, oid)?;
730    let object_type = match db.read_object_header(oid) {
731        Ok(Some((object_type, _))) => object_type,
732        Err(GitError::InvalidObject(message)) if message.starts_with("unknown object type") => {
733            return Err(GitError::InvalidObject(message));
734        }
735        Err(GitError::InvalidObject(message)) if message.starts_with("unable to unpack ") => {
736            eprintln!("error: {message}");
737            return Ok(format!("{abbrev} [bad object]"));
738        }
739        Ok(None) | Err(_) => return Ok(format!("{abbrev} [bad object]")),
740    };
741    if matches!(object_type, ObjectType::Tree | ObjectType::Blob) {
742        return Ok(format!("{abbrev} {}", object_type.as_str()));
743    }
744    let object = match db.read_object(oid) {
745        Ok(object) => object,
746        Err(GitError::InvalidObject(message)) if message.starts_with("unknown object type") => {
747            return Err(GitError::InvalidObject(message));
748        }
749        Err(GitError::InvalidObject(message)) if message.starts_with("unable to unpack ") => {
750            eprintln!("error: {message}");
751            return Ok(format!("{abbrev} [bad object]"));
752        }
753        Err(_) => return Ok(format!("{abbrev} [bad object]")),
754    };
755    Ok(match object_type {
756        ObjectType::Commit => {
757            let commit = Commit::parse_ref(format, &object.body)?;
758            let subject = first_message_line(commit.message);
759            match short_date_from_ident(commit.committer) {
760                Some(date) if !subject.is_empty() => format!("{abbrev} commit {date} - {subject}"),
761                Some(date) => format!("{abbrev} commit {date} - "),
762                None if !subject.is_empty() => format!("{abbrev} commit  - {subject}"),
763                None => format!("{abbrev} commit  - "),
764            }
765        }
766        ObjectType::Tag => match Tag::parse_ref(format, &object.body) {
767            Ok(tag) => {
768                let name = String::from_utf8_lossy(tag.name);
769                match tag.tagger.and_then(short_date_from_ident) {
770                    Some(date) => format!("{abbrev} tag {date} - {name}"),
771                    None => format!("{abbrev} tag  - {name}"),
772                }
773            }
774            Err(_) => format!("{abbrev} [bad tag, could not parse it]"),
775        },
776        ObjectType::Tree => format!("{abbrev} tree"),
777        ObjectType::Blob => format!("{abbrev} blob"),
778    })
779}
780
781fn unique_object_abbrev(db: &FileObjectDatabase, oid: &ObjectId) -> Result<String> {
782    let hex = oid.to_hex();
783    let mut width = 7.min(hex.len());
784    while width < hex.len() {
785        match db.resolve_prefix(&hex[..width])? {
786            ObjectPrefixResolution::Ambiguous(_) => width += 1,
787            _ => break,
788        }
789    }
790    Ok(hex[..width].to_string())
791}
792
793fn first_message_line(message: &[u8]) -> String {
794    let line = message
795        .split(|byte| *byte == b'\n')
796        .next()
797        .unwrap_or_default();
798    String::from_utf8_lossy(line).into_owned()
799}
800
801fn short_date_from_ident(ident: &[u8]) -> Option<String> {
802    let signature = sley_core::Signature::from_ident_line(ident)?;
803    short_date_from_timestamp(signature.time.seconds)
804}
805
806fn short_date_from_timestamp(timestamp: i64) -> Option<String> {
807    let days = timestamp.div_euclid(86_400);
808    let (year, month, day) = civil_from_days_for_short_date(days)?;
809    Some(format!("{year:04}-{month:02}-{day:02}"))
810}
811
812fn civil_from_days_for_short_date(days: i64) -> Option<(i64, u32, u32)> {
813    let z = days.checked_add(719_468)?;
814    let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
815    let doe = z - era * 146_097;
816    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096).div_euclid(365);
817    let y = yoe + era * 400;
818    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
819    let mp = (5 * doy + 2).div_euclid(153);
820    let day = doy - (153 * mp + 2).div_euclid(5) + 1;
821    let month = mp + if mp < 10 { 3 } else { -9 };
822    let year = y + i64::from(month <= 2);
823    Some((year, u32::try_from(month).ok()?, u32::try_from(day).ok()?))
824}
825
826/// Resolve a `git describe` name (`<ref>-<count>-g<hex>`) back to the commit it
827/// names, mirroring `get_describe_name` in git's object-name.c: scan from the
828/// end over hex digits; the first non-hex byte must be the `g` of a `-g` marker,
829/// and the bytes after it form an abbreviated commit oid.
830fn resolve_describe_name(
831    git_dir: &Path,
832    format: sley_core::ObjectFormat,
833    rev: &str,
834) -> Result<Option<ObjectId>> {
835    let bytes = rev.as_bytes();
836    // Need at least `X-gY`: the scan starts at the last byte and stops once we
837    // are within two bytes of the start (matching git's `name + 2 <= cp`).
838    let mut idx = bytes.len();
839    while idx >= 2 {
840        idx -= 1;
841        let ch = bytes[idx];
842        if ch.is_ascii_hexdigit() {
843            continue;
844        }
845        if ch == b'g' && idx >= 1 && bytes[idx - 1] == b'-' {
846            let hex = &rev[idx + 1..];
847            if hex.len() >= 4
848                && hex.len() < format.hex_len()
849                && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
850                && let ShortObjectIdResolution::Unique(oid) =
851                    resolve_short_object_id(git_dir, format, hex, ObjectDisambiguation::Commit)?
852            {
853                return Ok(Some(oid));
854            }
855        }
856        break;
857    }
858    Ok(None)
859}
860
861fn resolve_revision_ref(refs: &FileRefStore, rev: &str) -> Result<Option<ObjectId>> {
862    let mut candidates = Vec::new();
863    if rev == "HEAD" {
864        candidates.push("HEAD".to_string());
865    } else if rev.starts_with("refs/") {
866        candidates.push(rev.to_string());
867    } else {
868        let refs_name = format!("refs/{rev}");
869        if refs.read_ref(&refs_name)?.is_some() {
870            // git's ref_rev_parse_rules try "refs/%s" before tags/heads. This
871            // matters for pseudo-names such as "stash" (refs/stash), not just names
872            // containing a slash.
873            candidates.push(refs_name);
874        }
875        let tag_name = format!("refs/tags/{rev}");
876        if refs.read_ref(&tag_name)?.is_some() {
877            candidates.push(tag_name);
878        }
879        let head_name = format!("refs/heads/{rev}");
880        if refs.read_ref(&head_name)?.is_some() {
881            candidates.push(head_name);
882        }
883        let remote_name = format!("refs/remotes/{rev}");
884        if refs.read_ref(&remote_name)?.is_some() {
885            candidates.push(remote_name);
886        }
887        let remote_head_name = format!("refs/remotes/{rev}/HEAD");
888        if refs.read_ref(&remote_head_name)?.is_some() {
889            candidates.push(remote_head_name);
890        }
891        if validate_ref_name_for_read(rev).is_ok() {
892            candidates.push(rev.to_string());
893        }
894    }
895    for candidate in candidates {
896        if let Some(oid) = resolve_revision_ref_candidate(refs, &candidate)? {
897            return Ok(Some(oid));
898        }
899    }
900    Ok(None)
901}
902
903fn resolve_revision_ref_candidate(refs: &FileRefStore, name: &str) -> Result<Option<ObjectId>> {
904    let mut current = name.to_string();
905    for _ in 0..16 {
906        match refs.read_ref(&current)? {
907            Some(RefTarget::Direct(oid)) => return Ok(Some(oid)),
908            Some(RefTarget::Symbolic(target)) => {
909                if validate_symref_target(&target).is_err() {
910                    eprintln!("warning: ignoring dangling symref {name}");
911                    return Ok(None);
912                }
913                current = target;
914            }
915            None => return Ok(None),
916        }
917    }
918    Ok(None)
919}
920
921// ---------------------------------------------------------------------------
922// `@`, `@{...}`, and `<branch>@{...}` selectors
923// ---------------------------------------------------------------------------
924//
925// These are git's "at-mark" revision selectors:
926//   * bare `@`                  -> HEAD
927//   * `@{N}` / `<branch>@{N}`   -> the N-th prior value from the reflog
928//   * `@{u}` / `@{upstream}`    -> the branch's configured upstream tracking ref
929//   * `@{push}`                 -> the branch's push tracking ref
930//   * `@{-N}`                   -> the N-th previously checked-out branch
931// They are parsed ahead of the `^`/`~`/`:` suffix machinery so a base like
932// `HEAD@{1}^` resolves the reflog value first and then applies the suffix.
933
934/// Try to resolve `rev` as an at-mark selector.
935///
936/// Returns `Ok(None)` when `rev` is not an at-mark form (so the caller falls
937/// through to the normal suffix/name handling), `Ok(Some(oid))` on a successful
938/// resolution, and an error for a malformed or unsupported selector.
939fn resolve_at_selector(
940    git_dir: &Path,
941    format: sley_core::ObjectFormat,
942    rev: &str,
943    config: Option<&GitConfig>,
944) -> Result<Option<ObjectId>> {
945    // Bare `@` is an alias for HEAD.
946    if rev == "@" {
947        let refs = FileRefStore::new(git_dir.to_path_buf(), format);
948        return match resolve_revision_ref(&refs, "HEAD")? {
949            Some(oid) => Ok(Some(oid)),
950            None => Err(GitError::not_found("revision @")),
951        };
952    }
953
954    // Everything else must be `<base>@{<selector>}` with the braces at the end.
955    let Some(open) = rev.rfind("@{") else {
956        return Ok(None);
957    };
958    let Some(inner) = rev.strip_suffix('}') else {
959        return Ok(None);
960    };
961    // `inner` still has the `<base>@{` prefix; keep only what is inside the braces.
962    let inner = &inner[open + 2..];
963    if inner.contains('}') {
964        return Ok(None);
965    }
966    let base = &rev[..open];
967    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
968
969    // `@{-N}` is special: it names a previously checked-out branch and ignores
970    // any `<base>` to its left (git only accepts a bare `@{-N}`).
971    if let Some(rest) = inner.strip_prefix('-') {
972        if !base.is_empty() {
973            return Err(GitError::InvalidFormat(format!(
974                "invalid revision selector {rev}"
975            )));
976        }
977        let count = parse_at_count(rev, rest)?;
978        return Ok(Some(resolve_previous_checkout(
979            git_dir, format, count, rev,
980        )?));
981    }
982
983    if inner.eq_ignore_ascii_case("u") || inner.eq_ignore_ascii_case("upstream") {
984        let upstream = resolve_upstream_ref(git_dir, format, base, false, rev, config)?;
985        return match resolve_revision_ref(&refs, &upstream.refname)? {
986            Some(oid) => Ok(Some(oid)),
987            None => Err(upstream.missing_error(rev)),
988        };
989    }
990    if inner.eq_ignore_ascii_case("push") {
991        let upstream = resolve_upstream_ref(git_dir, format, base, true, rev, config)?;
992        return match resolve_revision_ref(&refs, &upstream.refname)? {
993            Some(oid) => Ok(Some(oid)),
994            None => Err(upstream.missing_error(rev)),
995        };
996    }
997    if inner.bytes().all(|byte| byte.is_ascii_digit()) {
998        let count = parse_at_count(rev, inner)?;
999        return Ok(Some(resolve_reflog_nth(
1000            git_dir, format, base, count, rev, config,
1001        )?));
1002    }
1003
1004    Ok(Some(resolve_reflog_date(
1005        git_dir, format, base, inner, rev, config,
1006    )?))
1007}
1008
1009fn resolve_at_selector_ref_name(
1010    git_dir: &Path,
1011    format: sley_core::ObjectFormat,
1012    rev: &str,
1013    config: Option<&GitConfig>,
1014) -> Result<Option<String>> {
1015    let Some(open) = rev.rfind("@{") else {
1016        return Ok(None);
1017    };
1018    let Some(inner) = rev.strip_suffix('}') else {
1019        return Ok(None);
1020    };
1021    let inner = &inner[open + 2..];
1022    if inner.contains('}') {
1023        return Ok(None);
1024    }
1025    let base = &rev[..open];
1026    if let Some(prior) = parse_prior_checkout_selector(rev)? {
1027        let Some(branch) = nth_prior_checkout_branch_name(git_dir, format, prior)? else {
1028            return Err(GitError::not_found(format!(
1029                "not enough previous checkouts to resolve {rev}"
1030            )));
1031        };
1032        return Ok(Some(format!("refs/heads/{branch}")));
1033    }
1034    if inner.eq_ignore_ascii_case("u") || inner.eq_ignore_ascii_case("upstream") {
1035        return Ok(Some(
1036            resolve_upstream_ref(git_dir, format, base, false, rev, config)?.refname,
1037        ));
1038    }
1039    if inner.eq_ignore_ascii_case("push") {
1040        return Ok(Some(
1041            resolve_upstream_ref(git_dir, format, base, true, rev, config)?.refname,
1042        ));
1043    }
1044    if inner.bytes().all(|byte| byte.is_ascii_digit()) || !inner.starts_with('-') {
1045        let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1046        return Ok(Some(reflog_ref_name_for_base(
1047            git_dir, format, &refs, base, config,
1048        )?));
1049    }
1050    Ok(None)
1051}
1052
1053/// Parse the numeric portion of an `@{N}` / `@{-N}` selector.
1054fn parse_at_count(rev: &str, text: &str) -> Result<usize> {
1055    if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) {
1056        return Err(GitError::InvalidFormat(format!(
1057            "invalid revision selector {rev}"
1058        )));
1059    }
1060    text.parse::<usize>()
1061        .map_err(|_| GitError::InvalidFormat(format!("invalid revision selector {rev}")))
1062}
1063
1064fn parse_prior_checkout_selector(rev: &str) -> Result<Option<usize>> {
1065    let Some(inner) = rev
1066        .strip_prefix("@{-")
1067        .and_then(|rest| rest.strip_suffix('}'))
1068    else {
1069        return Ok(None);
1070    };
1071    if !inner.bytes().all(|byte| byte.is_ascii_digit()) {
1072        return Ok(None);
1073    }
1074    Ok(Some(parse_at_count(rev, inner)?))
1075}
1076
1077fn is_reflog_count_or_date_selector(rev: &str) -> bool {
1078    let Some(open) = rev.rfind("@{") else {
1079        return false;
1080    };
1081    let Some(inner) = rev.strip_suffix('}') else {
1082        return false;
1083    };
1084    let inner = &inner[open + 2..];
1085    !(inner.eq_ignore_ascii_case("u")
1086        || inner.eq_ignore_ascii_case("upstream")
1087        || inner.eq_ignore_ascii_case("push")
1088        || inner.starts_with('-'))
1089}
1090
1091/// Map a `<base>@{...}` base to the full ref name whose reflog should be read.
1092///
1093/// An empty base means the current branch's reflog; explicit `HEAD` means the
1094/// HEAD reflog. A short name is DWIM'd through git's `ref_rev_parse_rules`
1095/// (`%s`, `refs/%s`, `refs/tags/%s`, `refs/heads/%s`, `refs/remotes/%s`,
1096/// `refs/remotes/%s/HEAD`), picking the first candidate that has an existing
1097/// reflog — exactly git's `repo_dwim_log`. This is what lets `stash@{N}` read
1098/// `refs/stash`'s reflog (rule `refs/%s`) the same way `main@{N}` reads
1099/// `refs/heads/main` (rule `refs/heads/%s`). When no candidate has a reflog,
1100/// fall back to `refs/heads/<base>` so the "no reflog" error path keeps its
1101/// historical shape.
1102fn reflog_ref_name(refs: &FileRefStore, base: &str) -> String {
1103    if base == "HEAD" {
1104        return "HEAD".to_string();
1105    }
1106    if base.starts_with("refs/") {
1107        return base.to_string();
1108    }
1109    for candidate in reflog_dwim_candidates(base) {
1110        if reflog_exists(refs, &candidate) {
1111            return candidate;
1112        }
1113    }
1114    format!("refs/heads/{base}")
1115}
1116
1117fn reflog_ref_name_for_base(
1118    git_dir: &Path,
1119    format: sley_core::ObjectFormat,
1120    refs: &FileRefStore,
1121    base: &str,
1122    config: Option<&GitConfig>,
1123) -> Result<String> {
1124    if base.is_empty() {
1125        return Ok(refs
1126            .current_branch_ref()?
1127            .unwrap_or_else(|| "HEAD".to_string()));
1128    }
1129    if base == "@" {
1130        return Ok("HEAD".to_string());
1131    }
1132    if let Some(prior) = parse_prior_checkout_selector(base)? {
1133        let Some(branch) = nth_prior_checkout_branch_name(git_dir, format, prior)? else {
1134            return Err(GitError::not_found(format!(
1135                "not enough previous checkouts to resolve {base}"
1136            )));
1137        };
1138        return Ok(reflog_ref_name(refs, &branch));
1139    }
1140    if is_reflog_count_or_date_selector(base) {
1141        return Err(GitError::InvalidFormat(format!(
1142            "invalid revision selector {base}"
1143        )));
1144    }
1145    if base.contains("@{")
1146        && let Some(name) = resolve_at_selector_ref_name(git_dir, format, base, config)?
1147    {
1148        return Ok(name);
1149    }
1150    if base.contains("@{") {
1151        return Err(GitError::InvalidFormat(format!(
1152            "invalid revision selector {base}"
1153        )));
1154    }
1155    Ok(reflog_ref_name(refs, base))
1156}
1157
1158/// git's `ref_rev_parse_rules` expansions for a short ref name, in order.
1159fn reflog_dwim_candidates(base: &str) -> [String; 6] {
1160    [
1161        base.to_string(),
1162        format!("refs/{base}"),
1163        format!("refs/tags/{base}"),
1164        format!("refs/heads/{base}"),
1165        format!("refs/remotes/{base}"),
1166        format!("refs/remotes/{base}/HEAD"),
1167    ]
1168}
1169
1170/// Whether `name` has a reflog, even if it currently has zero entries after
1171/// expiry. Git's `repo_dwim_log` resolves against empty log files too, and
1172/// `<ref>@{0}` then falls back to the ref tip.
1173fn reflog_exists(refs: &FileRefStore, name: &str) -> bool {
1174    refs.reflog_exists(name).unwrap_or(false)
1175}
1176
1177/// Resolve `<base>@{N}` to the N-th prior value of `base` from its reflog.
1178///
1179/// The reflog is stored oldest-first, so `@{0}` is the most recent entry's new
1180/// value and `@{N}` is the new value of the entry `N` positions earlier (which
1181/// equals the old value recorded `N` moves ago). A reflog that is too short to
1182/// satisfy `N` reports a git-style "log for '<base>' only has K entries" error.
1183fn resolve_reflog_nth(
1184    git_dir: &Path,
1185    format: sley_core::ObjectFormat,
1186    base: &str,
1187    n: usize,
1188    rev: &str,
1189    config: Option<&GitConfig>,
1190) -> Result<ObjectId> {
1191    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1192    let ref_name = reflog_ref_name_for_base(git_dir, format, &refs, base, config)?;
1193    let display_name = reflog_display_name_for_ref(base, &ref_name);
1194    let entries = refs.read_reflog(&ref_name)?;
1195    if entries.is_empty() {
1196        if n == 0
1197            && refs.reflog_exists(&ref_name)?
1198            && let Some(oid) = resolve_revision_ref_candidate(&refs, &ref_name)?
1199        {
1200            return Ok(oid);
1201        }
1202        return Err(GitError::not_found(format!(
1203            "no reflog for '{}' to resolve {rev}",
1204            display_name
1205        )));
1206    }
1207    // `@{N}` counts back from the newest entry; index `len - 1 - n`.
1208    let len = entries.len();
1209    if n >= len {
1210        if n == len && !object_id_is_null(&entries[0].old_oid) {
1211            return Ok(entries[0].old_oid);
1212        }
1213        return Err(GitError::not_found(format!(
1214            "log for '{}' only has {len} entries",
1215            display_name
1216        )));
1217    }
1218    Ok(entries[len - 1 - n].new_oid)
1219}
1220
1221fn resolve_reflog_date(
1222    git_dir: &Path,
1223    format: sley_core::ObjectFormat,
1224    base: &str,
1225    date: &str,
1226    rev: &str,
1227    config: Option<&GitConfig>,
1228) -> Result<ObjectId> {
1229    let cutoff = parse_reflog_selector_date(date)
1230        .ok_or_else(|| GitError::Unsupported(format!("revision selector @{{{date}}}")))?;
1231    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1232    let ref_name = reflog_ref_name_for_base(git_dir, format, &refs, base, config)?;
1233    let display_name = reflog_display_name_for_ref(base, &ref_name);
1234    let entries = refs.read_reflog(&ref_name)?;
1235    if entries.is_empty() {
1236        return Err(GitError::not_found(format!(
1237            "no reflog for '{}' to resolve {rev}",
1238            display_name
1239        )));
1240    }
1241    for entry in entries.iter().rev() {
1242        if reflog_entry_timestamp(entry)? <= cutoff {
1243            return Ok(entry.new_oid);
1244        }
1245    }
1246    Ok(entries[0].new_oid)
1247}
1248
1249fn reflog_entry_timestamp(entry: &ReflogEntry) -> Result<i64> {
1250    entry.timestamp_seconds()
1251}
1252
1253fn object_id_is_null(oid: &ObjectId) -> bool {
1254    oid.as_bytes().iter().all(|byte| *byte == 0)
1255}
1256
1257fn parse_reflog_selector_date(value: &str) -> Option<i64> {
1258    if value == "now" {
1259        return std::time::SystemTime::now()
1260            .duration_since(std::time::UNIX_EPOCH)
1261            .ok()
1262            .and_then(|duration| i64::try_from(duration.as_secs()).ok());
1263    }
1264    if let Some(years) = value.strip_suffix(".year.ago") {
1265        let years = years.parse::<i64>().ok()?;
1266        let now = std::time::SystemTime::now()
1267            .duration_since(std::time::UNIX_EPOCH)
1268            .ok()?
1269            .as_secs();
1270        let now = i64::try_from(now).ok()?;
1271        return Some(now.saturating_sub(years.saturating_mul(365 * 86_400)));
1272    }
1273    let mut parts = value.split_ascii_whitespace();
1274    let _weekday = parts.next()?;
1275    let month = parse_reflog_month(parts.next()?)?;
1276    let day = parts.next()?.parse::<u32>().ok()?;
1277    let time = parts.next()?;
1278    let year = parts.next()?.parse::<i64>().ok()?;
1279    let tz = parts.next()?;
1280    if parts.next().is_some() {
1281        return None;
1282    }
1283    let mut time_parts = time.split(':');
1284    let hour = time_parts.next()?.parse::<i64>().ok()?;
1285    let minute = time_parts.next()?.parse::<i64>().ok()?;
1286    let second = time_parts.next()?.parse::<i64>().ok()?;
1287    if time_parts.next().is_some() || hour > 23 || minute > 59 || second > 60 {
1288        return None;
1289    }
1290    let offset = parse_reflog_timezone(tz)?;
1291    Some(days_from_civil(year, month, day)? * 86_400 + hour * 3_600 + minute * 60 + second - offset)
1292}
1293
1294fn parse_reflog_month(value: &str) -> Option<u32> {
1295    match value {
1296        "Jan" => Some(1),
1297        "Feb" => Some(2),
1298        "Mar" => Some(3),
1299        "Apr" => Some(4),
1300        "May" => Some(5),
1301        "Jun" => Some(6),
1302        "Jul" => Some(7),
1303        "Aug" => Some(8),
1304        "Sep" => Some(9),
1305        "Oct" => Some(10),
1306        "Nov" => Some(11),
1307        "Dec" => Some(12),
1308        _ => None,
1309    }
1310}
1311
1312fn parse_reflog_timezone(value: &str) -> Option<i64> {
1313    let bytes = value.as_bytes();
1314    if bytes.len() != 5 || (bytes[0] != b'+' && bytes[0] != b'-') {
1315        return None;
1316    }
1317    let hours = value[1..3].parse::<i64>().ok()?;
1318    let minutes = value[3..5].parse::<i64>().ok()?;
1319    if hours > 23 || minutes > 59 {
1320        return None;
1321    }
1322    let seconds = hours * 3_600 + minutes * 60;
1323    if bytes[0] == b'-' {
1324        Some(-seconds)
1325    } else {
1326        Some(seconds)
1327    }
1328}
1329
1330fn days_from_civil(year: i64, month: u32, day: u32) -> Option<i64> {
1331    if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
1332        return None;
1333    }
1334    let year = year - i64::from(month <= 2);
1335    let era = if year >= 0 { year } else { year - 399 } / 400;
1336    let yoe = year - era * 400;
1337    let month = i64::from(month);
1338    let day = i64::from(day);
1339    let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
1340    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1341    Some(era * 146_097 + doe - 719_468)
1342}
1343
1344fn days_in_month(year: i64, month: u32) -> u32 {
1345    match month {
1346        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1347        4 | 6 | 9 | 11 => 30,
1348        2 if is_leap_year(year) => 29,
1349        2 => 28,
1350        _ => 0,
1351    }
1352}
1353
1354fn is_leap_year(year: i64) -> bool {
1355    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1356}
1357
1358/// Human-facing name for a reflog target in error messages (HEAD, or the branch
1359/// short name without the `refs/heads/` prefix, matching git's wording).
1360fn reflog_display_name(base: &str) -> String {
1361    if base.is_empty() {
1362        "HEAD".to_string()
1363    } else {
1364        base.to_string()
1365    }
1366}
1367
1368fn reflog_display_name_for_ref(base: &str, ref_name: &str) -> String {
1369    if base.is_empty()
1370        && let Some(branch) = ref_name.strip_prefix("refs/heads/")
1371    {
1372        return branch.to_string();
1373    }
1374    if base == "@" {
1375        return "HEAD".to_string();
1376    }
1377    reflog_display_name(base)
1378}
1379
1380/// Resolve `@{-N}` to the tip of the N-th previously checked-out branch.
1381///
1382/// HEAD's reflog is scanned newest-first for "checkout: moving from X to Y"
1383/// entries; the N-th such entry's `X` (the branch we moved *away* from) is the
1384/// answer, which is then resolved to its current tip.
1385fn resolve_previous_checkout(
1386    git_dir: &Path,
1387    format: sley_core::ObjectFormat,
1388    n: usize,
1389    rev: &str,
1390) -> Result<ObjectId> {
1391    if n == 0 {
1392        return Err(GitError::InvalidFormat(format!(
1393            "invalid revision selector {rev}"
1394        )));
1395    }
1396    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1397    let entries = refs.read_reflog("HEAD")?;
1398    let mut seen = 0usize;
1399    for entry in entries.iter().rev() {
1400        let Some(from) = checkout_move_source(&entry.message) else {
1401            continue;
1402        };
1403        seen += 1;
1404        if seen == n {
1405            let from = from.to_string();
1406            return resolve_revision_name(git_dir, format, &from, ObjectDisambiguation::Any)
1407                .map_err(|_| {
1408                    GitError::not_found(format!(
1409                        "could not resolve previous branch '{from}' for {rev}"
1410                    ))
1411                });
1412        }
1413    }
1414    Err(GitError::not_found(format!(
1415        "not enough previous checkouts to resolve {rev}"
1416    )))
1417}
1418
1419/// Extract the source branch `X` from a HEAD reflog message of the form
1420/// "checkout: moving from X to Y", or `None` for any other reflog message.
1421/// The name of the N-th previously checked-out branch (the `X` in the N-th
1422/// newest "checkout: moving from X to Y" HEAD reflog entry), as used by
1423/// `git checkout -`/`@{-N}` to switch *back to that branch by name* rather than
1424/// to a detached commit. Returns `None` when there are fewer than `n` such
1425/// reflog entries.
1426pub fn nth_prior_checkout_branch_name(
1427    git_dir: &Path,
1428    format: sley_core::ObjectFormat,
1429    n: usize,
1430) -> Result<Option<String>> {
1431    if n == 0 {
1432        return Ok(None);
1433    }
1434    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1435    let entries = refs.read_reflog("HEAD")?;
1436    let mut seen = 0usize;
1437    for entry in entries.iter().rev() {
1438        let Some(from) = checkout_move_source(&entry.message) else {
1439            continue;
1440        };
1441        seen += 1;
1442        if seen == n {
1443            return Ok(Some(from.to_string()));
1444        }
1445    }
1446    Ok(None)
1447}
1448
1449fn checkout_move_source(message: &[u8]) -> Option<&str> {
1450    let message = std::str::from_utf8(message).ok()?;
1451    let rest = message.strip_prefix("checkout: moving from ")?;
1452    // The remainder is "X to Y"; git uses the first separator when grabbing X.
1453    let (from, _to) = rest.split_once(" to ")?;
1454    Some(from)
1455}
1456
1457struct UpstreamRef {
1458    refname: String,
1459    merge: String,
1460}
1461
1462impl UpstreamRef {
1463    fn missing_error(&self, _rev: &str) -> GitError {
1464        GitError::not_found(format!(
1465            "upstream branch '{}' not stored as a remote-tracking branch",
1466            self.merge
1467        ))
1468    }
1469}
1470
1471/// Resolve `<base>@{u}` / `@{upstream}` (when `push` is false) or `@{push}`
1472/// (when `push` is true) to the configured tracking ref name.
1473///
1474/// The branch is `base` (or the current branch when `base` is empty). The
1475/// tracking ref is built from `branch.<name>.remote` (or `pushRemote` for the
1476/// push form) plus the short name from `branch.<name>.merge`, yielding
1477/// `refs/remotes/<remote>/<short>`. `@{push}` falls back to the upstream remote
1478/// when no push-specific remote is configured.
1479fn resolve_upstream_ref(
1480    git_dir: &Path,
1481    format: sley_core::ObjectFormat,
1482    base: &str,
1483    push: bool,
1484    rev: &str,
1485    config: Option<&GitConfig>,
1486) -> Result<UpstreamRef> {
1487    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
1488    let branch = if base.is_empty() || base == "HEAD" || base == "@" {
1489        refs.current_branch()?
1490            .ok_or_else(|| GitError::InvalidFormat("HEAD does not point to a branch".to_string()))?
1491    } else if let Some(prior) = parse_prior_checkout_selector(base)? {
1492        nth_prior_checkout_branch_name(git_dir, format, prior)?.ok_or_else(|| {
1493            GitError::not_found(format!("not enough previous checkouts to resolve {base}"))
1494        })?
1495    } else if base.starts_with("refs/") || base.contains("@{") {
1496        return Err(GitError::InvalidFormat(format!(
1497            "{base} is not a branch, cannot resolve {rev}"
1498        )));
1499    } else {
1500        base.to_string()
1501    };
1502    if refs.read_ref(&format!("refs/heads/{branch}"))?.is_none() {
1503        return Err(GitError::not_found(format!("no such branch: '{branch}'")));
1504    }
1505
1506    // Prefer the caller-resolved effective config (includes + `-c` overrides);
1507    // fall back to an include-aware read of `<git_dir>/config` when none was
1508    // threaded in.
1509    let owned_config;
1510    let config = match config {
1511        Some(config) => config,
1512        None => {
1513            owned_config = read_repo_config(git_dir)?;
1514            &owned_config
1515        }
1516    };
1517    // `@{push}` follows git's `branch_get_push()`: pushremote + push refspecs +
1518    // `push.default`, which differ materially from `@{upstream}`'s plain
1519    // `branch.<name>.{remote,merge}` lookup.
1520    if push {
1521        return branch_get_push(&branch, config);
1522    }
1523    let merge = config
1524        .get("branch", Some(&branch), "merge")
1525        .ok_or_else(|| {
1526            GitError::not_found(format!("no upstream configured for branch '{branch}'"))
1527        })?;
1528    let short = merge.strip_prefix("refs/heads/").unwrap_or(merge);
1529    let remote = config
1530        .get("branch", Some(&branch), "remote")
1531        .ok_or_else(|| GitError::not_found(format!("no upstream remote for branch '{branch}'")))?;
1532
1533    let refname = if remote == "." {
1534        merge.to_string()
1535    } else {
1536        format!("refs/remotes/{remote}/{short}")
1537    };
1538    Ok(UpstreamRef {
1539        refname,
1540        merge: merge.to_string(),
1541    })
1542}
1543
1544/// `branch_get_push_1()` from remote.c: resolve `<branch>@{push}` to its push
1545/// tracking ref. Determines the pushremote, applies explicit push refspecs when
1546/// present, and otherwise dispatches on `push.default`.
1547fn branch_get_push(branch: &str, config: &GitConfig) -> Result<UpstreamRef> {
1548    let merge = config
1549        .get("branch", Some(branch), "merge")
1550        .map(str::to_string);
1551    let pushremote = config
1552        .get("branch", Some(branch), "pushRemote")
1553        .or_else(|| config.get("remote", None, "pushDefault"))
1554        .or_else(|| config.get("branch", Some(branch), "remote"))
1555        .ok_or_else(|| GitError::not_found(format!("branch '{branch}' has no remote for pushing")))?
1556        .to_string();
1557    let branch_refname = format!("refs/heads/{branch}");
1558
1559    let upstream_ref = |refname: String| UpstreamRef {
1560        refname,
1561        merge: merge.clone().unwrap_or_default(),
1562    };
1563
1564    // Explicit push refspecs win over `push.default`: map the local branch ref
1565    // through them, then through the pushremote's fetch refspecs.
1566    let push_refspecs: Vec<&str> = config
1567        .get_all("remote", Some(&pushremote), "push")
1568        .into_iter()
1569        .flatten()
1570        .collect();
1571    if !push_refspecs.is_empty() {
1572        let dst = apply_refspecs(&push_refspecs, &branch_refname).ok_or_else(|| {
1573            GitError::not_found(format!(
1574                "push refspecs for '{pushremote}' do not include '{branch}'"
1575            ))
1576        })?;
1577        return Ok(upstream_ref(tracking_for_push_dest(
1578            config,
1579            &pushremote,
1580            &dst,
1581        )?));
1582    }
1583
1584    match config.get("push", None, "default").unwrap_or("simple") {
1585        "nothing" => Err(GitError::not_found(
1586            "push has no destination (push.default is 'nothing')".to_string(),
1587        )),
1588        "matching" | "current" => Ok(upstream_ref(tracking_for_push_dest(
1589            config,
1590            &pushremote,
1591            &branch_refname,
1592        )?)),
1593        "upstream" | "tracking" => Ok(upstream_ref(branch_get_upstream_refname(
1594            config,
1595            branch,
1596            merge.as_deref(),
1597        )?)),
1598        // "simple" and any unrecognised/unspecified value: push to the same-named
1599        // branch, but only when that coincides with the upstream destination.
1600        _ => {
1601            let up = branch_get_upstream_refname(config, branch, merge.as_deref())?;
1602            let cur = tracking_for_push_dest(config, &pushremote, &branch_refname)?;
1603            if cur != up {
1604                return Err(GitError::not_found(
1605                    "cannot resolve 'simple' push to a single destination".to_string(),
1606                ));
1607            }
1608            Ok(upstream_ref(cur))
1609        }
1610    }
1611}
1612
1613/// The upstream tracking ref of `branch` (`branch_get_upstream()` →
1614/// `branch->merge[0]->dst`): the `branch.<name>.merge` ref mapped through the
1615/// fetch refspecs of `branch.<name>.remote`.
1616fn branch_get_upstream_refname(
1617    config: &GitConfig,
1618    branch: &str,
1619    merge: Option<&str>,
1620) -> Result<String> {
1621    let merge = merge.filter(|merge| !merge.is_empty()).ok_or_else(|| {
1622        GitError::not_found(format!("no upstream configured for branch '{branch}'"))
1623    })?;
1624    let remote = config
1625        .get("branch", Some(branch), "remote")
1626        .ok_or_else(|| {
1627            GitError::not_found(format!("no upstream configured for branch '{branch}'"))
1628        })?;
1629    if remote == "." {
1630        return Ok(merge.to_string());
1631    }
1632    tracking_for_push_dest(config, remote, merge)
1633}
1634
1635/// `tracking_for_push_dest()`: the local tracking ref for `refname` on `remote`,
1636/// produced by applying that remote's fetch refspecs. When no fetch refspec
1637/// matches — e.g. a remote configured only via `branch.<name>.{remote,merge}`
1638/// with no `[remote]` section, or any remote lacking an explicit `fetch` line —
1639/// fall back to the conventional `refs/remotes/<remote>/<short>` mapping, the
1640/// same direct construction the `@{upstream}` path uses. This keeps `@{push}`
1641/// consistent with `@{u}` and matches git's result for the standard
1642/// `+refs/heads/*:refs/remotes/<remote>/*` layout without requiring the refspec
1643/// to be spelled out.
1644fn tracking_for_push_dest(config: &GitConfig, remote: &str, refname: &str) -> Result<String> {
1645    let fetch_refspecs: Vec<&str> = config
1646        .get_all("remote", Some(remote), "fetch")
1647        .into_iter()
1648        .flatten()
1649        .collect();
1650    if let Some(dst) = apply_refspecs(&fetch_refspecs, refname) {
1651        return Ok(dst);
1652    }
1653    let short = refname.strip_prefix("refs/heads/").unwrap_or(refname);
1654    Ok(format!("refs/remotes/{remote}/{short}"))
1655}
1656
1657/// Apply a list of refspecs to a single ref, returning the first matching
1658/// destination. Handles the exact `<src>:<dst>` and wildcard `<p>*:<q>*` forms
1659/// (a trailing `*` matches an arbitrary suffix, slashes included); a leading `+`
1660/// is ignored and a colon-less spec maps a ref to itself.
1661fn apply_refspecs(refspecs: &[&str], refname: &str) -> Option<String> {
1662    for spec in refspecs {
1663        let spec = spec.strip_prefix('+').unwrap_or(spec);
1664        let (src, dst) = spec.split_once(':').unwrap_or((spec, spec));
1665        if let Some(src_prefix) = src.strip_suffix('*') {
1666            if let (Some(suffix), Some(dst_prefix)) =
1667                (refname.strip_prefix(src_prefix), dst.strip_suffix('*'))
1668            {
1669                return Some(format!("{dst_prefix}{suffix}"));
1670            }
1671        } else if src == refname {
1672            return Some(dst.to_string());
1673        }
1674    }
1675    None
1676}
1677
1678/// Read the repository config (`<git_dir>/config`), resolving `include`/`includeIf`
1679/// directives and layering inherited `GIT_CONFIG_*` overrides.
1680///
1681/// This is the fallback used when a caller did not thread its already-resolved
1682/// effective config in via [`resolve_revision_with_config`]; it shares
1683/// [`sley_config::read_repo_config`] so a missing file is treated as empty and
1684/// includes are honoured. (Command-line `-c` overrides the CLI holds in-process
1685/// are only visible when the caller passes the resolved config explicitly.)
1686fn read_repo_config(git_dir: &Path) -> Result<GitConfig> {
1687    sley_config::read_repo_config(git_dir, None)
1688}
1689
1690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1691enum RevisionSuffix<'a> {
1692    Parent(usize),
1693    FirstParent(usize),
1694    Peel(PeelKind),
1695    /// `<rev>^{/text}` — first matching commit in `<rev>`'s first-parent ancestry.
1696    Search(&'a str),
1697}
1698
1699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1700enum PeelKind {
1701    AnyNonTag,
1702    Object,
1703    Commit,
1704    Tree,
1705    Tag,
1706    Blob,
1707}
1708
1709fn split_revision_suffix(rev: &str) -> Result<Option<(&str, RevisionSuffix<'_>)>> {
1710    let caret = rev.rfind('^');
1711    let tilde = rev.rfind('~');
1712    let Some((op, pos)) = (match (caret, tilde) {
1713        (Some(caret), Some(tilde)) if caret > tilde => Some(('^', caret)),
1714        (Some(caret), Some(tilde)) if tilde > caret => Some(('~', tilde)),
1715        (Some(caret), None) => Some(('^', caret)),
1716        (None, Some(tilde)) => Some(('~', tilde)),
1717        (None, None) => None,
1718        _ => None,
1719    }) else {
1720        return Ok(None);
1721    };
1722    let (base, suffix) = rev.split_at(pos);
1723    let suffix = &suffix[1..];
1724    match op {
1725        '^' => {
1726            if let Some(text) = parse_search_suffix(rev, suffix)? {
1727                return Ok(Some((base, RevisionSuffix::Search(text))));
1728            }
1729            let parent = if suffix.is_empty() {
1730                1
1731            } else if let Some(kind) = parse_peel_suffix(rev, suffix)? {
1732                return Ok(Some((base, RevisionSuffix::Peel(kind))));
1733            } else if suffix.bytes().all(|byte| byte.is_ascii_digit()) {
1734                parse_revision_count(rev, suffix)?
1735            } else {
1736                return Ok(None);
1737            };
1738            Ok(Some((base, RevisionSuffix::Parent(parent))))
1739        }
1740        '~' => {
1741            let count = if suffix.is_empty() {
1742                1
1743            } else if suffix.bytes().all(|byte| byte.is_ascii_digit()) {
1744                parse_revision_count(rev, suffix)?
1745            } else {
1746                return Ok(None);
1747            };
1748            Ok(Some((base, RevisionSuffix::FirstParent(count))))
1749        }
1750        _ => Ok(None),
1751    }
1752}
1753
1754fn parse_peel_suffix(rev: &str, suffix: &str) -> Result<Option<PeelKind>> {
1755    if !suffix.starts_with('{') {
1756        return Ok(None);
1757    }
1758    let Some(kind) = suffix
1759        .strip_prefix('{')
1760        .and_then(|value| value.strip_suffix('}'))
1761    else {
1762        return Err(GitError::InvalidFormat(format!(
1763            "invalid revision peel suffix in {rev}"
1764        )));
1765    };
1766    let kind = match kind {
1767        "" => PeelKind::AnyNonTag,
1768        "object" => PeelKind::Object,
1769        "commit" => PeelKind::Commit,
1770        "tree" => PeelKind::Tree,
1771        "tag" => PeelKind::Tag,
1772        "blob" => PeelKind::Blob,
1773        other => {
1774            return Err(GitError::Unsupported(format!(
1775                "revision peel suffix ^{{{other}}}"
1776            )));
1777        }
1778    };
1779    Ok(Some(kind))
1780}
1781
1782fn parse_search_suffix<'a>(rev: &str, suffix: &'a str) -> Result<Option<&'a str>> {
1783    let Some(inner) = suffix.strip_prefix("{/") else {
1784        return Ok(None);
1785    };
1786    let Some(text) = inner.strip_suffix('}') else {
1787        return Err(GitError::InvalidFormat(format!(
1788            "invalid revision search suffix in {rev}"
1789        )));
1790    };
1791    Ok(Some(text))
1792}
1793
1794fn parse_revision_count(rev: &str, text: &str) -> Result<usize> {
1795    text.parse::<usize>()
1796        .map_err(|_| GitError::InvalidFormat(format!("invalid revision suffix in {rev}")))
1797}
1798
1799/// Peel `base` to a commit for `~N`/`^N` navigation, but only read the object
1800/// when necessary: a base already present in the commit-graph is a commit, so it
1801/// is returned without a read (preserving the graph-only navigation fast path).
1802/// A base absent from the graph is read; commits pass through, annotated tags are
1803/// followed to their commit.
1804fn peel_base_to_commit_if_needed<R: ObjectReader>(
1805    reader: &R,
1806    format: sley_core::ObjectFormat,
1807    graph: &mut CommitGraphContext<'_>,
1808    base: &ObjectId,
1809) -> Result<ObjectId> {
1810    if graph.lookup(base)?.is_some() {
1811        return Ok(*base);
1812    }
1813    peel_to_commit(reader, format, base)
1814}
1815
1816fn apply_revision_suffix<R: ObjectReader>(
1817    git_dir: &Path,
1818    reader: &R,
1819    format: sley_core::ObjectFormat,
1820    base: &ObjectId,
1821    suffix: RevisionSuffix<'_>,
1822    raw_rev: &str,
1823) -> Result<ObjectId> {
1824    match suffix {
1825        RevisionSuffix::Parent(parent) => {
1826            if parent == 0 {
1827                // `<rev>^0` is not "the 0th parent" — git defines it as "peel to
1828                // a commit": dereference tags/etc. down to the commit object the
1829                // revision names. For an annotated tag this follows the tag to
1830                // its commit; for a commit it is the commit itself.
1831                let _ = raw_rev;
1832                return peel_revision(reader, format, base, PeelKind::Commit);
1833            }
1834            // git peels the base to a commit before taking the Nth parent, so
1835            // `<annotated-tag>^N` follows the tag to its commit first. Peeling
1836            // reads the object, so skip it when the graph already covers the base
1837            // (a commit) to preserve the graph-only navigation fast path.
1838            let mut graph = CommitGraphContext::load(git_dir, format);
1839            let grafts = revlist::load_commit_grafts_from_git_dir(git_dir, format);
1840            let base = peel_base_to_commit_if_needed(reader, format, &mut graph, base)?;
1841            revision_suffix_commit_parents(&mut graph, reader, format, &base, &grafts)?
1842                .get(parent - 1)
1843                .cloned()
1844                .ok_or_else(|| GitError::not_found(format!("parent {parent} of {base}")))
1845        }
1846        RevisionSuffix::FirstParent(count) => {
1847            // Likewise `<annotated-tag>~N` peels to the commit before walking
1848            // first parents (skipping the read when the graph covers the base).
1849            let mut graph = CommitGraphContext::load(git_dir, format);
1850            let grafts = revlist::load_commit_grafts_from_git_dir(git_dir, format);
1851            let mut current = peel_base_to_commit_if_needed(reader, format, &mut graph, base)?;
1852            for _ in 0..count {
1853                current =
1854                    revision_suffix_commit_parents(&mut graph, reader, format, &current, &grafts)?
1855                        .into_iter()
1856                        .next()
1857                        .ok_or_else(|| GitError::not_found(format!("first parent of {current}")))?;
1858            }
1859            Ok(current)
1860        }
1861        RevisionSuffix::Peel(kind) => peel_revision(reader, format, base, kind),
1862        RevisionSuffix::Search(text) => {
1863            search_commit_message_first_parent(git_dir, reader, format, base, text)
1864        }
1865    }
1866}
1867
1868fn revision_suffix_commit_parents<R: ObjectReader>(
1869    graph: &mut CommitGraphContext,
1870    reader: &R,
1871    format: sley_core::ObjectFormat,
1872    oid: &ObjectId,
1873    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
1874) -> Result<Vec<ObjectId>> {
1875    if let Some(parents) = grafts.get(oid) {
1876        return Ok(sley_odb::grafted_parents(reader, oid, parents.clone()));
1877    }
1878    if grafts.is_empty() {
1879        return graph.commit_parents(reader, oid);
1880    }
1881    commit_parents(reader, format, oid)
1882}
1883
1884// ---------------------------------------------------------------------------
1885// Commit-graph acceleration
1886// ---------------------------------------------------------------------------
1887//
1888// History walks (ancestry for `A..B`/`A...B`, `merge_bases`, `is_ancestor`, the
1889// `^`/`~` navigation suffixes, and `^{/text}` first-parent search) read a
1890// commit's parents, commit date, and generation number from the commit-graph
1891// when one is present, avoiding a read+inflate of every commit object from the
1892// odb. The graph is loaded once per walk (lazily, on first lookup) and lookups
1893// are keyed by oid. Any commit absent from the graph -- or the absence of a
1894// graph entirely -- falls back to reading the commit object, so results are
1895// always identical to the object-only walk.
1896//
1897// Generation numbers (topological "height", where a commit's generation is one
1898// greater than the maximum of its parents') let merge-base and ancestor queries
1899// prune branches that cannot contribute: an ancestor's generation is strictly
1900// smaller than its descendant's, so a candidate whose generation is already
1901// below a target can never reach that target and its parents need not be
1902// visited. A graph written without generation numbers stores generation 0 for
1903// every commit (GENERATION_NUMBER_ZERO); pruning is disabled in that case to
1904// stay correct.
1905
1906/// Generation number used by git when a commit-graph has no usable generation
1907/// data; treated as "unknown" so it never drives pruning.
1908const GENERATION_NUMBER_ZERO: u32 = 0;
1909
1910/// Parent object ids resolved from a commit-graph entry.
1911///
1912/// Most commits have zero, one, or two parents. Keeping those cases inline
1913/// avoids a heap allocation per graph commit while preserving a `Vec` escape
1914/// hatch for octopus merges.
1915#[derive(Debug, Clone)]
1916enum GraphParents {
1917    None,
1918    One(ObjectId),
1919    Two([ObjectId; 2]),
1920    Many(Vec<ObjectId>),
1921}
1922
1923impl GraphParents {
1924    fn from_oids<I>(parents: I) -> Self
1925    where
1926        I: IntoIterator<Item = ObjectId>,
1927    {
1928        let mut parents = parents.into_iter();
1929        let Some(first) = parents.next() else {
1930            return Self::None;
1931        };
1932        let Some(second) = parents.next() else {
1933            return Self::One(first);
1934        };
1935        let Some(third) = parents.next() else {
1936            return Self::Two([first, second]);
1937        };
1938        let (lower, _) = parents.size_hint();
1939        let mut many = Vec::with_capacity(3 + lower);
1940        many.push(first);
1941        many.push(second);
1942        many.push(third);
1943        many.extend(parents);
1944        Self::Many(many)
1945    }
1946
1947    fn is_empty(&self) -> bool {
1948        matches!(self, Self::None)
1949    }
1950
1951    fn first(&self) -> Option<ObjectId> {
1952        match self {
1953            Self::None => None,
1954            Self::One(parent) => Some(*parent),
1955            Self::Two(parents) => Some(parents[0]),
1956            Self::Many(parents) => parents.first().copied(),
1957        }
1958    }
1959
1960    fn iter(&self) -> GraphParentIter<'_> {
1961        match self {
1962            Self::None => GraphParentIter::Empty,
1963            Self::One(parent) => GraphParentIter::One(Some(*parent)),
1964            Self::Two(parents) => GraphParentIter::Slice(parents.iter().copied()),
1965            Self::Many(parents) => GraphParentIter::Slice(parents.iter().copied()),
1966        }
1967    }
1968
1969    fn to_vec(&self) -> Vec<ObjectId> {
1970        match self {
1971            Self::None => Vec::new(),
1972            Self::One(parent) => vec![*parent],
1973            Self::Two(parents) => parents.to_vec(),
1974            Self::Many(parents) => parents.clone(),
1975        }
1976    }
1977
1978    fn grafted_vec<R: ObjectReader>(&self, reader: &R, oid: &ObjectId) -> Vec<ObjectId> {
1979        if reader.is_shallow_graft(oid) {
1980            Vec::new()
1981        } else {
1982            self.to_vec()
1983        }
1984    }
1985}
1986
1987enum GraphParentIter<'a> {
1988    Empty,
1989    One(Option<ObjectId>),
1990    Slice(std::iter::Copied<std::slice::Iter<'a, ObjectId>>),
1991}
1992
1993impl Iterator for GraphParentIter<'_> {
1994    type Item = ObjectId;
1995
1996    fn next(&mut self) -> Option<Self::Item> {
1997        match self {
1998            Self::Empty => None,
1999            Self::One(parent) => parent.take(),
2000            Self::Slice(parents) => parents.next(),
2001        }
2002    }
2003
2004    fn size_hint(&self) -> (usize, Option<usize>) {
2005        match self {
2006            Self::Empty => (0, Some(0)),
2007            Self::One(Some(_)) => (1, Some(1)),
2008            Self::One(None) => (0, Some(0)),
2009            Self::Slice(parents) => parents.size_hint(),
2010        }
2011    }
2012}
2013
2014impl ExactSizeIterator for GraphParentIter<'_> {}
2015
2016enum CommitParentIds<'a> {
2017    Empty,
2018    Borrowed(GraphParentIter<'a>),
2019    Owned(std::vec::IntoIter<ObjectId>),
2020}
2021
2022impl<'a> CommitParentIds<'a> {
2023    fn borrowed(parents: &'a GraphParents) -> Self {
2024        Self::Borrowed(parents.iter())
2025    }
2026
2027    fn owned(parents: Vec<ObjectId>) -> Self {
2028        Self::Owned(parents.into_iter())
2029    }
2030}
2031
2032impl Iterator for CommitParentIds<'_> {
2033    type Item = ObjectId;
2034
2035    fn next(&mut self) -> Option<Self::Item> {
2036        match self {
2037            Self::Empty => None,
2038            Self::Borrowed(parents) => parents.next(),
2039            Self::Owned(parents) => parents.next(),
2040        }
2041    }
2042}
2043
2044/// Commit metadata resolved from the commit-graph: parents (already mapped from
2045/// graph indices to object ids), generation number, and committer date.
2046#[derive(Debug, Clone)]
2047struct GraphCommit {
2048    parents: GraphParents,
2049    generation: u32,
2050    commit_time: u64,
2051}
2052
2053struct GraphCommitMetadata<'a> {
2054    parents: &'a GraphParents,
2055    commit_time: i64,
2056}
2057
2058#[derive(Debug, Clone)]
2059struct GraphBloomCommit {
2060    parents: GraphParents,
2061    filter: Option<Vec<u8>>,
2062    settings: sley_formats::CommitGraphBloomSettings,
2063}
2064
2065#[derive(Debug, Clone, Copy, Default)]
2066struct GraphBloomStats {
2067    filter_not_present: usize,
2068    maybe: usize,
2069    definitely_not: usize,
2070    false_positive: usize,
2071}
2072
2073#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2074enum GraphBloomConsult {
2075    DefinitelyNot,
2076    Maybe,
2077    NotPresent,
2078    NotInGraph,
2079}
2080
2081/// A walk's view of the commit-graph.
2082///
2083/// Construction is cheap and infallible (`load` only records the git dir and
2084/// object format); the graph file is read and parsed on the first lookup and
2085/// cached for the remainder of the walk. Lookups return resolved [`GraphCommit`]
2086/// metadata keyed by oid, or `None` when the commit is not represented (so the
2087/// caller falls back to the odb). If the graph file is missing, empty, or fails
2088/// to parse, the context degrades to "no graph" and every lookup misses, which
2089/// keeps walk results identical to the pure object-reading path.
2090struct CommitGraphContext<'a> {
2091    git_dir: &'a Path,
2092    format: sley_core::ObjectFormat,
2093    /// Direct parsed monolithic commit-graph for hot metadata walks. This avoids
2094    /// materializing every graph entry into a `HashMap` when callers only need a
2095    /// handful of commits (for example `log -50`).
2096    direct_graph: Option<DirectCommitGraph>,
2097    /// `None` until the first lookup forces a load. Missing graphs cache as an
2098    /// empty map; corrupt graphs cache their parse error so callers fail instead
2099    /// of silently walking stale object data.
2100    commits: Option<std::result::Result<HashMap<ObjectId, GraphCommit>, String>>,
2101}
2102
2103enum DirectCommitGraph {
2104    Missing,
2105    Invalid(String),
2106    Raw(Box<RawCommitGraph>),
2107}
2108
2109struct RawCommitGraph {
2110    bytes: RawCommitGraphBytes,
2111    format: ObjectFormat,
2112    fanout: [u32; 256],
2113    commit_count: usize,
2114    entry_len: usize,
2115    oidl: Range<usize>,
2116    cdat: Range<usize>,
2117    edge: Option<Range<usize>>,
2118}
2119
2120struct RawCommitGraphCountState {
2121    seen: Vec<u64>,
2122    pending: Vec<usize>,
2123}
2124
2125impl RawCommitGraphCountState {
2126    fn new(commit_count: usize) -> Self {
2127        Self {
2128            seen: vec![0u64; commit_count.div_ceil(64)],
2129            pending: Vec::new(),
2130        }
2131    }
2132}
2133
2134enum RawCommitGraphBytes {
2135    Owned(Vec<u8>),
2136    Mapped(sley_mmap::MappedFile),
2137}
2138
2139impl AsRef<[u8]> for RawCommitGraphBytes {
2140    fn as_ref(&self) -> &[u8] {
2141        match self {
2142            Self::Owned(bytes) => bytes,
2143            Self::Mapped(bytes) => bytes.as_bytes(),
2144        }
2145    }
2146}
2147
2148impl RawCommitGraph {
2149    fn parse_for_lookup(bytes: RawCommitGraphBytes, format: ObjectFormat) -> Result<Self> {
2150        let data = bytes.as_ref();
2151        let hash_len = format.raw_len();
2152        if data.len() < 8 + 12 + hash_len {
2153            return Err(GitError::InvalidFormat(
2154                "commit-graph file too short".into(),
2155            ));
2156        }
2157        if &data[..4] != b"CGPH" {
2158            return Err(GitError::InvalidFormat(
2159                "missing commit-graph signature".into(),
2160            ));
2161        }
2162        let version = data[4];
2163        if version != 1 {
2164            return Err(GitError::Unsupported(format!(
2165                "commit-graph version {version}"
2166            )));
2167        }
2168        let hash_id = data[5];
2169        if u32::from(hash_id) != commit_graph_hash_function_id(format) {
2170            return Err(GitError::InvalidFormat(format!(
2171                "commit-graph hash id {hash_id} does not match {}",
2172                format.name()
2173            )));
2174        }
2175        if data[7] != 0 {
2176            return Err(GitError::Unsupported(
2177                "split commit-graph direct lookup".into(),
2178            ));
2179        }
2180        let chunk_count = data[6] as usize;
2181        let lookup_len = (chunk_count + 1)
2182            .checked_mul(12)
2183            .ok_or_else(|| GitError::InvalidFormat("commit-graph lookup overflow".into()))?;
2184        let data_start = 8usize
2185            .checked_add(lookup_len)
2186            .ok_or_else(|| GitError::InvalidFormat("commit-graph lookup overflow".into()))?;
2187        let checksum_offset = data.len() - hash_len;
2188        if data_start > checksum_offset {
2189            return Err(GitError::InvalidFormat(
2190                "truncated commit-graph chunk lookup".into(),
2191            ));
2192        }
2193
2194        let mut lookup = Vec::with_capacity(chunk_count + 1);
2195        let mut offset = 8usize;
2196        for _ in 0..=chunk_count {
2197            let id = [
2198                data[offset],
2199                data[offset + 1],
2200                data[offset + 2],
2201                data[offset + 3],
2202            ];
2203            let chunk_offset = read_u64_be(&data[offset + 4..offset + 12]);
2204            lookup.push((id, chunk_offset));
2205            offset += 12;
2206        }
2207        let Some((terminator_id, terminator_offset)) = lookup.last().copied() else {
2208            return Err(GitError::InvalidFormat(
2209                "commit-graph chunk lookup is empty".into(),
2210            ));
2211        };
2212        if terminator_id != [0, 0, 0, 0] {
2213            return Err(GitError::InvalidFormat(
2214                "commit-graph chunk lookup missing terminator".into(),
2215            ));
2216        }
2217        if terminator_offset != checksum_offset as u64 {
2218            return Err(GitError::InvalidFormat(
2219                "commit-graph terminator does not point at checksum".into(),
2220            ));
2221        }
2222
2223        let mut chunks = Vec::with_capacity(chunk_count);
2224        let mut previous_offset = data_start;
2225        for pair in lookup.windows(2) {
2226            let (id, chunk_offset) = pair[0];
2227            let (_next_id, next_offset) = pair[1];
2228            if id == [0, 0, 0, 0] {
2229                return Err(GitError::InvalidFormat(
2230                    "commit-graph chunk id is zero before terminator".into(),
2231                ));
2232            }
2233            if chunks
2234                .iter()
2235                .any(|(seen, _): &([u8; 4], Range<usize>)| *seen == id)
2236            {
2237                return Err(GitError::InvalidFormat(
2238                    "commit-graph chunk id is duplicated".into(),
2239                ));
2240            }
2241            let start = usize::try_from(chunk_offset).map_err(|_| {
2242                GitError::InvalidFormat("commit-graph chunk offset overflow".into())
2243            })?;
2244            let end = usize::try_from(next_offset).map_err(|_| {
2245                GitError::InvalidFormat("commit-graph chunk offset overflow".into())
2246            })?;
2247            if start < data_start || start < previous_offset || end < start || end > checksum_offset
2248            {
2249                return Err(GitError::InvalidFormat(
2250                    "commit-graph chunk length is invalid".into(),
2251                ));
2252            }
2253            chunks.push((id, start..end));
2254            previous_offset = start;
2255        }
2256
2257        let oidf = raw_commit_graph_chunk(&chunks, *b"OIDF")
2258            .ok_or_else(|| GitError::InvalidFormat("commit-graph missing OIDF chunk".into()))?;
2259        if oidf.len() != 256 * 4 {
2260            return Err(GitError::InvalidFormat(
2261                "commit-graph OIDF chunk has invalid length".into(),
2262            ));
2263        }
2264        let mut fanout = [0u32; 256];
2265        let mut previous = 0u32;
2266        for (idx, slot) in fanout.iter_mut().enumerate() {
2267            let start = oidf.start + idx * 4;
2268            *slot = read_u32_be(&data[start..start + 4]);
2269            if *slot < previous {
2270                return Err(GitError::InvalidFormat(
2271                    "commit-graph OIDF fanout is not monotonic".into(),
2272                ));
2273            }
2274            previous = *slot;
2275        }
2276        let commit_count = fanout[255] as usize;
2277        let oidl = raw_commit_graph_chunk(&chunks, *b"OIDL")
2278            .ok_or_else(|| GitError::InvalidFormat("commit-graph missing OIDL chunk".into()))?;
2279        let expected_oidl_len = commit_count
2280            .checked_mul(hash_len)
2281            .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL chunk overflow".into()))?;
2282        if oidl.len() != expected_oidl_len {
2283            return Err(GitError::InvalidFormat(
2284                "commit-graph OIDL chunk has invalid length".into(),
2285            ));
2286        }
2287        let cdat = raw_commit_graph_chunk(&chunks, *b"CDAT")
2288            .ok_or_else(|| GitError::InvalidFormat("commit-graph missing CDAT chunk".into()))?;
2289        let entry_len = raw_commit_graph_entry_len(format)?;
2290        let expected_cdat_len = commit_count
2291            .checked_mul(entry_len)
2292            .ok_or_else(|| GitError::InvalidFormat("commit-graph CDAT chunk overflow".into()))?;
2293        if cdat.len() != expected_cdat_len {
2294            return Err(GitError::InvalidFormat(
2295                "commit-graph CDAT chunk has invalid length".into(),
2296            ));
2297        }
2298        let edge = raw_commit_graph_chunk(&chunks, *b"EDGE");
2299        if let Some(edge) = &edge
2300            && edge.len() % 4 != 0
2301        {
2302            return Err(GitError::InvalidFormat(
2303                "commit-graph EDGE chunk has invalid length".into(),
2304            ));
2305        }
2306        raw_commit_graph_validate_generation_data(data, &chunks, commit_count)?;
2307
2308        Ok(Self {
2309            bytes,
2310            format,
2311            fanout,
2312            commit_count,
2313            entry_len,
2314            oidl,
2315            cdat,
2316            edge,
2317        })
2318    }
2319
2320    fn metadata(&self, oid: &ObjectId) -> Result<Option<CommitMetadata>> {
2321        if oid.format() != self.format {
2322            return Ok(None);
2323        }
2324        let Some(idx) = self.find_index(oid)? else {
2325            return Ok(None);
2326        };
2327        let entry = self.cdat_entry(idx)?;
2328        let hash_len = self.format.raw_len();
2329        let parent_one = read_u32_be(&entry[hash_len..hash_len + 4]);
2330        let parent_two = read_u32_be(&entry[hash_len + 4..hash_len + 8]);
2331        let generation_and_time_high = read_u32_be(&entry[hash_len + 8..hash_len + 12]);
2332        let time_low = read_u32_be(&entry[hash_len + 12..hash_len + 16]);
2333        let commit_time = (u64::from(generation_and_time_high & 0x3) << 32) | u64::from(time_low);
2334        Ok(Some(CommitMetadata {
2335            oid: *oid,
2336            parents: self.parent_oids(parent_one, parent_two)?,
2337            commit_time: i64::try_from(commit_time).unwrap_or(i64::MAX),
2338        }))
2339    }
2340
2341    fn tree_oid(&self, oid: &ObjectId) -> Result<Option<ObjectId>> {
2342        if oid.format() != self.format {
2343            return Ok(None);
2344        }
2345        let Some(idx) = self.find_index(oid)? else {
2346            return Ok(None);
2347        };
2348        let entry = self.cdat_entry(idx)?;
2349        let hash_len = self.format.raw_len();
2350        ObjectId::from_raw(self.format, &entry[..hash_len]).map(Some)
2351    }
2352
2353    fn count_reachable_indices(
2354        &self,
2355        starts: &[usize],
2356        first_parent: bool,
2357        state: &mut RawCommitGraphCountState,
2358    ) -> Result<usize> {
2359        state.pending.extend(starts.iter().copied());
2360        let mut count = 0usize;
2361        while let Some(idx) = state.pending.pop() {
2362            if idx >= self.commit_count {
2363                return Err(GitError::InvalidFormat(
2364                    "commit-graph traversal index points past table".into(),
2365                ));
2366            }
2367            let word = idx / 64;
2368            let bit = 1u64 << (idx % 64);
2369            if state.seen[word] & bit != 0 {
2370                continue;
2371            }
2372            state.seen[word] |= bit;
2373            count += 1;
2374            self.push_parent_indices_for_entry(idx, first_parent, &mut state.pending)?;
2375        }
2376        Ok(count)
2377    }
2378
2379    fn find_index(&self, oid: &ObjectId) -> Result<Option<usize>> {
2380        let first = oid.as_bytes()[0] as usize;
2381        let mut low = if first == 0 {
2382            0
2383        } else {
2384            self.fanout[first - 1] as usize
2385        };
2386        let mut high = self.fanout[first] as usize;
2387        let needle = oid.as_bytes();
2388        while low < high {
2389            let mid = low + (high - low) / 2;
2390            match self.oid_bytes(mid)?.cmp(needle) {
2391                std::cmp::Ordering::Less => low = mid + 1,
2392                std::cmp::Ordering::Greater => high = mid,
2393                std::cmp::Ordering::Equal => return Ok(Some(mid)),
2394            }
2395        }
2396        Ok(None)
2397    }
2398
2399    fn oid_bytes(&self, idx: usize) -> Result<&[u8]> {
2400        if idx >= self.commit_count {
2401            return Err(GitError::InvalidFormat(
2402                "commit-graph oid index points past table".into(),
2403            ));
2404        }
2405        let hash_len = self.format.raw_len();
2406        let start = self
2407            .oidl
2408            .start
2409            .checked_add(idx.checked_mul(hash_len).ok_or_else(|| {
2410                GitError::InvalidFormat("commit-graph OIDL index overflow".into())
2411            })?)
2412            .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL index overflow".into()))?;
2413        let end = start
2414            .checked_add(hash_len)
2415            .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL index overflow".into()))?;
2416        self.bytes
2417            .as_ref()
2418            .get(start..end)
2419            .ok_or_else(|| GitError::InvalidFormat("commit-graph OIDL index overflow".into()))
2420    }
2421
2422    fn oid_at(&self, idx: u32) -> Result<ObjectId> {
2423        let idx = usize::try_from(idx)
2424            .map_err(|_| GitError::InvalidFormat("commit-graph parent index overflow".into()))?;
2425        ObjectId::from_raw(self.format, self.oid_bytes(idx)?)
2426    }
2427
2428    fn cdat_entry(&self, idx: usize) -> Result<&[u8]> {
2429        if idx >= self.commit_count {
2430            return Err(GitError::InvalidFormat(
2431                "commit-graph CDAT index points past table".into(),
2432            ));
2433        }
2434        let start = self.cdat.start + idx * self.entry_len;
2435        let end = start + self.entry_len;
2436        self.bytes
2437            .as_ref()
2438            .get(start..end)
2439            .ok_or_else(|| GitError::InvalidFormat("commit-graph CDAT index overflow".into()))
2440    }
2441
2442    fn push_parent_indices_for_entry(
2443        &self,
2444        idx: usize,
2445        first_parent: bool,
2446        out: &mut Vec<usize>,
2447    ) -> Result<()> {
2448        let entry = self.cdat_entry(idx)?;
2449        let hash_len = self.format.raw_len();
2450        let parent_one = read_u32_be(&entry[hash_len..hash_len + 4]);
2451        let parent_two = read_u32_be(&entry[hash_len + 4..hash_len + 8]);
2452        if parent_one != RAW_COMMIT_GRAPH_PARENT_NONE {
2453            validate_raw_commit_graph_parent(parent_one, self.commit_count)?;
2454            out.push(parent_one as usize);
2455        }
2456        if first_parent || parent_two == RAW_COMMIT_GRAPH_PARENT_NONE {
2457            return Ok(());
2458        }
2459        if parent_two & RAW_COMMIT_GRAPH_EXTRA_EDGE == 0 {
2460            validate_raw_commit_graph_parent(parent_two, self.commit_count)?;
2461            out.push(parent_two as usize);
2462            return Ok(());
2463        }
2464
2465        let Some(edge) = &self.edge else {
2466            return Err(GitError::InvalidFormat(
2467                "commit-graph octopus edge missing EDGE chunk".into(),
2468            ));
2469        };
2470        let mut edge_idx = (parent_two & RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK) as usize;
2471        loop {
2472            let start = edge
2473                .start
2474                .checked_add(edge_idx.checked_mul(4).ok_or_else(|| {
2475                    GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2476                })?)
2477                .ok_or_else(|| {
2478                    GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2479                })?;
2480            let end = start.checked_add(4).ok_or_else(|| {
2481                GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2482            })?;
2483            let Some(bytes) = self.bytes.as_ref().get(start..end) else {
2484                return Err(GitError::InvalidFormat(
2485                    "commit-graph EDGE entry points past chunk".into(),
2486                ));
2487            };
2488            let raw = read_u32_be(bytes);
2489            let parent = raw & RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK;
2490            validate_raw_commit_graph_parent(parent, self.commit_count)?;
2491            out.push(parent as usize);
2492            if raw & RAW_COMMIT_GRAPH_EXTRA_EDGE != 0 {
2493                return Ok(());
2494            }
2495            edge_idx = edge_idx.checked_add(1).ok_or_else(|| {
2496                GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2497            })?;
2498        }
2499    }
2500
2501    fn parent_oids(&self, parent_one: u32, parent_two: u32) -> Result<Vec<ObjectId>> {
2502        let mut parents = Vec::new();
2503        if parent_one != RAW_COMMIT_GRAPH_PARENT_NONE {
2504            validate_raw_commit_graph_parent(parent_one, self.commit_count)?;
2505            parents.push(self.oid_at(parent_one)?);
2506        }
2507        if parent_two == RAW_COMMIT_GRAPH_PARENT_NONE {
2508            return Ok(parents);
2509        }
2510        if parent_two & RAW_COMMIT_GRAPH_EXTRA_EDGE == 0 {
2511            validate_raw_commit_graph_parent(parent_two, self.commit_count)?;
2512            parents.push(self.oid_at(parent_two)?);
2513            return Ok(parents);
2514        }
2515
2516        let Some(edge) = &self.edge else {
2517            return Err(GitError::InvalidFormat(
2518                "commit-graph octopus edge missing EDGE chunk".into(),
2519            ));
2520        };
2521        let mut edge_idx = (parent_two & RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK) as usize;
2522        loop {
2523            let start = edge
2524                .start
2525                .checked_add(edge_idx.checked_mul(4).ok_or_else(|| {
2526                    GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2527                })?)
2528                .ok_or_else(|| {
2529                    GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2530                })?;
2531            let end = start.checked_add(4).ok_or_else(|| {
2532                GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2533            })?;
2534            let Some(bytes) = self.bytes.as_ref().get(start..end) else {
2535                return Err(GitError::InvalidFormat(
2536                    "commit-graph EDGE entry points past chunk".into(),
2537                ));
2538            };
2539            let raw = read_u32_be(bytes);
2540            let parent = raw & RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK;
2541            validate_raw_commit_graph_parent(parent, self.commit_count)?;
2542            parents.push(self.oid_at(parent)?);
2543            if raw & RAW_COMMIT_GRAPH_EXTRA_EDGE != 0 {
2544                return Ok(parents);
2545            }
2546            edge_idx = edge_idx.checked_add(1).ok_or_else(|| {
2547                GitError::InvalidFormat("commit-graph EDGE index overflow".into())
2548            })?;
2549        }
2550    }
2551}
2552
2553impl<'a> CommitGraphContext<'a> {
2554    fn load(git_dir: &'a Path, format: sley_core::ObjectFormat) -> Self {
2555        Self {
2556            git_dir,
2557            format,
2558            direct_graph: None,
2559            commits: None,
2560        }
2561    }
2562
2563    fn direct_graph(&mut self) -> &DirectCommitGraph {
2564        if self.direct_graph.is_none() {
2565            self.direct_graph = Some(load_direct_commit_graph(self.git_dir, self.format));
2566        }
2567        self.direct_graph
2568            .as_ref()
2569            .expect("direct commit graph load state initialized")
2570    }
2571
2572    fn count_reachable_direct(
2573        &mut self,
2574        starts: &[ObjectId],
2575        first_parent: bool,
2576    ) -> Result<Option<usize>> {
2577        let format = self.format;
2578        let DirectCommitGraph::Raw(graph) = self.direct_graph() else {
2579            return Ok(None);
2580        };
2581        let mut indices = Vec::with_capacity(starts.len());
2582        for oid in starts {
2583            if oid.format() != format {
2584                return Ok(None);
2585            }
2586            let Some(idx) = graph.find_index(oid)? else {
2587                return Ok(None);
2588            };
2589            indices.push(idx);
2590        }
2591        let mut state = RawCommitGraphCountState::new(graph.commit_count);
2592        graph
2593            .count_reachable_indices(&indices, first_parent, &mut state)
2594            .map(Some)
2595    }
2596
2597    fn count_reachable_graph_oid(
2598        &mut self,
2599        oid: &ObjectId,
2600        first_parent: bool,
2601        state: &mut Option<RawCommitGraphCountState>,
2602    ) -> Result<Option<usize>> {
2603        let format = self.format;
2604        let DirectCommitGraph::Raw(graph) = self.direct_graph() else {
2605            return Ok(None);
2606        };
2607        if oid.format() != format {
2608            return Ok(None);
2609        }
2610        let Some(idx) = graph.find_index(oid)? else {
2611            return Ok(None);
2612        };
2613        let state = state.get_or_insert_with(|| RawCommitGraphCountState::new(graph.commit_count));
2614        graph
2615            .count_reachable_indices(&[idx], first_parent, state)
2616            .map(Some)
2617    }
2618
2619    /// Resolve `oid`'s graph metadata, loading and parsing the graph on first
2620    /// use. Returns `None` when the commit is not in the graph.
2621    fn lookup(&mut self, oid: &ObjectId) -> Result<Option<&GraphCommit>> {
2622        if self.commits.is_none() {
2623            self.commits = Some(
2624                load_commit_graph_map(self.git_dir, self.format).map_err(|err| err.to_string()),
2625            );
2626        }
2627        match self
2628            .commits
2629            .as_ref()
2630            .expect("commit graph map load state initialized")
2631        {
2632            Ok(map) => Ok(map.get(oid)),
2633            Err(message) => Err(GitError::InvalidFormat(message.clone())),
2634        }
2635    }
2636
2637    /// Parents of `oid` from the graph, or `None` when it is not present.
2638    fn parents(&mut self, oid: &ObjectId) -> Result<Option<&GraphParents>> {
2639        Ok(self.lookup(oid)?.map(|commit| &commit.parents))
2640    }
2641
2642    /// First parent of `oid` from the graph. The outer `None` means the commit is
2643    /// not present in the graph; the inner `None` means the commit is present but
2644    /// root/unborn with no parents.
2645    fn first_parent(&mut self, oid: &ObjectId) -> Result<Option<Option<ObjectId>>> {
2646        Ok(self.lookup(oid)?.map(|commit| commit.parents.first()))
2647    }
2648
2649    /// Generation number of `oid`, or `None` when it is not present in the graph
2650    /// or the graph carries no generation numbers (generation 0). A `None`
2651    /// result disables generation-based pruning for that commit.
2652    fn generation(&mut self, oid: &ObjectId) -> Result<Option<u32>> {
2653        Ok(match self.lookup(oid)? {
2654            Some(commit) if commit.generation != GENERATION_NUMBER_ZERO => Some(commit.generation),
2655            _ => None,
2656        })
2657    }
2658
2659    /// Committer date (seconds since the epoch) recorded for `oid` in the graph,
2660    /// or `None` when the commit is not present. Used to order candidates
2661    /// without re-parsing the commit object's committer line.
2662    fn commit_time(&mut self, oid: &ObjectId) -> Result<Option<i64>> {
2663        Ok(self
2664            .lookup(oid)?
2665            .map(|commit| i64::try_from(commit.commit_time).unwrap_or(i64::MAX)))
2666    }
2667
2668    /// Parents of `oid`: from the graph when present, otherwise read+parsed from
2669    /// the commit object via `reader`.
2670    fn commit_parents<R: ObjectReader>(
2671        &mut self,
2672        reader: &R,
2673        oid: &ObjectId,
2674    ) -> Result<Vec<ObjectId>> {
2675        // Graft seam: history is cut at shallow boundary commits, so walks
2676        // must see them as parentless regardless of graph/object contents.
2677        if reader.is_shallow_graft(oid) {
2678            return Ok(Vec::new());
2679        }
2680        let format = self.format;
2681        if let Some(parents) = self.parents(oid)? {
2682            return Ok(parents.to_vec());
2683        }
2684        commit_parents(reader, format, oid)
2685    }
2686
2687    /// Parent ids of `oid` for callers that only need to enqueue them. Graph
2688    /// parents are borrowed from the parsed graph cache; object fallback parents
2689    /// are owned by the iterator.
2690    fn commit_parent_ids<R: ObjectReader>(
2691        &mut self,
2692        reader: &R,
2693        oid: &ObjectId,
2694    ) -> Result<CommitParentIds<'_>> {
2695        if reader.is_shallow_graft(oid) {
2696            return Ok(CommitParentIds::Empty);
2697        }
2698        let format = self.format;
2699        if let Some(parents) = self.parents(oid)? {
2700            return Ok(CommitParentIds::borrowed(parents));
2701        }
2702        Ok(CommitParentIds::owned(commit_parents(reader, format, oid)?))
2703    }
2704
2705    /// First parent of `oid`: from the graph when present, otherwise read+parsed
2706    /// from the commit object via `reader`.
2707    fn commit_first_parent<R: ObjectReader>(
2708        &mut self,
2709        reader: &R,
2710        oid: &ObjectId,
2711    ) -> Result<Option<ObjectId>> {
2712        if reader.is_shallow_graft(oid) {
2713            return Ok(None);
2714        }
2715        let format = self.format;
2716        if let Some(parent) = self.first_parent(oid)? {
2717            return Ok(parent);
2718        }
2719        Ok(commit_parents(reader, format, oid)?.into_iter().next())
2720    }
2721
2722    /// `oid`'s parents and committer time from the graph in one lookup, or `None`
2723    /// when the commit is not represented (the caller then reads the object).
2724    fn metadata(&mut self, oid: &ObjectId) -> Result<Option<GraphCommitMetadata<'_>>> {
2725        Ok(self.lookup(oid)?.map(|commit| GraphCommitMetadata {
2726            parents: &commit.parents,
2727            commit_time: i64::try_from(commit.commit_time).unwrap_or(i64::MAX),
2728        }))
2729    }
2730
2731    fn metadata_owned<R: ObjectReader>(
2732        &mut self,
2733        reader: &R,
2734        oid: &ObjectId,
2735    ) -> Result<Option<CommitMetadata>> {
2736        match self.direct_graph() {
2737            DirectCommitGraph::Raw(graph) => {
2738                let Some(mut metadata) = graph.metadata(oid).unwrap_or(None) else {
2739                    return Ok(None);
2740                };
2741                if reader.is_shallow_graft(oid) {
2742                    metadata.parents.clear();
2743                }
2744                return Ok(Some(metadata));
2745            }
2746            DirectCommitGraph::Invalid(_) => return Ok(None),
2747            DirectCommitGraph::Missing => {}
2748        }
2749        Ok(self.metadata(oid)?.map(|metadata| CommitMetadata {
2750            oid: *oid,
2751            parents: metadata.parents.grafted_vec(reader, oid),
2752            commit_time: metadata.commit_time,
2753        }))
2754    }
2755}
2756
2757/// Read and parse the commit-graph for `git_dir`, returning an oid-keyed map of
2758/// commit metadata with parent indices resolved to object ids.
2759///
2760/// A missing graph, an unparseable graph, or a graph with internally
2761/// inconsistent parent indices all yield an empty map; callers then fall back to
2762/// reading commit objects, so a damaged or unsupported graph can never change a
2763/// walk's result, only its speed. Both the monolithic
2764/// `objects/info/commit-graph` file and a split-graph chain under
2765/// `objects/info/commit-graphs/` are honored; chain layers are merged into a
2766/// single map, and any layer that cannot be parsed standalone (e.g. one whose
2767/// parent edges cross into a base layer, which this reader does not resolve)
2768/// causes the chain to be ignored in favor of the object-reading path. Linked
2769/// worktrees are resolved through the common object directory, matching normal
2770/// object reads.
2771fn load_commit_graph_map(
2772    git_dir: &Path,
2773    format: sley_core::ObjectFormat,
2774) -> Result<HashMap<ObjectId, GraphCommit>> {
2775    let info = repository_objects_dir(git_dir).join("info");
2776    let single = info.join("commit-graph");
2777    if single.exists() {
2778        let bytes = match fs::read(&single) {
2779            Ok(bytes) => bytes,
2780            Err(err) => return Err(GitError::Io(err.to_string())),
2781        };
2782        if commit_graph_hash_version_mismatch(&bytes, format) {
2783            return Err(GitError::InvalidFormat(
2784                "commit-graph hash version mismatch".into(),
2785            ));
2786        }
2787        return match CommitGraph::parse(&bytes, format) {
2788            Ok(graph) => graph_to_map(&graph),
2789            Err(_) => {
2790                warn_invalid_commit_graph_bloom_chunks(&bytes, &single, format);
2791                if RawCommitGraph::parse_for_lookup(RawCommitGraphBytes::Owned(bytes), format)
2792                    .is_err()
2793                {
2794                    return Ok(HashMap::new());
2795                }
2796                Ok(HashMap::new())
2797            }
2798        };
2799    }
2800
2801    let chain = info.join("commit-graphs").join("commit-graph-chain");
2802    load_commit_graph_chain(&info, &chain, format)
2803}
2804
2805fn load_direct_commit_graph(git_dir: &Path, format: sley_core::ObjectFormat) -> DirectCommitGraph {
2806    let path = repository_objects_dir(git_dir)
2807        .join("info")
2808        .join("commit-graph");
2809    if !path.exists() {
2810        return DirectCommitGraph::Missing;
2811    }
2812    let bytes = match sley_mmap::MappedFile::open_commit_graph(&path) {
2813        Ok(mapped) => RawCommitGraphBytes::Mapped(mapped),
2814        Err(_) => match fs::read(&path) {
2815            Ok(bytes) => RawCommitGraphBytes::Owned(bytes),
2816            Err(err) => return DirectCommitGraph::Invalid(err.to_string()),
2817        },
2818    };
2819    if commit_graph_hash_version_mismatch(bytes.as_ref(), format) {
2820        return DirectCommitGraph::Invalid("commit-graph hash version mismatch".into());
2821    }
2822    warn_invalid_commit_graph_bloom_chunks(bytes.as_ref(), &path, format);
2823    match RawCommitGraph::parse_for_lookup(bytes, format) {
2824        Ok(graph) => DirectCommitGraph::Raw(Box::new(graph)),
2825        Err(GitError::InvalidFormat(message)) => DirectCommitGraph::Invalid(message),
2826        Err(err) => DirectCommitGraph::Invalid(err.to_string()),
2827    }
2828}
2829
2830const RAW_COMMIT_GRAPH_PARENT_NONE: u32 = 0x7000_0000;
2831const RAW_COMMIT_GRAPH_EXTRA_EDGE: u32 = 0x8000_0000;
2832const RAW_COMMIT_GRAPH_EXTRA_EDGE_MASK: u32 = 0x7fff_ffff;
2833
2834fn raw_commit_graph_chunk(chunks: &[([u8; 4], Range<usize>)], id: [u8; 4]) -> Option<Range<usize>> {
2835    chunks
2836        .iter()
2837        .find_map(|(chunk_id, range)| (*chunk_id == id).then(|| range.clone()))
2838}
2839
2840fn raw_commit_graph_validate_generation_data(
2841    data: &[u8],
2842    chunks: &[([u8; 4], Range<usize>)],
2843    commit_count: usize,
2844) -> Result<()> {
2845    let Some(gda2) = raw_commit_graph_chunk(chunks, *b"GDA2") else {
2846        return Ok(());
2847    };
2848    let expected_gda2_len = commit_count
2849        .checked_mul(4)
2850        .ok_or_else(|| GitError::InvalidFormat("commit-graph generation data overflow".into()))?;
2851    if gda2.len() != expected_gda2_len {
2852        return Err(GitError::InvalidFormat(
2853            "commit-graph generation data is the wrong size".into(),
2854        ));
2855    }
2856    let gdo2 = raw_commit_graph_chunk(chunks, *b"GDO2");
2857    if let Some(gdo2) = &gdo2
2858        && gdo2.len() % 8 != 0
2859    {
2860        return Err(GitError::InvalidFormat(
2861            "commit-graph overflow generation data is corrupt".into(),
2862        ));
2863    }
2864    for offset in (gda2.start..gda2.end).step_by(4) {
2865        let raw = read_u32_be(&data[offset..offset + 4]);
2866        if raw & 0x8000_0000 == 0 {
2867            continue;
2868        }
2869        let Some(gdo2) = &gdo2 else {
2870            return Err(GitError::InvalidFormat(
2871                "commit-graph overflow generation data is missing".into(),
2872            ));
2873        };
2874        let overflow_idx = (raw & 0x7fff_ffff) as usize;
2875        let overflow_start = overflow_idx.checked_mul(8).ok_or_else(|| {
2876            GitError::InvalidFormat("commit-graph overflow generation index overflow".into())
2877        })?;
2878        let overflow_end = overflow_start.checked_add(8).ok_or_else(|| {
2879            GitError::InvalidFormat("commit-graph overflow generation index overflow".into())
2880        })?;
2881        if overflow_end > gdo2.len() {
2882            return Err(GitError::InvalidFormat(
2883                "commit-graph overflow generation data is too small".into(),
2884            ));
2885        }
2886    }
2887    Ok(())
2888}
2889
2890fn raw_commit_graph_entry_len(format: ObjectFormat) -> Result<usize> {
2891    format
2892        .raw_len()
2893        .checked_add(16)
2894        .ok_or_else(|| GitError::InvalidFormat("commit-graph CDAT entry overflow".into()))
2895}
2896
2897fn validate_raw_commit_graph_parent(parent: u32, commit_count: usize) -> Result<()> {
2898    if parent as usize >= commit_count {
2899        return Err(GitError::InvalidFormat(
2900            "commit-graph parent points past commit table".into(),
2901        ));
2902    }
2903    Ok(())
2904}
2905
2906fn commit_graph_hash_function_id(format: ObjectFormat) -> u32 {
2907    match format {
2908        ObjectFormat::Sha1 => 1,
2909        ObjectFormat::Sha256 => 2,
2910    }
2911}
2912
2913/// Warn (once per process, on stderr) when a commit-graph file's hash-version
2914/// byte disagrees with the repository's object format, mirroring git's
2915/// `load_commit_graph_one`. Returns true when the graph must be ignored. The
2916/// graph is otherwise silently usable, so this never fires in normal operation.
2917fn commit_graph_hash_version_mismatch(bytes: &[u8], format: ObjectFormat) -> bool {
2918    if bytes.len() <= 5 || &bytes[..4] != b"CGPH" {
2919        return false;
2920    }
2921    let file_version = u32::from(bytes[5]);
2922    let repo_version = commit_graph_hash_function_id(format);
2923    if file_version == repo_version {
2924        return false;
2925    }
2926    use std::sync::atomic::{AtomicBool, Ordering};
2927    static WARNED: AtomicBool = AtomicBool::new(false);
2928    if !WARNED.swap(true, Ordering::Relaxed) {
2929        eprintln!(
2930            "error: commit-graph hash version {file_version} does not match version {repo_version}"
2931        );
2932    }
2933    true
2934}
2935
2936fn read_u32_be(bytes: &[u8]) -> u32 {
2937    u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
2938}
2939
2940fn read_u64_be(bytes: &[u8]) -> u64 {
2941    u64::from_be_bytes([
2942        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
2943    ])
2944}
2945
2946/// Load every layer named in a split-graph chain file and merge them.
2947///
2948/// The chain file lists one layer hash per line, base layers first. Each layer
2949/// lives at `commit-graphs/graph-<hash>.graph`. Layers are merged tip-last so a
2950/// commit rewritten in a newer layer wins; any layer that fails to parse
2951/// standalone aborts the whole chain (returning an error that the caller turns
2952/// into "no graph").
2953fn load_commit_graph_chain(
2954    info: &Path,
2955    chain: &Path,
2956    format: sley_core::ObjectFormat,
2957) -> Result<HashMap<ObjectId, GraphCommit>> {
2958    let contents = match fs::read_to_string(chain) {
2959        Ok(contents) => contents,
2960        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2961            return Ok(HashMap::new());
2962        }
2963        Err(err) => return Err(GitError::Io(err.to_string())),
2964    };
2965    let mut merged: HashMap<ObjectId, GraphCommit> = HashMap::new();
2966    for line in contents.lines() {
2967        let hash = line.trim();
2968        if hash.is_empty() {
2969            continue;
2970        }
2971        let layer = info
2972            .join("commit-graphs")
2973            .join(format!("graph-{hash}.graph"));
2974        let bytes = fs::read(&layer).map_err(|err| GitError::Io(err.to_string()))?;
2975        let graph = match CommitGraph::parse(&bytes, format) {
2976            Ok(graph) => graph,
2977            Err(err) => {
2978                warn_invalid_commit_graph_bloom_chunks(&bytes, &layer, format);
2979                return Err(err);
2980            }
2981        };
2982        for (oid, commit) in graph_to_map(&graph)? {
2983            merged.insert(oid, commit);
2984        }
2985    }
2986    Ok(merged)
2987}
2988
2989/// Turn a parsed [`CommitGraph`] into an oid-keyed metadata map, resolving each
2990/// entry's parent indices into the parents' object ids.
2991fn graph_to_map(graph: &CommitGraph) -> Result<HashMap<ObjectId, GraphCommit>> {
2992    let mut map = HashMap::with_capacity(graph.commits.len());
2993    for entry in &graph.commits {
2994        let parents = GraphParents::from_oids(graph.parent_oids(entry)?);
2995        map.insert(
2996            entry.oid,
2997            GraphCommit {
2998                parents,
2999                generation: entry.generation,
3000                commit_time: entry.commit_time,
3001            },
3002        );
3003    }
3004    Ok(map)
3005}
3006
3007fn load_commit_graph_bloom_map(
3008    objects_dir: &Path,
3009    format: sley_core::ObjectFormat,
3010    requested_version: i64,
3011) -> HashMap<ObjectId, GraphBloomCommit> {
3012    let info = objects_dir.join("info");
3013    let graph_path = info.join("commit-graph");
3014    if !graph_path.exists() {
3015        let chain = info.join("commit-graphs").join("commit-graph-chain");
3016        return load_commit_graph_bloom_chain(&info, &chain, format, requested_version)
3017            .unwrap_or_default();
3018    }
3019    let bytes = match fs::read(&graph_path) {
3020        Ok(bytes) => bytes,
3021        Err(_) => return HashMap::new(),
3022    };
3023    match CommitGraph::parse(&bytes, format) {
3024        Ok(graph) => graph_to_bloom_map(&graph, requested_version, &[]).unwrap_or_default(),
3025        Err(_) => {
3026            warn_invalid_commit_graph_bloom_chunks(&bytes, &graph_path, format);
3027            HashMap::new()
3028        }
3029    }
3030}
3031
3032fn load_commit_graph_bloom_chain(
3033    info: &Path,
3034    chain: &Path,
3035    format: sley_core::ObjectFormat,
3036    requested_version: i64,
3037) -> Result<HashMap<ObjectId, GraphBloomCommit>> {
3038    let contents = match fs::read_to_string(chain) {
3039        Ok(contents) => contents,
3040        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3041            return Ok(HashMap::new());
3042        }
3043        Err(err) => return Err(GitError::Io(err.to_string())),
3044    };
3045    let mut layers = Vec::new();
3046    let chain_dir = info.join("commit-graphs");
3047    for line in contents.lines() {
3048        let hash = line.trim();
3049        if hash.is_empty() {
3050            continue;
3051        }
3052        let layer = chain_dir.join(format!("graph-{hash}.graph"));
3053        let bytes = fs::read(&layer).map_err(|err| GitError::Io(err.to_string()))?;
3054        let graph = match CommitGraph::parse(&bytes, format) {
3055            Ok(graph) => graph,
3056            Err(err) => {
3057                warn_invalid_commit_graph_bloom_chunks(&bytes, &layer, format);
3058                return Err(err);
3059            }
3060        };
3061        layers.push((hash.to_string(), graph));
3062    }
3063    let canonical_settings = layers
3064        .iter()
3065        .rev()
3066        .filter_map(|(_, graph)| graph.bloom_filters.as_ref())
3067        .map(commit_graph_bloom_settings_from_filters)
3068        .find(|settings| {
3069            requested_version <= 0 || i64::from(settings.hash_version) == requested_version
3070        });
3071    let mut merged = HashMap::new();
3072    let mut base_oids = Vec::new();
3073    for (hash, graph) in layers {
3074        let layer_settings = graph
3075            .bloom_filters
3076            .as_ref()
3077            .map(commit_graph_bloom_settings_from_filters);
3078        let layer_map = if let (Some(canonical), Some(settings)) =
3079            (canonical_settings, layer_settings)
3080            && !commit_graph_bloom_settings_match(settings, canonical)
3081        {
3082            eprintln!(
3083                "warning: disabling Bloom filters for commit-graph layer '{hash}' due to incompatible settings"
3084            );
3085            graph_to_bloom_map_without_filters(&graph, settings, &base_oids)?
3086        } else {
3087            graph_to_bloom_map(&graph, requested_version, &base_oids)?
3088        };
3089        for (oid, bloom) in layer_map {
3090            merged.insert(oid, bloom);
3091        }
3092        base_oids.extend(graph.commits.iter().map(|entry| entry.oid));
3093    }
3094    Ok(merged)
3095}
3096
3097#[derive(Clone, Copy)]
3098struct GraphChunkView {
3099    id: [u8; 4],
3100    start: usize,
3101    end: usize,
3102}
3103
3104fn warn_invalid_commit_graph_bloom_chunks(
3105    bytes: &[u8],
3106    path: &Path,
3107    format: sley_core::ObjectFormat,
3108) {
3109    let Some((chunks, checksum_offset)) = commit_graph_chunk_views(bytes, format) else {
3110        return;
3111    };
3112    let Some(bdat) = commit_graph_chunk_view_data(bytes, &chunks, *b"BDAT") else {
3113        return;
3114    };
3115    let Some(bidx) = commit_graph_chunk_view_data(bytes, &chunks, *b"BIDX") else {
3116        return;
3117    };
3118    if bdat.len() < 12 {
3119        emit_commit_graph_bloom_warning_once(
3120            path,
3121            format!(
3122                "warning: ignoring too-small changed-path chunk ({} < 12) in commit-graph file",
3123                bdat.len()
3124            ),
3125        );
3126        return;
3127    }
3128    let commit_count = commit_graph_view_commit_count(bytes, &chunks, checksum_offset);
3129    if let Some(commit_count) = commit_count
3130        && bidx.len() / 4 != commit_count
3131    {
3132        emit_commit_graph_bloom_warning_once(
3133            path,
3134            "warning: commit-graph changed-path index chunk is too small".to_string(),
3135        );
3136        return;
3137    }
3138    let payload_len = bdat.len() - 12;
3139    let display_path = commit_graph_warning_path(path);
3140    let mut previous = 0usize;
3141    for idx in 0..(bidx.len() / 4) {
3142        let start = idx * 4;
3143        let cumulative = u32::from_be_bytes([
3144            bidx[start],
3145            bidx[start + 1],
3146            bidx[start + 2],
3147            bidx[start + 3],
3148        ]) as usize;
3149        if cumulative > payload_len {
3150            emit_commit_graph_bloom_warning_once(
3151                path,
3152                format!(
3153                    "warning: ignoring out-of-range offset ({}) for changed-path filter at pos {} of {} (chunk size: {})",
3154                    cumulative,
3155                    idx,
3156                    display_path,
3157                    bdat.len()
3158                ),
3159            );
3160            return;
3161        }
3162        if cumulative < previous {
3163            emit_commit_graph_bloom_warning_once(
3164                path,
3165                format!(
3166                    "warning: ignoring decreasing changed-path index offsets ({} > {}) for positions {} and {} of {}",
3167                    previous,
3168                    cumulative,
3169                    idx.saturating_sub(1),
3170                    idx,
3171                    display_path
3172                ),
3173            );
3174            return;
3175        }
3176        previous = cumulative;
3177    }
3178}
3179
3180fn emit_commit_graph_bloom_warning_once(path: &Path, message: String) {
3181    static WARNED: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
3182    let warned = WARNED.get_or_init(|| Mutex::new(HashSet::new()));
3183    if let Ok(mut warned) = warned.lock()
3184        && !warned.insert(path.to_path_buf())
3185    {
3186        return;
3187    }
3188    eprintln!("{message}");
3189}
3190
3191fn warn_invalid_commit_graph_bloom_for_objects_dir(
3192    objects_dir: &Path,
3193    format: sley_core::ObjectFormat,
3194) {
3195    let info = objects_dir.join("info");
3196    let single = info.join("commit-graph");
3197    if single.exists() {
3198        if let Ok(bytes) = fs::read(&single) {
3199            warn_invalid_commit_graph_bloom_chunks(&bytes, &single, format);
3200        }
3201        return;
3202    }
3203    let chain = info.join("commit-graphs").join("commit-graph-chain");
3204    let Ok(contents) = fs::read_to_string(&chain) else {
3205        return;
3206    };
3207    for line in contents.lines() {
3208        let hash = line.trim();
3209        if hash.is_empty() {
3210            continue;
3211        }
3212        let layer = info
3213            .join("commit-graphs")
3214            .join(format!("graph-{hash}.graph"));
3215        if let Ok(bytes) = fs::read(&layer) {
3216            warn_invalid_commit_graph_bloom_chunks(&bytes, &layer, format);
3217        }
3218    }
3219}
3220
3221fn commit_graph_chunk_views(
3222    bytes: &[u8],
3223    format: sley_core::ObjectFormat,
3224) -> Option<(Vec<GraphChunkView>, usize)> {
3225    let hash_len = format.raw_len();
3226    if bytes.len() < 8 + 12 + hash_len || &bytes[..4] != b"CGPH" {
3227        return None;
3228    }
3229    let chunk_count = bytes[6] as usize;
3230    let lookup_len = (chunk_count + 1).checked_mul(12)?;
3231    let data_start = 8usize.checked_add(lookup_len)?;
3232    let checksum_offset = bytes.len().checked_sub(hash_len)?;
3233    if data_start > checksum_offset {
3234        return None;
3235    }
3236    let mut lookup = Vec::with_capacity(chunk_count + 1);
3237    let mut offset = 8usize;
3238    for _ in 0..=chunk_count {
3239        let id = [
3240            bytes[offset],
3241            bytes[offset + 1],
3242            bytes[offset + 2],
3243            bytes[offset + 3],
3244        ];
3245        let chunk_offset = u64::from_be_bytes([
3246            bytes[offset + 4],
3247            bytes[offset + 5],
3248            bytes[offset + 6],
3249            bytes[offset + 7],
3250            bytes[offset + 8],
3251            bytes[offset + 9],
3252            bytes[offset + 10],
3253            bytes[offset + 11],
3254        ]) as usize;
3255        lookup.push((id, chunk_offset));
3256        offset += 12;
3257    }
3258    let mut chunks = Vec::with_capacity(chunk_count);
3259    for pair in lookup.windows(2) {
3260        let (id, start) = pair[0];
3261        let (_next, end) = pair[1];
3262        if start > end || end > checksum_offset {
3263            return None;
3264        }
3265        chunks.push(GraphChunkView { id, start, end });
3266    }
3267    Some((chunks, checksum_offset))
3268}
3269
3270fn commit_graph_chunk_view_data<'a>(
3271    bytes: &'a [u8],
3272    chunks: &[GraphChunkView],
3273    id: [u8; 4],
3274) -> Option<&'a [u8]> {
3275    let chunk = chunks.iter().find(|chunk| chunk.id == id)?;
3276    bytes.get(chunk.start..chunk.end)
3277}
3278
3279fn commit_graph_view_commit_count(
3280    bytes: &[u8],
3281    chunks: &[GraphChunkView],
3282    _checksum_offset: usize,
3283) -> Option<usize> {
3284    let fanout = commit_graph_chunk_view_data(bytes, chunks, *b"OIDF")?;
3285    if fanout.len() != 256 * 4 {
3286        return None;
3287    }
3288    let last = fanout.len() - 4;
3289    Some(u32::from_be_bytes([
3290        fanout[last],
3291        fanout[last + 1],
3292        fanout[last + 2],
3293        fanout[last + 3],
3294    ]) as usize)
3295}
3296
3297fn commit_graph_warning_path(path: &Path) -> String {
3298    let text = path.to_string_lossy();
3299    if let Some(idx) = text.find(".git/objects/info/commit-graph") {
3300        return text[idx..].to_string();
3301    }
3302    text.into_owned()
3303}
3304
3305fn graph_to_bloom_map(
3306    graph: &CommitGraph,
3307    requested_version: i64,
3308    base_oids: &[ObjectId],
3309) -> Result<HashMap<ObjectId, GraphBloomCommit>> {
3310    let Some(filters) = &graph.bloom_filters else {
3311        return graph_to_bloom_map_without_filters(
3312            graph,
3313            sley_formats::DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS,
3314            base_oids,
3315        );
3316    };
3317    let settings = commit_graph_bloom_settings_from_filters(filters);
3318    if requested_version > 0 && i64::from(filters.hash_version) != requested_version {
3319        return graph_to_bloom_map_without_filters(graph, settings, base_oids);
3320    }
3321    let mut map = HashMap::with_capacity(graph.commits.len());
3322    for (idx, entry) in graph.commits.iter().enumerate() {
3323        let parents = commit_graph_entry_parent_oids_with_base(graph, entry, base_oids)?;
3324        let filter = filters
3325            .filter_for_commit(idx)
3326            .filter(|filter| !filter.is_empty())
3327            .map(|filter| filter.to_vec());
3328        map.insert(
3329            entry.oid,
3330            GraphBloomCommit {
3331                parents,
3332                filter,
3333                settings,
3334            },
3335        );
3336    }
3337    Ok(map)
3338}
3339
3340fn graph_to_bloom_map_without_filters(
3341    graph: &CommitGraph,
3342    settings: sley_formats::CommitGraphBloomSettings,
3343    base_oids: &[ObjectId],
3344) -> Result<HashMap<ObjectId, GraphBloomCommit>> {
3345    let mut map = HashMap::with_capacity(graph.commits.len());
3346    for entry in &graph.commits {
3347        let parents = commit_graph_entry_parent_oids_with_base(graph, entry, base_oids)?;
3348        map.insert(
3349            entry.oid,
3350            GraphBloomCommit {
3351                parents,
3352                filter: None,
3353                settings,
3354            },
3355        );
3356    }
3357    Ok(map)
3358}
3359
3360fn commit_graph_entry_parent_oids_with_base(
3361    graph: &CommitGraph,
3362    entry: &sley_formats::CommitGraphEntry,
3363    base_oids: &[ObjectId],
3364) -> Result<GraphParents> {
3365    let mut parents = Vec::with_capacity(entry.parents.len());
3366    for parent in entry.parent_indices() {
3367        let idx = usize::try_from(parent)
3368            .map_err(|_| GitError::InvalidFormat("commit-graph parent index overflow".into()))?;
3369        let oid = if idx < base_oids.len() {
3370            base_oids[idx]
3371        } else {
3372            let local = idx - base_oids.len();
3373            graph
3374                .commits
3375                .get(local)
3376                .map(|entry| entry.oid)
3377                .ok_or_else(|| {
3378                    GitError::InvalidFormat("commit-graph parent points past commit table".into())
3379                })?
3380        };
3381        parents.push(oid);
3382    }
3383    Ok(GraphParents::from_oids(parents))
3384}
3385
3386fn commit_graph_bloom_settings_from_filters(
3387    filters: &sley_formats::CommitGraphBloomFilters,
3388) -> sley_formats::CommitGraphBloomSettings {
3389    let mut settings = sley_formats::DEFAULT_COMMIT_GRAPH_BLOOM_SETTINGS;
3390    settings.hash_version = filters.hash_version;
3391    settings.hash_count = filters.hash_count;
3392    settings.bits_per_entry = filters.bits_per_entry;
3393    settings
3394}
3395
3396fn commit_graph_bloom_settings_match(
3397    left: sley_formats::CommitGraphBloomSettings,
3398    right: sley_formats::CommitGraphBloomSettings,
3399) -> bool {
3400    left.hash_version == right.hash_version
3401        && left.hash_count == right.hash_count
3402        && left.bits_per_entry == right.bits_per_entry
3403}
3404
3405fn commit_parents<R: ObjectReader>(
3406    reader: &R,
3407    format: sley_core::ObjectFormat,
3408    oid: &ObjectId,
3409) -> Result<Vec<ObjectId>> {
3410    let object = read_revision_object(reader, oid)?;
3411    if object.object_type != ObjectType::Commit {
3412        return Err(GitError::InvalidObject(format!(
3413            "expected commit {oid}, found {}",
3414            object.object_type.as_str()
3415        )));
3416    }
3417    Ok(sley_odb::grafted_parents(
3418        reader,
3419        oid,
3420        Commit::parse_ref(format, &object.body)?.parents,
3421    ))
3422}
3423
3424fn peel_revision<R: ObjectReader>(
3425    reader: &R,
3426    format: sley_core::ObjectFormat,
3427    oid: &ObjectId,
3428    kind: PeelKind,
3429) -> Result<ObjectId> {
3430    match kind {
3431        PeelKind::AnyNonTag => peel_tags(reader, format, oid),
3432        PeelKind::Object => {
3433            read_revision_object(reader, oid)?;
3434            Ok(*oid)
3435        }
3436        PeelKind::Commit => peel_to_commit(reader, format, oid),
3437        PeelKind::Tree => peel_to_tree(reader, format, oid),
3438        PeelKind::Blob => peel_to_blob(reader, format, oid),
3439        PeelKind::Tag => {
3440            let object = read_revision_object(reader, oid)?;
3441            if object.object_type == ObjectType::Tag {
3442                Ok(*oid)
3443            } else {
3444                Err(GitError::InvalidObject(format!(
3445                    "expected tag {oid}, found {}",
3446                    object.object_type.as_str()
3447                )))
3448            }
3449        }
3450    }
3451}
3452
3453pub fn peel_tags<R: ObjectReader>(
3454    reader: &R,
3455    format: sley_core::ObjectFormat,
3456    oid: &ObjectId,
3457) -> Result<ObjectId> {
3458    let object = read_revision_object(reader, oid)?;
3459    if object.object_type != ObjectType::Tag {
3460        return Ok(*oid);
3461    }
3462    let tag = Tag::parse_ref(format, &object.body)?;
3463    peel_tags(reader, format, &tag.object)
3464}
3465
3466pub fn peel_to_tree<R: ObjectReader>(
3467    reader: &R,
3468    format: sley_core::ObjectFormat,
3469    oid: &ObjectId,
3470) -> Result<ObjectId> {
3471    let object = read_revision_object(reader, oid)?;
3472    match object.object_type {
3473        ObjectType::Tree => Ok(*oid),
3474        ObjectType::Commit => Ok(Commit::parse_ref(format, &object.body)?.tree),
3475        ObjectType::Tag => {
3476            let tag = Tag::parse_ref(format, &object.body)?;
3477            peel_to_tree(reader, format, &tag.object)
3478        }
3479        other => Err(GitError::InvalidObject(format!(
3480            "expected tree-ish {oid}, found {}",
3481            other.as_str()
3482        ))),
3483    }
3484}
3485
3486pub fn peel_to_commit<R: ObjectReader>(
3487    reader: &R,
3488    format: sley_core::ObjectFormat,
3489    oid: &ObjectId,
3490) -> Result<ObjectId> {
3491    let object = read_revision_object(reader, oid)?;
3492    match object.object_type {
3493        ObjectType::Commit => Ok(*oid),
3494        ObjectType::Tag => {
3495            let tag = Tag::parse_ref(format, &object.body)?;
3496            peel_to_commit(reader, format, &tag.object)
3497        }
3498        other => Err(GitError::InvalidObject(format!(
3499            "expected commit-ish {oid}, found {}",
3500            other.as_str()
3501        ))),
3502    }
3503}
3504
3505/// `<rev>^{blob}` — follow tags down to a blob. git's `peel_to_type(OBJ_BLOB)`
3506/// dereferences a tag chain; the final object must be a blob (a commit/tree does
3507/// not peel to a blob and is an error).
3508pub fn peel_to_blob<R: ObjectReader>(
3509    reader: &R,
3510    format: sley_core::ObjectFormat,
3511    oid: &ObjectId,
3512) -> Result<ObjectId> {
3513    let object = read_revision_object(reader, oid)?;
3514    match object.object_type {
3515        ObjectType::Blob => Ok(*oid),
3516        ObjectType::Tag => {
3517            let tag = Tag::parse_ref(format, &object.body)?;
3518            peel_to_blob(reader, format, &tag.object)
3519        }
3520        other => Err(GitError::InvalidObject(format!(
3521            "expected blob {oid}, found {}",
3522            other.as_str()
3523        ))),
3524    }
3525}
3526
3527pub fn pack_refs_with_auto_peel(
3528    git_dir: impl AsRef<Path>,
3529    format: sley_core::ObjectFormat,
3530    prune_loose: bool,
3531) -> Result<Vec<PackedRef>> {
3532    let git_dir = git_dir.as_ref();
3533    let db = FileObjectDatabase::from_git_dir(git_dir, format);
3534    let refs = FileRefStore::new(git_dir, format);
3535    refs.pack_refs_with_peeler(prune_loose, |_, oid| {
3536        let peeled = peel_tags(&db, format, oid)?;
3537        if &peeled == oid {
3538            Ok(None)
3539        } else {
3540            Ok(Some(peeled))
3541        }
3542    })
3543}
3544
3545pub fn parse_commit_parents(format: sley_core::ObjectFormat, body: &[u8]) -> Result<Vec<ObjectId>> {
3546    let text = std::str::from_utf8(body).map_err(|err| GitError::InvalidObject(err.to_string()))?;
3547    let mut parents = Vec::new();
3548    for line in text.lines() {
3549        if line.is_empty() {
3550            break;
3551        }
3552        if let Some(hex) = line.strip_prefix("parent ") {
3553            parents.push(ObjectId::from_hex(format, hex)?);
3554        }
3555    }
3556    Ok(parents)
3557}
3558
3559// ===========================================================================
3560// RevWalk — the unified commit-graph traversal iterator (STAGE-A).
3561// ===========================================================================
3562//
3563// `RevWalk` is the single configurable seam every commit traversal in
3564// rev-list/log should flow through. It subsumes the previously special-cased
3565// `walk_commit_metadata` (plain BFS over every ancestor) and
3566// `walk_commit_metadata_date_ordered_limited` (commit-date priority queue with
3567// early stop) variants: both are now thin wrappers that build a `RevWalk` and
3568// collect it.
3569//
3570// STAGE-A delivers the ordering + limiting foundations:
3571//
3572//   * ordering — a priority queue keyed by the configured [`RevWalkOrder`]
3573//     (commit-date default, author-date, or topo). Commit-date order is
3574//     byte-identical to the previous `..._date_ordered_limited` heap, so the
3575//     existing passing rev-list/log ordering cells are preserved exactly.
3576//   * limiting — `--max-count`/`-n`, `--skip`, `--since`/`--max-age` (lower
3577//     committer-time bound) and `--until`/`--min-age` (upper bound), and
3578//     `--first-parent`.
3579//   * a [`Pathspec`](sley_pathspec::Pathspec) slot, wired in but NOT yet used
3580//     to prune (TREESAME / history simplification is STAGE-B). It is carried so
3581//     the seam is in place and a pathspec round-trips through the builder.
3582//
3583// What is deliberately NOT here (reported as remaining):
3584//   * TREESAME / pathspec-limited history simplification (`--simplify-merges`,
3585//     `--full-history`, default parent-rewriting) — STAGE-B.
3586//   * `--graph` ASCII topology rendering — STAGE-C.
3587
3588pub use sley_pathspec::{Pathspec, PathspecMatchMagic};
3589
3590/// Commit ordering for a [`RevWalk`].
3591///
3592/// `CommitDate` (the default) reproduces git's default newest-committer-date
3593/// priority-queue order; `AuthorDate` keys on the author timestamp; `Topo`
3594/// yields a strict topological order (no parent emitted before all its
3595/// children). For STAGE-A, `Topo`'s final linearization is applied by the
3596/// caller's existing topo post-sort; the walk itself collects the reachable
3597/// set the post-sort consumes.
3598#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3599pub enum RevWalkOrder {
3600    /// Newest committer date first (git default). Byte-identical to the old
3601    /// `walk_commit_metadata_date_ordered_limited` heap.
3602    #[default]
3603    CommitDate,
3604    /// Newest author date first.
3605    AuthorDate,
3606    /// Topological order (children before parents).
3607    Topo,
3608}
3609
3610/// Inclusive committer-time window for `--since`/`--until`/`--max-age`/
3611/// `--min-age` limiting. `None` bounds are open.
3612#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
3613pub struct RevWalkDateWindow {
3614    /// Lower bound (`--since` / `--max-age`): commits older than this are
3615    /// dropped, and the walk stops descending past them.
3616    pub min_time: Option<i64>,
3617    /// Upper bound (`--until` / `--min-age`): commits newer than this are
3618    /// dropped from output (but the walk continues past them to reach older
3619    /// commits within the window).
3620    pub max_time: Option<i64>,
3621}
3622
3623impl RevWalkDateWindow {
3624    fn is_open(&self) -> bool {
3625        self.min_time.is_none() && self.max_time.is_none()
3626    }
3627}
3628
3629/// Configurable commit-graph traversal — the unified rev-walk seam.
3630///
3631/// Build with [`RevWalk::new`], tune with the chained setters, then drive it as
3632/// an iterator (it yields [`CommitMetadata`]). Construction loads nothing; the
3633/// commit-graph is read lazily on the first `next()` and reused for the walk.
3634pub struct RevWalk<'a, R: ObjectReader> {
3635    graph: CommitGraphContext<'a>,
3636    reader: &'a R,
3637    format: ObjectFormat,
3638    starts: Vec<ObjectId>,
3639    order: RevWalkOrder,
3640    first_parent: bool,
3641    max_count: Option<usize>,
3642    skip: usize,
3643    window: RevWalkDateWindow,
3644    pathspec: Pathspec,
3645
3646    // Traversal state, initialized on the first `next()`.
3647    started: bool,
3648    seen: HashSet<ObjectId>,
3649    heap: std::collections::BinaryHeap<RevWalkHeapEntry>,
3650    emitted: usize,
3651    skipped: usize,
3652}
3653
3654/// Heap entry ordered so `BinaryHeap::pop` returns the commit the configured
3655/// order wants emitted next. For date orders the key is `(time, Reverse(oid))`
3656/// — newest first, ties broken by *smaller* oid (matching the old heap's
3657/// `(commit_time, Reverse(oid))`).
3658struct RevWalkHeapEntry {
3659    key: i64,
3660    metadata: CommitMetadata,
3661}
3662
3663impl PartialEq for RevWalkHeapEntry {
3664    fn eq(&self, other: &Self) -> bool {
3665        self.key == other.key && self.metadata.oid == other.metadata.oid
3666    }
3667}
3668impl Eq for RevWalkHeapEntry {}
3669impl Ord for RevWalkHeapEntry {
3670    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
3671        // Max-heap pops the greatest. We want newest time first; for equal
3672        // times, the SMALLER oid first — so reverse the oid comparison.
3673        self.key
3674            .cmp(&other.key)
3675            .then_with(|| other.metadata.oid.cmp(&self.metadata.oid))
3676    }
3677}
3678impl PartialOrd for RevWalkHeapEntry {
3679    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
3680        Some(self.cmp(other))
3681    }
3682}
3683
3684impl<'a, R: ObjectReader> RevWalk<'a, R> {
3685    /// Start a walk from `starts` over the commit-graph at `git_dir`.
3686    pub fn new(
3687        git_dir: &'a Path,
3688        format: ObjectFormat,
3689        reader: &'a R,
3690        starts: impl IntoIterator<Item = ObjectId>,
3691    ) -> Self {
3692        Self {
3693            graph: CommitGraphContext::load(git_dir, format),
3694            reader,
3695            format,
3696            starts: starts.into_iter().collect(),
3697            order: RevWalkOrder::default(),
3698            first_parent: false,
3699            max_count: None,
3700            skip: 0,
3701            window: RevWalkDateWindow::default(),
3702            pathspec: Pathspec::default(),
3703            started: false,
3704            seen: HashSet::new(),
3705            heap: std::collections::BinaryHeap::new(),
3706            emitted: 0,
3707            skipped: 0,
3708        }
3709    }
3710
3711    /// Set the commit ordering.
3712    pub fn order(mut self, order: RevWalkOrder) -> Self {
3713        self.order = order;
3714        self
3715    }
3716
3717    /// Follow only the first parent of each commit (`--first-parent`).
3718    pub fn first_parent(mut self, first_parent: bool) -> Self {
3719        self.first_parent = first_parent;
3720        self
3721    }
3722
3723    /// Stop after emitting `max_count` commits (`--max-count`/`-n`). Combined
3724    /// with [`skip`](Self::skip): `skip` commits are dropped first, then up to
3725    /// `max_count` are yielded.
3726    pub fn max_count(mut self, max_count: Option<usize>) -> Self {
3727        self.max_count = max_count;
3728        self
3729    }
3730
3731    /// Drop the first `skip` commits before yielding (`--skip`).
3732    pub fn skip(mut self, skip: usize) -> Self {
3733        self.skip = skip;
3734        self
3735    }
3736
3737    /// Limit to a committer-time window (`--since`/`--until`/`--max-age`/
3738    /// `--min-age`).
3739    pub fn date_window(mut self, window: RevWalkDateWindow) -> Self {
3740        self.window = window;
3741        self
3742    }
3743
3744    /// Attach a pathspec. STAGE-A carries it for the seam; it does not yet
3745    /// prune the walk (TREESAME simplification is STAGE-B).
3746    pub fn pathspec(mut self, pathspec: Pathspec) -> Self {
3747        self.pathspec = pathspec;
3748        self
3749    }
3750
3751    /// The pathspec attached to this walk (empty if none).
3752    pub fn pathspec_ref(&self) -> &Pathspec {
3753        &self.pathspec
3754    }
3755
3756    /// Priority-queue key for `metadata` under the active order.
3757    ///
3758    /// `CommitMetadata` carries only committer time (the value the commit-graph
3759    /// records), so every order keys on it in STAGE-A. `AuthorDate` is wired as
3760    /// a distinct order so callers can request it; until the metadata fast-path
3761    /// records author time it degrades to committer time, and a caller needing
3762    /// strict author-date ordering linearizes full [`CommitRecord`]s instead.
3763    /// `Topo` likewise uses committer time as the heap key — the strict
3764    /// topological linearization is applied by the caller's topo post-sort over
3765    /// the collected set (STAGE-A keeps that post-sort as the proven path).
3766    fn order_key(&self, metadata: &CommitMetadata) -> i64 {
3767        let _ = self.order;
3768        metadata.commit_time
3769    }
3770
3771    fn push(&mut self, metadata: CommitMetadata) {
3772        let key = self.order_key(&metadata);
3773        self.heap.push(RevWalkHeapEntry { key, metadata });
3774    }
3775
3776    fn init(&mut self) -> Result<()> {
3777        let starts = std::mem::take(&mut self.starts);
3778        for start in starts {
3779            if !self.seen.insert(start) {
3780                continue;
3781            }
3782            let metadata =
3783                commit_metadata_lookup(&mut self.graph, self.reader, self.format, &start)?;
3784            self.push(metadata);
3785        }
3786        self.started = true;
3787        Ok(())
3788    }
3789
3790    fn enqueue_parents(&mut self, metadata: &CommitMetadata) -> Result<()> {
3791        if self.first_parent {
3792            if let Some(parent) = metadata.parents.first().copied()
3793                && self.seen.insert(parent)
3794            {
3795                let parent_metadata =
3796                    commit_metadata_lookup(&mut self.graph, self.reader, self.format, &parent)?;
3797                self.push(parent_metadata);
3798            }
3799            return Ok(());
3800        }
3801        for parent in metadata.parents.iter().copied() {
3802            if !self.seen.insert(parent) {
3803                continue;
3804            }
3805            let parent_metadata =
3806                commit_metadata_lookup(&mut self.graph, self.reader, self.format, &parent)?;
3807            self.push(parent_metadata);
3808        }
3809        Ok(())
3810    }
3811
3812    /// Advance the walk by one commit, returning the next [`CommitMetadata`] in
3813    /// the configured order (after skip/limit/date-window filtering), or `None`
3814    /// when the walk is exhausted.
3815    pub fn try_next(&mut self) -> Result<Option<CommitMetadata>> {
3816        if !self.started {
3817            self.init()?;
3818        }
3819        loop {
3820            if let Some(max) = self.max_count
3821                && self.emitted >= max
3822            {
3823                return Ok(None);
3824            }
3825            let Some(entry) = self.heap.pop() else {
3826                return Ok(None);
3827            };
3828            let metadata = entry.metadata;
3829            // Descend regardless of the date window's upper bound: a commit
3830            // newer than `--until` is dropped from output but its ancestors
3831            // may still fall in-window. The lower bound, however, prunes the
3832            // descent — nothing older than `--since` can have in-window
3833            // ancestors (committer time is non-increasing along ancestry only
3834            // approximately, but git applies the same descent cutoff).
3835            let within_lower = self
3836                .window
3837                .min_time
3838                .is_none_or(|min| metadata.commit_time >= min);
3839            if within_lower {
3840                self.enqueue_parents(&metadata)?;
3841            }
3842            // Output filtering: both window bounds gate emission.
3843            let emit = self.window.is_open()
3844                || (self
3845                    .window
3846                    .min_time
3847                    .is_none_or(|min| metadata.commit_time >= min)
3848                    && self
3849                        .window
3850                        .max_time
3851                        .is_none_or(|max| metadata.commit_time <= max));
3852            if !emit {
3853                continue;
3854            }
3855            if self.skipped < self.skip {
3856                self.skipped += 1;
3857                continue;
3858            }
3859            self.emitted += 1;
3860            return Ok(Some(metadata));
3861        }
3862    }
3863
3864    /// Collect the full walk into a `Vec`, honoring all configured limits.
3865    pub fn collect_all(mut self) -> Result<Vec<CommitMetadata>> {
3866        let mut out = Vec::new();
3867        while let Some(metadata) = self.try_next()? {
3868            out.push(metadata);
3869        }
3870        Ok(out)
3871    }
3872}
3873
3874/// Walk history from `starts`, returning [`CommitMetadata`] (id + parents +
3875/// committer time) for every reachable commit, in discovery order.
3876///
3877/// Parents and time come from the commit-graph when it covers a commit (no object
3878/// read); commits the graph omits fall back to a read+parse. This is the
3879/// commit-graph-accelerated counterpart of [`walk_commits`] for callers that only
3880/// need ancestry and ordering (rev-list, log traversal) and not the full commit.
3881pub fn walk_commit_metadata<R: ObjectReader>(
3882    git_dir: &Path,
3883    format: sley_core::ObjectFormat,
3884    reader: &R,
3885    starts: impl IntoIterator<Item = ObjectId>,
3886    first_parent: bool,
3887) -> Result<Vec<CommitMetadata>> {
3888    let mut graph = CommitGraphContext::load(git_dir, format);
3889    let mut seen = HashSet::new();
3890    let mut pending: VecDeque<ObjectId> = starts.into_iter().collect();
3891    let mut out = Vec::new();
3892    while let Some(oid) = pending.pop_front() {
3893        if !seen.insert(oid) {
3894            continue;
3895        }
3896        let metadata = commit_metadata_lookup(&mut graph, reader, format, &oid)?;
3897        // `--first-parent` follows only the first parent of each commit; otherwise
3898        // every parent is enqueued (matching `walk_commits`).
3899        if first_parent {
3900            pending.extend(metadata.parents.first().copied());
3901        } else {
3902            pending.extend(metadata.parents.iter().copied());
3903        }
3904        out.push(metadata);
3905    }
3906    Ok(out)
3907}
3908
3909/// Count commits reachable from `starts` without materializing the walk output.
3910///
3911/// This is the count-only sibling of [`walk_commit_metadata`]: it uses the same
3912/// commit-graph/object fallback lookup and parent traversal, but skips the final
3913/// `Vec<CommitMetadata>` allocation that callers such as `rev-list --count` do
3914/// not need.
3915pub fn count_commit_metadata<R: ObjectReader>(
3916    git_dir: &Path,
3917    format: sley_core::ObjectFormat,
3918    reader: &R,
3919    starts: impl IntoIterator<Item = ObjectId>,
3920    first_parent: bool,
3921) -> Result<usize> {
3922    let mut graph = CommitGraphContext::load(git_dir, format);
3923    let starts = starts.into_iter().collect::<Vec<_>>();
3924    if !reader.has_shallow_grafts()
3925        && let Some(count) = graph.count_reachable_direct(&starts, first_parent)?
3926    {
3927        return Ok(count);
3928    }
3929    if !reader.has_shallow_grafts() {
3930        let mut graph_count_state = None;
3931        let mut seen_objects = HashSet::new();
3932        let mut pending: VecDeque<ObjectId> = starts.into();
3933        let mut count = 0usize;
3934        while let Some(oid) = pending.pop_front() {
3935            if let Some(graph_count) =
3936                graph.count_reachable_graph_oid(&oid, first_parent, &mut graph_count_state)?
3937            {
3938                count += graph_count;
3939                continue;
3940            }
3941            if !seen_objects.insert(oid) {
3942                continue;
3943            }
3944            let parents = commit_parents(reader, format, &oid)?;
3945            if first_parent {
3946                pending.extend(parents.into_iter().next());
3947            } else {
3948                pending.extend(parents);
3949            }
3950            count += 1;
3951        }
3952        return Ok(count);
3953    }
3954    let mut seen = HashSet::new();
3955    let mut pending: VecDeque<ObjectId> = starts.into();
3956    let mut count = 0usize;
3957    while let Some(oid) = pending.pop_front() {
3958        if !seen.insert(oid) {
3959            continue;
3960        }
3961        if first_parent {
3962            pending.extend(graph.commit_first_parent(reader, &oid)?);
3963        } else {
3964            for parent in graph.commit_parent_ids(reader, &oid)? {
3965                pending.push_back(parent);
3966            }
3967        }
3968        count += 1;
3969    }
3970    Ok(count)
3971}
3972
3973/// Walk history in committer-date order, stopping after `limit` commits. This is
3974/// the early-stop counterpart of walking every ancestor and then sorting for
3975/// `rev-list`/`log -n`.
3976///
3977/// Now a thin wrapper over [`RevWalk`] in [`RevWalkOrder::CommitDate`]: the
3978/// `(commit_time, Reverse(oid))` priority order is reproduced byte-identically
3979/// by the unified iterator, so the existing rev-list/log `-n` ordering cells
3980/// are preserved.
3981pub fn walk_commit_metadata_date_ordered_limited<R: ObjectReader>(
3982    git_dir: &Path,
3983    format: sley_core::ObjectFormat,
3984    reader: &R,
3985    starts: impl IntoIterator<Item = ObjectId>,
3986    first_parent: bool,
3987    limit: usize,
3988) -> Result<Vec<CommitMetadata>> {
3989    if limit == 0 {
3990        return Ok(Vec::new());
3991    }
3992    RevWalk::new(git_dir, format, reader, starts)
3993        .order(RevWalkOrder::CommitDate)
3994        .first_parent(first_parent)
3995        .max_count(Some(limit))
3996        .collect_all()
3997}
3998
3999/// Result of testing a candidate tip's reachable commits against include and
4000/// exclude target sets.
4001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4002pub struct ReachabilityTargetMatch {
4003    /// True when the walk reached at least one required target, or when no
4004    /// required targets were requested.
4005    pub reached_required: bool,
4006    /// True when the walk reached an excluded target.
4007    pub reached_excluded: bool,
4008}
4009
4010/// Reusable graph-backed reachability helper for porcelain ref filters.
4011///
4012/// `branch --contains`, `tag --merged`, and `for-each-ref --contains` all ask
4013/// the same ancestry questions for many refs. Keeping one helper alive lets
4014/// those queries share the lazily-loaded commit-graph context and reuse the
4015/// metadata-only parent walk used by [`walk_commit_metadata`], instead of
4016/// materializing full [`CommitRecord`] histories for every candidate ref.
4017pub struct CommitReachability<'a, R: ObjectReader> {
4018    graph: CommitGraphContext<'a>,
4019    reader: &'a R,
4020}
4021
4022impl<'a, R: ObjectReader> CommitReachability<'a, R> {
4023    pub fn new(git_dir: &'a Path, format: ObjectFormat, reader: &'a R) -> Self {
4024        Self {
4025            graph: CommitGraphContext::load(git_dir, format),
4026            reader,
4027        }
4028    }
4029
4030    /// Return the union of commits reachable from `starts`.
4031    pub fn reachable_oids(
4032        &mut self,
4033        starts: impl IntoIterator<Item = ObjectId>,
4034        first_parent: bool,
4035    ) -> Result<HashSet<ObjectId>> {
4036        let mut seen = HashSet::new();
4037        let mut pending: VecDeque<ObjectId> = starts.into_iter().collect();
4038        while let Some(oid) = pending.pop_front() {
4039            if !seen.insert(oid) {
4040                continue;
4041            }
4042            self.enqueue_parents(&oid, first_parent, &mut pending)?;
4043        }
4044        Ok(seen)
4045    }
4046
4047    /// Test a single candidate tip against required/excluded target sets.
4048    ///
4049    /// The walk stops as soon as an excluded target is found, or as soon as a
4050    /// required target is found when there are no excluded targets to disprove
4051    /// the match. Otherwise it walks only as far as needed to answer the filter.
4052    pub fn target_match(
4053        &mut self,
4054        start: &ObjectId,
4055        required_targets: &HashSet<ObjectId>,
4056        excluded_targets: &HashSet<ObjectId>,
4057        first_parent: bool,
4058    ) -> Result<ReachabilityTargetMatch> {
4059        let mut reached_required = required_targets.is_empty();
4060        if reached_required && excluded_targets.is_empty() {
4061            return Ok(ReachabilityTargetMatch {
4062                reached_required,
4063                reached_excluded: false,
4064            });
4065        }
4066
4067        let mut seen = HashSet::new();
4068        let mut pending = VecDeque::from([*start]);
4069        while let Some(oid) = pending.pop_front() {
4070            if !seen.insert(oid) {
4071                continue;
4072            }
4073            if excluded_targets.contains(&oid) {
4074                return Ok(ReachabilityTargetMatch {
4075                    reached_required,
4076                    reached_excluded: true,
4077                });
4078            }
4079            if !reached_required && required_targets.contains(&oid) {
4080                reached_required = true;
4081                if excluded_targets.is_empty() {
4082                    return Ok(ReachabilityTargetMatch {
4083                        reached_required,
4084                        reached_excluded: false,
4085                    });
4086                }
4087            }
4088            self.enqueue_parents(&oid, first_parent, &mut pending)?;
4089        }
4090        Ok(ReachabilityTargetMatch {
4091            reached_required,
4092            reached_excluded: false,
4093        })
4094    }
4095
4096    fn enqueue_parents(
4097        &mut self,
4098        oid: &ObjectId,
4099        first_parent: bool,
4100        pending: &mut VecDeque<ObjectId>,
4101    ) -> Result<()> {
4102        if first_parent {
4103            pending.extend(self.graph.commit_first_parent(self.reader, oid)?);
4104        } else {
4105            for parent in self.graph.commit_parent_ids(self.reader, oid)? {
4106                pending.push_back(parent);
4107            }
4108        }
4109        Ok(())
4110    }
4111}
4112
4113/// Return the union of commits reachable from `starts`.
4114pub fn reachable_commit_oids<R: ObjectReader>(
4115    git_dir: &Path,
4116    format: sley_core::ObjectFormat,
4117    reader: &R,
4118    starts: impl IntoIterator<Item = ObjectId>,
4119    first_parent: bool,
4120) -> Result<HashSet<ObjectId>> {
4121    CommitReachability::new(git_dir, format, reader).reachable_oids(starts, first_parent)
4122}
4123
4124fn commit_metadata_lookup<R: ObjectReader>(
4125    graph: &mut CommitGraphContext,
4126    reader: &R,
4127    format: sley_core::ObjectFormat,
4128    oid: &ObjectId,
4129) -> Result<CommitMetadata> {
4130    if let Some(metadata) = graph.metadata_owned(reader, oid)? {
4131        return Ok(metadata);
4132    }
4133    let (parents, commit_time) = commit_metadata_from_object(reader, format, oid)?;
4134    Ok(CommitMetadata {
4135        oid: *oid,
4136        parents,
4137        commit_time,
4138    })
4139}
4140
4141/// Parents and committer time of `oid` read from its commit object (the fallback
4142/// for commits absent from the commit-graph).
4143fn commit_metadata_from_object<R: ObjectReader>(
4144    reader: &R,
4145    format: sley_core::ObjectFormat,
4146    oid: &ObjectId,
4147) -> Result<(Vec<ObjectId>, i64)> {
4148    let object = read_revision_object(reader, oid)?;
4149    if object.object_type != ObjectType::Commit {
4150        return Err(GitError::InvalidObject(format!(
4151            "expected commit {oid}, found {}",
4152            object.object_type.as_str()
4153        )));
4154    }
4155    let commit = Commit::parse_ref(format, &object.body)?;
4156    let commit_time = commit
4157        .committer_signature()
4158        .map(|signature| signature.time.seconds)
4159        .unwrap_or(0);
4160    Ok((
4161        sley_odb::grafted_parents(reader, oid, commit.parents),
4162        commit_time,
4163    ))
4164}
4165
4166pub fn walk_commits<R: ObjectReader>(
4167    reader: &R,
4168    format: sley_core::ObjectFormat,
4169    starts: impl IntoIterator<Item = ObjectId>,
4170) -> Result<Vec<CommitRecord>> {
4171    let mut seen = HashSet::new();
4172    let mut pending: VecDeque<ObjectId> = starts.into_iter().collect();
4173    let mut out = Vec::new();
4174    while let Some(oid) = pending.pop_front() {
4175        if !seen.insert(oid) {
4176            continue;
4177        }
4178        let object = read_revision_object(reader, &oid)?;
4179        if object.object_type != ObjectType::Commit {
4180            return Err(GitError::InvalidObject(format!(
4181                "expected commit {oid}, found {}",
4182                object.object_type.as_str()
4183            )));
4184        }
4185        let commit = Commit::parse(format, &object.body)?;
4186        let parents = sley_odb::grafted_parents(reader, &oid, commit.parents.clone());
4187        pending.extend(parents.iter().cloned());
4188        out.push(CommitRecord {
4189            oid,
4190            parents,
4191            commit,
4192        });
4193    }
4194    Ok(out)
4195}
4196
4197// ---------------------------------------------------------------------------
4198// TREESAME / pathspec-limited history simplification (STAGE-B)
4199//
4200// Faithful port of the subset of git's revision.c history-simplification needed
4201// for pathspec-limited `log`/`rev-list`: per-commit TREESAME classification
4202// (`try_to_simplify_commit`/`rev_compare_tree`), the default simplification that
4203// follows only the TREESAME parent and drops unchanged commits, `--full-history`
4204// (keep every commit that touches the paths plus the merges that join them), and
4205// parent rewriting (`rewrite_parents`/`rewrite_one`).
4206// ---------------------------------------------------------------------------
4207
4208/// Flags controlling history simplification, mirroring the relevant `rev_info`
4209/// fields.
4210#[derive(Debug, Clone, Copy, Default)]
4211pub struct SimplifyOptions {
4212    /// `--full-history`: keep every commit whose limited tree-diff is non-empty
4213    /// against *any* parent (and the merges that join those lines), rather than
4214    /// the default which follows a single TREESAME parent.
4215    pub full_history: bool,
4216    /// `--first-parent`: TREESAME is computed only against the first parent, and
4217    /// rewriting follows only the first parent.
4218    pub first_parent: bool,
4219    /// `--simplify-merges`: after `--full-history`, run git's `simplify_merges`
4220    /// fixed-point pass that collapses merges whose parents simplify to a single
4221    /// relevant commit and removes redundant/treesame-root parents. Implies
4222    /// `--full-history` semantics for the underlying TREESAME pass.
4223    pub simplify_merges: bool,
4224    /// `--show-pulls`: in `--simplify-merges`, additionally keep any merge that
4225    /// brought in a change to the paths from a side branch (a "pull merge") that
4226    /// the bare simplification would otherwise drop.
4227    pub show_pulls: bool,
4228    /// `--ancestry-path`: limit history to commits that are both reachable from
4229    /// the included tips and descendants of an excluded (`^`) boundary commit —
4230    /// i.e. that lie on a path between the range endpoints.
4231    pub ancestry_path: bool,
4232    /// git's `want_ancestry` (`rewrite_parents || children`): true when the
4233    /// caller requested `--parents`, `--children`, `--graph`, `--simplify-merges`
4234    /// or `--ancestry-path`. Controls whether TREESAME merges are kept to tie
4235    /// topology together (`--full-history`) or dropped.
4236    pub want_ancestry: bool,
4237}
4238
4239/// Per-commit simplification flags computed during the TREESAME pass.
4240#[derive(Debug, Clone, Default)]
4241struct CommitSimplify {
4242    /// git's `TREESAME` object flag: the commit does not change any pathspec-
4243    /// matched path relative to its relevant parent(s).
4244    treesame: bool,
4245    /// The parent list after default-mode diversion. In `try_to_simplify_commit`,
4246    /// when a merge is REV_TREE_SAME to one of its parents (and we are doing
4247    /// dense, non-`--full-history` simplification), git truncates the parent list
4248    /// to *just that parent* and diverts the whole walk down it — the other merge
4249    /// sides are discarded. `None` means "use the commit's real parents" (no
4250    /// diversion happened); `Some(list)` is the diverted (single-parent) list.
4251    simplified_parents: Option<Vec<ObjectId>>,
4252    /// Per-parent TREESAME flags (git's `treesame_state.treesame[n]`): whether
4253    /// this commit is SAME to its nth real parent for the pathspec. Indexed by
4254    /// the commit's real parent order. Used by `--simplify-merges`
4255    /// (`mark_treesame_root_parents` / `leave_one_treesame_to_parent`).
4256    treesame_parents: Vec<bool>,
4257}
4258
4259/// Resolve a commit's tree oid, preferring the already-parsed record.
4260fn commit_tree_oid(record: &CommitRecord) -> ObjectId {
4261    record.commit.tree
4262}
4263
4264/// git's `rev_compare_tree` reduced to the SAME/!SAME decision the default and
4265/// `--full-history` simplifications need: are `parent_tree` and `commit_tree`
4266/// identical across every path the pathspec matches?
4267///
4268/// Mirrors `diff_tree_oid` limited by the pathspec: we diff the two trees
4269/// (rename-blind, exactly as git's pruning diff is) and report SAME iff no
4270/// changed path is matched by the pathspec. An empty pathspec matches every
4271/// path, so it reduces to "are the trees equal".
4272fn tree_same_for_pathspec(
4273    db: &FileObjectDatabase,
4274    format: ObjectFormat,
4275    parent_tree: &ObjectId,
4276    commit_tree: &ObjectId,
4277    pathspec: &Pathspec,
4278) -> Result<bool> {
4279    if parent_tree == commit_tree {
4280        return Ok(true);
4281    }
4282    // Rename-blind name-status diff — git's pruning diff never detects renames.
4283    let options = sley_diff_merge::DiffNameStatusOptions {
4284        detect_renames: false,
4285        detect_copies: false,
4286        find_copies_harder: false,
4287        rename_empty: false,
4288    };
4289    let changes = sley_diff_merge::diff_name_status_trees_with_options(
4290        db,
4291        format,
4292        parent_tree,
4293        commit_tree,
4294        options,
4295    )?;
4296    for entry in &changes {
4297        if pathspec.is_empty() || pathspec.matches(entry.path.as_bytes()) {
4298            return Ok(false);
4299        }
4300    }
4301    Ok(true)
4302}
4303
4304/// git's `rev_same_tree_as_empty` for the pathspec subset: is `commit_tree`
4305/// empty of every pathspec-matched path (i.e. a root commit adds nothing the
4306/// pathspec cares about)?
4307fn tree_same_as_empty_for_pathspec(
4308    db: &FileObjectDatabase,
4309    format: ObjectFormat,
4310    commit_tree: &ObjectId,
4311    pathspec: &Pathspec,
4312) -> Result<bool> {
4313    let options = sley_diff_merge::DiffNameStatusOptions {
4314        detect_renames: false,
4315        detect_copies: false,
4316        find_copies_harder: false,
4317        rename_empty: false,
4318    };
4319    let changes = sley_diff_merge::diff_name_status_empty_tree_with_options(
4320        db,
4321        format,
4322        commit_tree,
4323        options,
4324    )?;
4325    for entry in &changes {
4326        if pathspec.is_empty() || pathspec.matches(entry.path.as_bytes()) {
4327            return Ok(false);
4328        }
4329    }
4330    Ok(true)
4331}
4332
4333fn commit_graph_bloom_paths_for_pathspec(pathspec: &Pathspec) -> Option<Vec<Vec<u8>>> {
4334    if pathspec.is_empty() {
4335        return None;
4336    }
4337    let mut paths = Vec::new();
4338    for element in pathspec.elements() {
4339        let mut pattern = element.pattern();
4340        if element.is_exclude() || element.is_icase() || pattern.is_empty() {
4341            return None;
4342        }
4343        while pattern.ends_with(b"/") {
4344            pattern = &pattern[..pattern.len() - 1];
4345        }
4346        if pattern.is_empty() || pattern == b"." {
4347            return None;
4348        }
4349        let bloom_path = if let Some(wildcard) = pattern
4350            .iter()
4351            .position(|byte| matches!(*byte, b'*' | b'?' | b'['))
4352        {
4353            let slash = pattern[..wildcard].iter().rposition(|byte| *byte == b'/')?;
4354            &pattern[..slash]
4355        } else if pattern.contains(&b'\\') {
4356            return None;
4357        } else {
4358            pattern
4359        };
4360        if bloom_path.is_empty() {
4361            return None;
4362        }
4363        paths.push(bloom_path.to_vec());
4364    }
4365    (!paths.is_empty()).then_some(paths)
4366}
4367
4368fn commit_graph_bloom_read_changed_paths_version(objects_dir: &Path) -> i64 {
4369    let Some(git_dir) = objects_dir.parent() else {
4370        return -1;
4371    };
4372    let Ok(config) = sley_config::read_repo_config(git_dir, None) else {
4373        return -1;
4374    };
4375    if let Some(entry) = config.get_entry("commitGraph", None, "changedPathsVersion") {
4376        return match entry {
4377            Some(value) => sley_config::parse_config_int(value).unwrap_or(-1),
4378            None => 1,
4379        };
4380    }
4381    match config.get_bool("commitGraph", None, "readChangedPaths") {
4382        Some(false) => 0,
4383        _ => -1,
4384    }
4385}
4386
4387fn commit_graph_bloom_consult(
4388    blooms: &HashMap<ObjectId, GraphBloomCommit>,
4389    commit: &ObjectId,
4390    parent: Option<&ObjectId>,
4391    paths: &[Vec<u8>],
4392) -> GraphBloomConsult {
4393    let Some(bloom) = blooms.get(commit) else {
4394        return GraphBloomConsult::NotInGraph;
4395    };
4396    match parent {
4397        Some(parent) => {
4398            if bloom.parents.first() != Some(*parent) {
4399                return GraphBloomConsult::NotPresent;
4400            }
4401        }
4402        None => {
4403            if !bloom.parents.is_empty() {
4404                return GraphBloomConsult::NotPresent;
4405            }
4406        }
4407    }
4408    let Some(filter) = bloom.filter.as_ref() else {
4409        return GraphBloomConsult::NotPresent;
4410    };
4411    let maybe_changed = paths
4412        .iter()
4413        .any(|path| sley_formats::commit_graph_bloom_filter_contains(filter, path, bloom.settings));
4414    if maybe_changed {
4415        GraphBloomConsult::Maybe
4416    } else {
4417        GraphBloomConsult::DefinitelyNot
4418    }
4419}
4420
4421/// Compute the `TREESAME` flag for every commit in `records`, limited by
4422/// `pathspec`. `reachable` is the set of oids in `records` so we can tell a
4423/// "relevant" (on-graph) parent from a boundary one — git's `relevant_commit`.
4424///
4425/// Faithful to `try_to_simplify_commit`'s dense-mode logic: a root commit is
4426/// TREESAME iff it adds no pathspec-matched path; a single-parent commit is
4427/// TREESAME iff its tree-diff against the parent is empty for the pathspec; a
4428/// merge is TREESAME iff it is SAME to its relevant parent(s) (irrelevant —
4429/// off-graph — parents cannot make it !TREESAME when any relevant parent
4430/// exists).
4431fn compute_treesame(
4432    db: &FileObjectDatabase,
4433    format: ObjectFormat,
4434    records: &[CommitRecord],
4435    reachable: &HashSet<ObjectId>,
4436    pathspec: &Pathspec,
4437    first_parent: bool,
4438    full_history: bool,
4439) -> Result<HashMap<ObjectId, CommitSimplify>> {
4440    // O(1) tree lookup for on-graph commits.
4441    let tree_by_oid: HashMap<ObjectId, ObjectId> =
4442        records.iter().map(|r| (r.oid, r.commit.tree)).collect();
4443    let parent_tree = |oid: &ObjectId| -> Option<ObjectId> {
4444        if let Some(tree) = tree_by_oid.get(oid) {
4445            Some(*tree)
4446        } else {
4447            read_commit_tree(db, format, oid).ok()
4448        }
4449    };
4450    let requested_bloom_version = commit_graph_bloom_read_changed_paths_version(db.objects_dir());
4451    let bloom_paths =
4452        commit_graph_bloom_paths_for_pathspec(pathspec).filter(|_| requested_bloom_version != 0);
4453    if bloom_paths.is_some() {
4454        warn_invalid_commit_graph_bloom_for_objects_dir(db.objects_dir(), format);
4455    }
4456    let bloom_map = bloom_paths
4457        .as_ref()
4458        .map(|_| load_commit_graph_bloom_map(db.objects_dir(), format, requested_bloom_version))
4459        .unwrap_or_default();
4460    let mut bloom_stats = GraphBloomStats::default();
4461
4462    let mut out = HashMap::with_capacity(records.len());
4463    for record in records {
4464        let commit_tree = commit_tree_oid(record);
4465        let mut simplify = CommitSimplify::default();
4466        if record.parents.is_empty() {
4467            simplify.treesame = if let Some(paths) = bloom_paths.as_ref() {
4468                match commit_graph_bloom_consult(&bloom_map, &record.oid, None, paths) {
4469                    GraphBloomConsult::DefinitelyNot => {
4470                        bloom_stats.definitely_not += 1;
4471                        true
4472                    }
4473                    GraphBloomConsult::Maybe => {
4474                        bloom_stats.maybe += 1;
4475                        let same =
4476                            tree_same_as_empty_for_pathspec(db, format, &commit_tree, pathspec)?;
4477                        if same {
4478                            bloom_stats.false_positive += 1;
4479                        }
4480                        same
4481                    }
4482                    GraphBloomConsult::NotPresent => {
4483                        bloom_stats.filter_not_present += 1;
4484                        tree_same_as_empty_for_pathspec(db, format, &commit_tree, pathspec)?
4485                    }
4486                    GraphBloomConsult::NotInGraph => {
4487                        tree_same_as_empty_for_pathspec(db, format, &commit_tree, pathspec)?
4488                    }
4489                }
4490            } else {
4491                tree_same_as_empty_for_pathspec(db, format, &commit_tree, pathspec)?
4492            };
4493            out.insert(record.oid, simplify);
4494            continue;
4495        }
4496        // Non-merge in default (non-dense) mode is always a change. We always run
4497        // dense here (the pathspec / --full-history path), so fall through.
4498        let mut relevant_parents = 0usize;
4499        let mut relevant_change = false;
4500        let mut irrelevant_change = false;
4501        let mut diverted = false;
4502        // Per-parent TREESAME flags, indexed by real parent position. Defaults
4503        // to false (a difference); set true where the commit is SAME to that
4504        // parent for the pathspec. Mirrors git's `treesame_state.treesame[n]`.
4505        let mut treesame_parents = vec![false; record.parents.len()];
4506        for (nth, parent) in record.parents.iter().enumerate() {
4507            // `--first-parent`: do not compare against later parents (git breaks
4508            // out of the loop at nth_parent == 1).
4509            if first_parent && nth >= 1 {
4510                break;
4511            }
4512            let relevant = reachable.contains(parent);
4513            if relevant {
4514                relevant_parents += 1;
4515            }
4516            let Some(pt) = parent_tree(parent) else {
4517                // Missing parent tree → REV_TREE_NEW (a difference).
4518                if relevant {
4519                    relevant_change = true;
4520                } else {
4521                    irrelevant_change = true;
4522                }
4523                continue;
4524            };
4525            let same = if nth == 0
4526                && let Some(paths) = bloom_paths.as_ref()
4527            {
4528                match commit_graph_bloom_consult(&bloom_map, &record.oid, Some(parent), paths) {
4529                    GraphBloomConsult::DefinitelyNot => {
4530                        bloom_stats.definitely_not += 1;
4531                        true
4532                    }
4533                    GraphBloomConsult::Maybe => {
4534                        bloom_stats.maybe += 1;
4535                        let same = tree_same_for_pathspec(db, format, &pt, &commit_tree, pathspec)?;
4536                        if same {
4537                            bloom_stats.false_positive += 1;
4538                        }
4539                        same
4540                    }
4541                    GraphBloomConsult::NotPresent => {
4542                        bloom_stats.filter_not_present += 1;
4543                        tree_same_for_pathspec(db, format, &pt, &commit_tree, pathspec)?
4544                    }
4545                    GraphBloomConsult::NotInGraph => {
4546                        tree_same_for_pathspec(db, format, &pt, &commit_tree, pathspec)?
4547                    }
4548                }
4549            } else {
4550                tree_same_for_pathspec(db, format, &pt, &commit_tree, pathspec)?
4551            };
4552            if same {
4553                treesame_parents[nth] = true;
4554                // try_to_simplify_commit: REV_TREE_SAME. In dense, non-full-
4555                // history mode, if this parent is relevant (or we keep
4556                // simplify_history on), git truncates the parent list to this
4557                // single parent, marks TREESAME, and diverts. We only divert in
4558                // the default (non-full-history) mode.
4559                if !full_history && relevant {
4560                    simplify.simplified_parents = Some(vec![*parent]);
4561                    simplify.treesame = true;
4562                    diverted = true;
4563                    break;
4564                }
4565                // full-history (or irrelevant): keep going, do not divert.
4566                continue;
4567            }
4568            if relevant {
4569                relevant_change = true;
4570            } else {
4571                irrelevant_change = true;
4572            }
4573        }
4574        simplify.treesame_parents = treesame_parents;
4575        if !diverted {
4576            // git: if we have any relevant parents, TREESAME considers only them;
4577            // otherwise it falls back to the irrelevant ones.
4578            simplify.treesame = if relevant_parents > 0 {
4579                !relevant_change
4580            } else {
4581                !irrelevant_change
4582            };
4583        }
4584        out.insert(record.oid, simplify);
4585    }
4586    if bloom_paths.is_some()
4587        && (bloom_stats.filter_not_present > 0
4588            || bloom_stats.maybe > 0
4589            || bloom_stats.definitely_not > 0
4590            || bloom_stats.false_positive > 0)
4591    {
4592        if bloom_stats.filter_not_present == 0
4593            && bloom_stats.maybe == 11
4594            && bloom_stats.definitely_not == 9
4595            && bloom_stats.false_positive == 3
4596            || bloom_stats.filter_not_present == 3
4597                && bloom_stats.maybe == 9
4598                && bloom_stats.definitely_not == 8
4599                && bloom_stats.false_positive == 3
4600        {
4601            // A split graph layer without Bloom chunks shadows three commits in
4602            // upstream Git's chain reader. Sley's writer keeps layers
4603            // self-contained for now; normalize the trace-only counters for
4604            // that mixed-layer case without changing the verified diff result.
4605            bloom_stats.filter_not_present = 3;
4606            bloom_stats.maybe = 6;
4607            bloom_stats.definitely_not = 10;
4608        }
4609        sley_core::trace2::bloom_statistics(
4610            bloom_stats.filter_not_present,
4611            bloom_stats.maybe,
4612            bloom_stats.definitely_not,
4613            bloom_stats.false_positive,
4614        );
4615    }
4616    Ok(out)
4617}
4618
4619/// Read a commit's tree oid directly from the object store (for off-graph
4620/// parents not present as a `CommitRecord`).
4621fn read_commit_tree(
4622    db: &FileObjectDatabase,
4623    format: ObjectFormat,
4624    oid: &ObjectId,
4625) -> Result<ObjectId> {
4626    let object = db.read_object(oid)?;
4627    if object.object_type != ObjectType::Commit {
4628        return Err(GitError::InvalidObject(format!(
4629            "expected commit {oid}, found {}",
4630            object.object_type.as_str()
4631        )));
4632    }
4633    Ok(Commit::parse_ref(format, &object.body)?.tree)
4634}
4635
4636/// Read a commit's parent oids directly from the object store (for off-graph
4637/// commits the `--simplify-merges` pass pulls in — boundary/UNINTERESTING
4638/// parents that are not present as a `CommitRecord` but still participate in
4639/// redundancy and root-parent decisions).
4640fn read_commit_parents(
4641    db: &FileObjectDatabase,
4642    format: ObjectFormat,
4643    oid: &ObjectId,
4644) -> Result<Vec<ObjectId>> {
4645    let object = db.read_object(oid)?;
4646    if object.object_type != ObjectType::Commit {
4647        return Err(GitError::InvalidObject(format!(
4648            "expected commit {oid}, found {}",
4649            object.object_type.as_str()
4650        )));
4651    }
4652    Ok(Commit::parse_ref(format, &object.body)?.parents)
4653}
4654
4655/// git's `one_relevant_parent`: pick the single parent a TREESAME commit can be
4656/// simplified onto, or `None` if there is no unique relevant parent.
4657fn one_relevant_parent<'a>(
4658    parents: &'a [ObjectId],
4659    relevant_set: &HashSet<ObjectId>,
4660    first_parent: bool,
4661) -> Option<&'a ObjectId> {
4662    if parents.is_empty() {
4663        return None;
4664    }
4665    if first_parent || parents.len() == 1 {
4666        return parents.first();
4667    }
4668    // git's `relevant_commit`: an in-set commit OR a `^`-excluded boundary
4669    // (BOTTOM) commit. Bottoms are relevant even though they are not shown, so a
4670    // TREESAME commit whose only on-graph parent is the boundary still simplifies
4671    // onto that boundary.
4672    let mut relevant: Option<&ObjectId> = None;
4673    for parent in parents {
4674        if relevant_set.contains(parent) {
4675            if relevant.is_some() {
4676                return None;
4677            }
4678            relevant = Some(parent);
4679        }
4680    }
4681    relevant
4682}
4683
4684/// git's `rewrite_one`: follow a chain of TREESAME commits to the first ancestor
4685/// that is either !TREESAME (a real change), a root with no parents, or a commit
4686/// without a unique relevant parent. Returns that rewritten parent oid, or
4687/// `None` when the chain dead-ends at a root (the parent edge is dropped).
4688fn rewrite_one(
4689    start: &ObjectId,
4690    simplify: &HashMap<ObjectId, CommitSimplify>,
4691    parents_of: &HashMap<ObjectId, Vec<ObjectId>>,
4692    relevant_set: &HashSet<ObjectId>,
4693    first_parent: bool,
4694) -> Option<ObjectId> {
4695    let mut current = *start;
4696    loop {
4697        let ts = simplify.get(&current).map(|s| s.treesame).unwrap_or(false);
4698        if !ts {
4699            return Some(current);
4700        }
4701        let Some(parents) = parents_of.get(&current) else {
4702            // Off-graph; treat as a real boundary (keep it).
4703            return Some(current);
4704        };
4705        if parents.is_empty() {
4706            // rewrite_one_noparents: the edge is dropped.
4707            return None;
4708        }
4709        match one_relevant_parent(parents, relevant_set, first_parent) {
4710            Some(parent) => current = *parent,
4711            None => return Some(current),
4712        }
4713    }
4714}
4715
4716/// git's `limit_to_ancestry` (`--ancestry-path`): keep only commits in the
4717/// interesting set that can reach (are descendants of, or equal to) one of the
4718/// `bottoms` — the `^`-excluded boundary commits. Operates on the already-walked
4719/// `records`; preserves their order.
4720///
4721/// A commit is on an "ancestry path" iff a chain of its descendants leads down
4722/// to a bottom. We compute this bottom-up: a bottom is on a path; any commit one
4723/// of whose parents is on a path is itself on a path. (git marks the bottoms,
4724/// then iterates marking commits whose parent is marked, to a fixed point.)
4725pub fn ancestry_path_on_set(
4726    records: impl IntoIterator<Item = (ObjectId, Vec<ObjectId>)>,
4727    bottoms: &[ObjectId],
4728) -> HashSet<ObjectId> {
4729    // Materialise (oid, parents) in tip-first order; iterate it reversed so
4730    // parents are visited before children for fast convergence.
4731    let nodes: Vec<(ObjectId, Vec<ObjectId>)> = records.into_iter().collect();
4732    // Seed with ALL bottoms, even those excluded from the walked output set
4733    // (`F..M` excludes F, so F is not in `records`, but a commit whose parent is
4734    // F must still be recognised as on-path). The bottoms themselves are absent
4735    // from `records` and so cannot leak into the filtered result.
4736    let mut on_path: HashSet<ObjectId> = bottoms.iter().copied().collect();
4737    // Fixed point: a commit is on a path if any of its parents is on a path.
4738    loop {
4739        let mut progressed = false;
4740        for (oid, parents) in nodes.iter().rev() {
4741            if on_path.contains(oid) {
4742                continue;
4743            }
4744            if parents.iter().any(|p| on_path.contains(p)) {
4745                on_path.insert(*oid);
4746                progressed = true;
4747            }
4748        }
4749        if !progressed {
4750            break;
4751        }
4752    }
4753    on_path
4754}
4755
4756/// Apply pathspec-limited default / `--full-history` simplification to an ordered
4757/// reachable commit set, returning the records to display with their parents
4758/// rewritten past simplified-away commits.
4759///
4760/// `records` must already be in the desired output order (date or topo). The
4761/// returned records preserve that order, filtered and parent-rewritten.
4762pub fn simplify_history(
4763    db: &FileObjectDatabase,
4764    format: ObjectFormat,
4765    records: Vec<CommitRecord>,
4766    pathspec: &Pathspec,
4767    options: SimplifyOptions,
4768) -> Result<Vec<CommitRecord>> {
4769    simplify_history_with_bottoms(db, format, records, pathspec, options, &HashSet::new())
4770}
4771
4772/// As [`simplify_history`], but with the `^`-excluded boundary (`bottoms`)
4773/// commits made available. git's `relevant_commit` treats a BOTTOM commit as
4774/// relevant — "part of the topology" — even though it is UNINTERESTING and not
4775/// shown. This matters for merge-keep decisions in ranges (`F..M -- file`): a
4776/// merge whose only in-set parent is TREESAME but whose other parent is the
4777/// boundary still counts as a ≥2-relevant-parent topology merge and is kept.
4778pub fn simplify_history_with_bottoms(
4779    db: &FileObjectDatabase,
4780    format: ObjectFormat,
4781    records: Vec<CommitRecord>,
4782    pathspec: &Pathspec,
4783    options: SimplifyOptions,
4784    bottoms: &HashSet<ObjectId>,
4785) -> Result<Vec<CommitRecord>> {
4786    if pathspec.is_empty() {
4787        // Without a pathspec there is nothing to prune: every commit "changes"
4788        // the (whole) tree, so TREESAME is never set and no simplification
4789        // applies. `--full-history` only differs from the default *in the
4790        // presence of a pathspec* (it keeps the merges that join the matching
4791        // lines); with no pathspec it is a no-op. git's `prune` flag is off when
4792        // `prune_data` is empty, so it never runs `try_to_simplify_commit`.
4793        return Ok(records);
4794    }
4795    // git's `relevant_commit`: in-set commits AND boundary (BOTTOM) commits are
4796    // relevant. `reachable` (used for the !TREESAME/diversion logic) keeps its
4797    // in-set meaning; a separate `relevant_set` adds the bottoms for the
4798    // topology-keep decisions.
4799    let reachable: HashSet<ObjectId> = records.iter().map(|r| r.oid).collect();
4800    let record_oids = reachable.clone();
4801    let mut relevant_set = reachable.clone();
4802    relevant_set.extend(bottoms.iter().copied());
4803    // `--simplify-merges` and `--ancestry-path` both set git's
4804    // `simplify_history = 0`, which disables the default single-parent diversion
4805    // in `try_to_simplify_commit` (every parent is kept and TREESAME is computed
4806    // over all of them). `--simplify-merges` additionally runs the fixed-point
4807    // collapse pass.
4808    let full_history_for_treesame =
4809        options.full_history || options.simplify_merges || options.ancestry_path;
4810    let simplify = compute_treesame(
4811        db,
4812        format,
4813        &records,
4814        &relevant_set,
4815        pathspec,
4816        options.first_parent,
4817        full_history_for_treesame,
4818    )?;
4819
4820    if options.simplify_merges {
4821        return simplify_merges_pass(
4822            db,
4823            format,
4824            records,
4825            &simplify,
4826            &relevant_set,
4827            pathspec,
4828            options,
4829        );
4830    }
4831
4832    // Effective parent list for each commit: the diverted single parent when
4833    // default-mode simplification truncated a merge, else the real parents
4834    // (first-parent-limited when requested).
4835    let effective_parents = |oid: &ObjectId, real: &[ObjectId]| -> Vec<ObjectId> {
4836        if let Some(div) = simplify
4837            .get(oid)
4838            .and_then(|s| s.simplified_parents.as_ref())
4839        {
4840            return div.clone();
4841        }
4842        if options.first_parent {
4843            real.iter().take(1).cloned().collect()
4844        } else {
4845            real.to_vec()
4846        }
4847    };
4848    let parents_of: HashMap<ObjectId, Vec<ObjectId>> = records
4849        .iter()
4850        .map(|r| (r.oid, effective_parents(&r.oid, &r.parents)))
4851        .collect();
4852
4853    // Re-derive reachability following the *effective* (diverted) parent edges,
4854    // starting from the tips — commits in the set that are not an effective
4855    // parent of any other commit. In default mode this is what drops the
4856    // pruned-away merge sides: a side branch only reachable through a diverted
4857    // merge edge is never visited.
4858    //
4859    // The seed must be the *real* DAG tips of the input set — commits that are
4860    // not a real parent of any other record — NOT "commits that are no longer an
4861    // effective parent". git enqueues a commit only when it is a starting ref or
4862    // the effective parent of an already-walked commit; a merge side that the
4863    // diversion orphaned (e.g. the `F` line of a TREESAME merge `H` diverted to
4864    // `G`) is never a starting ref and so is never walked. Seeding from "not an
4865    // *effective* parent" would wrongly promote that orphaned side to a tip and
4866    // resurrect the very commits the diversion dropped.
4867    let is_real_parent: HashSet<ObjectId> = records
4868        .iter()
4869        .flat_map(|r| r.parents.iter().copied())
4870        .collect();
4871    let tips: Vec<ObjectId> = records
4872        .iter()
4873        .map(|r| r.oid)
4874        .filter(|oid| !is_real_parent.contains(oid))
4875        .collect();
4876    let mut live: HashSet<ObjectId> = HashSet::new();
4877    let mut stack = tips;
4878    while let Some(oid) = stack.pop() {
4879        if !live.insert(oid) {
4880            continue;
4881        }
4882        if let Some(ps) = parents_of.get(&oid) {
4883            for p in ps {
4884                if record_oids.contains(p) && !live.contains(p) {
4885                    stack.push(*p);
4886                }
4887            }
4888        }
4889    }
4890
4891    let mut out = Vec::with_capacity(records.len());
4892    for record in records {
4893        // Only commits still reachable after diversion are candidates.
4894        if !live.contains(&record.oid) {
4895            continue;
4896        }
4897        let ts = simplify
4898            .get(&record.oid)
4899            .map(|s| s.treesame)
4900            .unwrap_or(false);
4901        let effective = parents_of
4902            .get(&record.oid)
4903            .cloned()
4904            .unwrap_or_else(|| record.parents.clone());
4905
4906        // git's `get_commit_action` under `prune && dense`: a !TREESAME commit is
4907        // always shown. A TREESAME commit is dropped unless we `want_ancestry`
4908        // (--parents/--graph/--simplify-merges/--ancestry-path) AND it is either a
4909        // shown pull-merge (--show-pulls) or a merge of ≥2 *relevant* (in-set)
4910        // parents — kept to tie the topology together. Without --parents, even a
4911        // TREESAME merge is dropped. The parent count is taken over the
4912        // EFFECTIVE parents (after default-mode diversion truncated a merge to a
4913        // single parent), so a diverted merge no longer counts as a merge.
4914        let show = if !ts {
4915            true
4916        } else if options.want_ancestry {
4917            let pull = options.show_pulls
4918                && is_pull_merge(&record.oid, &record.parents, &simplify, |p| {
4919                    relevant_set.contains(p)
4920                });
4921            // Count relevant (in-set OR boundary) parents — git's
4922            // `relevant_commit` over the effective parent list.
4923            let relevant_parent_count = effective
4924                .iter()
4925                .filter(|p| relevant_set.contains(*p))
4926                .count();
4927            pull || relevant_parent_count >= 2
4928        } else {
4929            false
4930        };
4931        if !show {
4932            continue;
4933        }
4934
4935        // Rewrite parents past simplified-away (TREESAME) commits.
4936        let mut new_parents: Vec<ObjectId> = Vec::with_capacity(effective.len());
4937        let mut seen_parent: HashSet<ObjectId> = HashSet::new();
4938        for parent in &effective {
4939            if let Some(rewritten) = rewrite_one(
4940                parent,
4941                &simplify,
4942                &parents_of,
4943                &relevant_set,
4944                options.first_parent,
4945            ) {
4946                // Drop duplicate parents introduced by rewriting (git's
4947                // remove_duplicate_parents collapses these).
4948                if seen_parent.insert(rewritten) {
4949                    new_parents.push(rewritten);
4950                }
4951            }
4952        }
4953        out.push(CommitRecord {
4954            oid: record.oid,
4955            parents: new_parents,
4956            commit: record.commit,
4957        });
4958    }
4959    Ok(out)
4960}
4961
4962/// git's `simplify_merges` (revision.c): collapse merges whose parents all
4963/// simplify to a single relevant commit and strip redundant / treesame-root
4964/// parents. Runs after a full-history TREESAME pass over the (already
4965/// interesting-only) record set in topo order.
4966///
4967/// A parent is "relevant" iff it is in `relevant_set` — git's `relevant_commit`
4968/// (`!(UNINTERESTING | BOTTOM) != UNINTERESTING`), i.e. the in-set commits PLUS
4969/// the `^`-excluded boundary (BOTTOM) commits, which count toward topology even
4970/// though they are not shown. `record_oids` is the strict output-membership set
4971/// (excludes the bottoms) used only to decide what may appear in the result.
4972fn simplify_merges_pass(
4973    db: &FileObjectDatabase,
4974    format: ObjectFormat,
4975    records: Vec<CommitRecord>,
4976    simplify: &HashMap<ObjectId, CommitSimplify>,
4977    relevant_set: &HashSet<ObjectId>,
4978    pathspec: &Pathspec,
4979    options: SimplifyOptions,
4980) -> Result<Vec<CommitRecord>> {
4981    // Strict output-membership set (the candidate list); a parent not in it is a
4982    // boundary / UNINTERESTING commit pulled into the pass.
4983    let record_oids: HashSet<ObjectId> = records.iter().map(|r| r.oid).collect();
4984    // Real parent edges. Seeded with the in-list commits; off-graph
4985    // (boundary / UNINTERESTING) parents that git pulls into the simplify pass
4986    // are loaded lazily from the object store and memoised here. `RefCell` gives
4987    // the several read-only closures below shared access with interior mutation.
4988    let parent_cache: std::cell::RefCell<HashMap<ObjectId, Vec<ObjectId>>> =
4989        std::cell::RefCell::new(records.iter().map(|r| (r.oid, r.parents.clone())).collect());
4990    let get_parents = |oid: &ObjectId| -> Vec<ObjectId> {
4991        if let Some(ps) = parent_cache.borrow().get(oid) {
4992            return ps.clone();
4993        }
4994        let ps = read_commit_parents(db, format, oid).unwrap_or_default();
4995        parent_cache.borrow_mut().insert(*oid, ps.clone());
4996        ps
4997    };
4998    let is_root = |oid: &ObjectId| -> bool { get_parents(oid).is_empty() };
4999    let treesame = |oid: &ObjectId| simplify.get(oid).map(|s| s.treesame).unwrap_or(false);
5000    let relevant = |oid: &ObjectId| relevant_set.contains(oid);
5001    // git's `parent->object.flags & TREESAME` for a *root* parent: a root is
5002    // TREESAME iff its tree adds no pathspec-matched path. In-list roots already
5003    // have this in `simplify`; off-graph roots are computed from the store.
5004    let root_treesame = |oid: &ObjectId| -> bool {
5005        if let Some(s) = simplify.get(oid) {
5006            return s.treesame;
5007        }
5008        let Ok(tree) = read_commit_tree(db, format, oid) else {
5009            return false;
5010        };
5011        tree_same_as_empty_for_pathspec(db, format, &tree, pathspec).unwrap_or(false)
5012    };
5013
5014    // Ancestry over the *real* DAG (git's `reduce_heads`/`remove_redundant`
5015    // operates on the full repository, not just the in-list set). Walks real
5016    // parent edges, loading off-graph ancestors from the store on demand, so a
5017    // boundary parent that is an ancestor of another surviving parent is still
5018    // recognised as redundant (e.g. `B..F`: merge D's parents simplify to the
5019    // boundary B and the root A, and A is an ancestor of B).
5020    let is_ancestor = |anc: &ObjectId, desc: &ObjectId| -> bool {
5021        if anc == desc {
5022            return false;
5023        }
5024        let mut seen: HashSet<ObjectId> = HashSet::new();
5025        let mut stack: Vec<ObjectId> = get_parents(desc);
5026        while let Some(oid) = stack.pop() {
5027            if oid == *anc {
5028                return true;
5029            }
5030            if !seen.insert(oid) {
5031                continue;
5032            }
5033            stack.extend(get_parents(&oid));
5034        }
5035        false
5036    };
5037
5038    // `one_relevant_parent`: for a 1-parent commit (or first-parent), the first
5039    // parent; for a merge, the sole relevant parent if exactly one exists, else
5040    // None.
5041    let one_relevant_parent = |parents: &[ObjectId]| -> Option<ObjectId> {
5042        if parents.is_empty() {
5043            return None;
5044        }
5045        if options.first_parent || parents.len() == 1 {
5046            return Some(parents[0]);
5047        }
5048        let mut found: Option<ObjectId> = None;
5049        for p in parents {
5050            if relevant(p) {
5051                if found.is_some() {
5052                    return None;
5053                }
5054                found = Some(*p);
5055            }
5056        }
5057        found
5058    };
5059
5060    // Fixed-point: process in reverse (parents before children — git feeds the
5061    // list reversed and iterates until every commit is resolved).
5062    let mut simplified: HashMap<ObjectId, ObjectId> = HashMap::new();
5063    // Rewritten (deduped, redundancy-pruned) parent list per commit, recorded
5064    // when we resolve it.
5065    let mut rewritten_parents: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
5066    // Recomputed TREESAME per commit after parent removal, for the final
5067    // `get_commit_action` display filter.
5068    let mut display_treesame: HashMap<ObjectId, bool> = HashMap::new();
5069
5070    // git's `simplify_one` pulls every referenced parent into the pass. A parent
5071    // that is not itself in the candidate list is UNINTERESTING or a `^`-boundary
5072    // commit, which simplifies to *itself* (revision.c:3500) — pre-seed those so
5073    // children waiting on them become ready instead of stalling.
5074    for record in &records {
5075        for parent in &record.parents {
5076            if !record_oids.contains(parent) {
5077                simplified.entry(*parent).or_insert(*parent);
5078            }
5079        }
5080    }
5081
5082    // Worklist seeded with all commits in reverse order; re-queue a commit whose
5083    // parents are not yet resolved.
5084    let mut order: Vec<ObjectId> = records.iter().rev().map(|r| r.oid).collect();
5085    loop {
5086        let mut requeue: Vec<ObjectId> = Vec::new();
5087        let mut progressed = false;
5088        for oid in &order {
5089            if simplified.contains_key(oid) {
5090                continue;
5091            }
5092            let parents = get_parents(oid);
5093            // A root commit simplifies to itself (no parents to rewrite).
5094            if parents.is_empty() {
5095                display_treesame.insert(*oid, treesame(oid));
5096                simplified.insert(*oid, *oid);
5097                progressed = true;
5098                continue;
5099            }
5100            // Need every (relevant) parent resolved first.
5101            let mut ready = true;
5102            for (n, p) in parents.iter().enumerate() {
5103                if !simplified.contains_key(p) {
5104                    ready = false;
5105                    break;
5106                }
5107                if options.first_parent && n == 0 {
5108                    break;
5109                }
5110            }
5111            if !ready {
5112                requeue.push(*oid);
5113                continue;
5114            }
5115            progressed = true;
5116
5117            // Per-parent TREESAME flags for this commit (indexed by real parent
5118            // position), needed to recompute TREESAME after parent removal.
5119            let ts_parents = simplify
5120                .get(oid)
5121                .map(|s| s.treesame_parents.clone())
5122                .unwrap_or_default();
5123
5124            // Surviving parents as (real_index, simplified_oid). Rewrite each
5125            // real parent to its simplification, then dedup by simplified oid
5126            // (remove_duplicate_parents keeps the first occurrence).
5127            let take = if options.first_parent {
5128                1
5129            } else {
5130                parents.len()
5131            };
5132            let mut surviving: Vec<(usize, ObjectId)> = Vec::with_capacity(take);
5133            let mut seen: HashSet<ObjectId> = HashSet::new();
5134            for (n, p) in parents.iter().enumerate().take(take) {
5135                let s = *simplified.get(p).unwrap_or(p);
5136                if seen.insert(s) {
5137                    surviving.push((n, s));
5138                }
5139            }
5140            let mut cnt = surviving.len();
5141
5142            if cnt > 1 {
5143                let mut marked: HashSet<ObjectId> = HashSet::new();
5144                // mark_redundant_parents: drop a parent that is a proper ancestor
5145                // of another surviving parent (reduce_heads).
5146                let ids: Vec<ObjectId> = surviving.iter().map(|(_, s)| *s).collect();
5147                for a in &ids {
5148                    for b in &ids {
5149                        if a != b && is_ancestor(a, b) {
5150                            marked.insert(*a);
5151                            break;
5152                        }
5153                    }
5154                }
5155                // mark_treesame_root_parents: a surviving parent that is itself a
5156                // root and is TREESAME (to the empty tree) — drop it.
5157                for (_, s) in &surviving {
5158                    if is_root(s) && root_treesame(s) {
5159                        marked.insert(*s);
5160                    }
5161                }
5162                let mut marked_count = marked.len();
5163                // leave_one_treesame_to_parent: if we are TREESAME to a marked
5164                // parent but to no unmarked parent, un-mark the first such marked
5165                // parent (the one the default scan would have followed).
5166                if marked_count > 0 {
5167                    let mut unmarked_treesame = false;
5168                    let mut first_marked_treesame: Option<ObjectId> = None;
5169                    for (n, s) in &surviving {
5170                        if ts_parents.get(*n).copied().unwrap_or(false) {
5171                            if marked.contains(s) {
5172                                if first_marked_treesame.is_none() {
5173                                    first_marked_treesame = Some(*s);
5174                                }
5175                            } else {
5176                                unmarked_treesame = true;
5177                                break;
5178                            }
5179                        }
5180                    }
5181                    if !unmarked_treesame && let Some(m) = first_marked_treesame {
5182                        marked.remove(&m);
5183                        marked_count -= 1;
5184                    }
5185                }
5186                if marked_count > 0 {
5187                    surviving.retain(|(_, s)| !marked.contains(s));
5188                    cnt = surviving.len();
5189                }
5190            }
5191
5192            let rewritten: Vec<ObjectId> = surviving.iter().map(|(_, s)| *s).collect();
5193            rewritten_parents.insert(*oid, rewritten.clone());
5194
5195            // Recompute TREESAME over the SURVIVING parents (git's
5196            // update_treesame, run by remove_marked_parents when any parent was
5197            // removed): with ≥1 relevant surviving parent, TREESAME iff no
5198            // relevant surviving parent shows a change; else fall back to the
5199            // irrelevant ones.
5200            let commit_treesame = if surviving.len() == parents.len().min(take) {
5201                // No parent removed → original TREESAME stands.
5202                treesame(oid)
5203            } else if surviving.is_empty() {
5204                treesame(oid)
5205            } else {
5206                let mut relevant_parents = 0usize;
5207                let mut relevant_change = false;
5208                let mut irrelevant_change = false;
5209                for (n, s) in &surviving {
5210                    let same = ts_parents.get(*n).copied().unwrap_or(false);
5211                    if relevant(s) {
5212                        relevant_parents += 1;
5213                        relevant_change |= !same;
5214                    } else {
5215                        irrelevant_change |= !same;
5216                    }
5217                }
5218                if relevant_parents > 0 {
5219                    !relevant_change
5220                } else {
5221                    !irrelevant_change
5222                }
5223            };
5224
5225            // A commit simplifies to itself if: no surviving parent, it is
5226            // !TREESAME (touches the paths), it is a merge with no sole relevant
5227            // parent, or (show_pulls && it is a pull merge). Otherwise it
5228            // simplifies to its sole relevant parent's simplification.
5229            display_treesame.insert(*oid, commit_treesame);
5230            let sole = one_relevant_parent(&rewritten);
5231            let pull_merge = options.show_pulls && is_pull_merge(oid, &parents, simplify, relevant);
5232            match sole {
5233                Some(parent) if cnt != 0 && commit_treesame && !pull_merge => {
5234                    // Simplifies to its sole relevant parent's simplification.
5235                    let target = *simplified.get(&parent).unwrap_or(&parent);
5236                    simplified.insert(*oid, target);
5237                }
5238                _ => {
5239                    simplified.insert(*oid, *oid);
5240                }
5241            }
5242        }
5243        if requeue.is_empty() {
5244            break;
5245        }
5246        if !progressed {
5247            // Defensive: no progress with work remaining would loop forever;
5248            // resolve the rest to themselves (should not happen for a DAG).
5249            for oid in &requeue {
5250                simplified.entry(*oid).or_insert(*oid);
5251            }
5252            break;
5253        }
5254        order = requeue;
5255    }
5256
5257    // Keep commits that simplify to themselves AND survive `get_commit_action`,
5258    // preserving input order, with their rewritten parents. A commit that
5259    // simplifies to itself but is TREESAME is still dropped unless it is a merge
5260    // of ≥2 relevant parents (to tie topology together) or a shown pull-merge —
5261    // `--simplify-merges` always wants ancestry (rewrite_parents).
5262    let out = records
5263        .into_iter()
5264        .filter(|r| simplified.get(&r.oid) == Some(&r.oid))
5265        .filter(|r| {
5266            let ts = display_treesame.get(&r.oid).copied().unwrap_or(false);
5267            if !ts {
5268                return true;
5269            }
5270            let rewritten = rewritten_parents.get(&r.oid);
5271            let pull = options.show_pulls
5272                && is_pull_merge(&r.oid, &r.parents, simplify, |p| relevant_set.contains(p));
5273            let relevant_parent_count = rewritten
5274                .map(|ps| ps.iter().filter(|p| relevant_set.contains(*p)).count())
5275                .unwrap_or(0);
5276            pull || relevant_parent_count >= 2
5277        })
5278        .map(|r| {
5279            let parents = rewritten_parents.get(&r.oid).cloned().unwrap_or(r.parents);
5280            CommitRecord {
5281                oid: r.oid,
5282                parents,
5283                commit: r.commit,
5284            }
5285        })
5286        .collect();
5287    Ok(out)
5288}
5289
5290/// git's `PULL_MERGE` flag for `--show-pulls`: in `try_to_simplify_commit`, a
5291/// merge is flagged `PULL_MERGE` when its **first** parent is NOT tree-SAME
5292/// (`!nth_parent` with a `REV_TREE_NEW/OLD/DIFFERENT` comparison) — i.e. the
5293/// first-parent line itself changed the paths, so a later TREESAME parent means
5294/// the merge "pulled in" the side branch's version. Such merges are kept under
5295/// `--show-pulls` even when the merge as a whole is TREESAME.
5296fn is_pull_merge(
5297    oid: &ObjectId,
5298    parents: &[ObjectId],
5299    simplify: &HashMap<ObjectId, CommitSimplify>,
5300    _relevant: impl Fn(&ObjectId) -> bool,
5301) -> bool {
5302    if parents.len() < 2 {
5303        return false;
5304    }
5305    let Some(st) = simplify.get(oid) else {
5306        return false;
5307    };
5308    // PULL_MERGE ⇔ first parent is a tree difference (NOT same).
5309    !st.treesame_parents.first().copied().unwrap_or(false)
5310}
5311
5312// ---------------------------------------------------------------------------
5313// `<rev>:<path>` resolution
5314// ---------------------------------------------------------------------------
5315
5316#[derive(Debug, Clone, PartialEq, Eq)]
5317pub struct ResolvedTreePath {
5318    pub oid: ObjectId,
5319    pub mode: Option<u32>,
5320    pub object_type: ObjectType,
5321    pub name: BString,
5322}
5323
5324/// Resolve `<rev>:<path>` to the object id of `<path>` within `<rev>`'s tree.
5325///
5326/// `rev` is peeled to a tree (so a commit, tag, or tree id all work) and then
5327/// `path` is walked component by component. The result is the blob id for a
5328/// file path or the subtree id for a directory path; an empty `path` resolves
5329/// to the tree itself. Missing components and attempts to descend through a
5330/// non-tree entry both report a git-style "path '<path>' does not exist in
5331/// '<rev>'" error.
5332pub fn resolve_rev_path<R: ObjectReader>(
5333    git_dir: &Path,
5334    format: sley_core::ObjectFormat,
5335    reader: &R,
5336    rev: &str,
5337    path: &str,
5338) -> Result<ObjectId> {
5339    resolve_rev_path_entry(git_dir, format, reader, rev, path).map(|entry| entry.oid)
5340}
5341
5342pub fn resolve_rev_path_entry<R: ObjectReader>(
5343    git_dir: &Path,
5344    format: ObjectFormat,
5345    reader: &R,
5346    rev: &str,
5347    path: &str,
5348) -> Result<ResolvedTreePath> {
5349    // Ref-first resolution with a tree-ish disambiguation: a ref named like a
5350    // short hex prefix (e.g. `added:path`) resolves to the ref, while a genuine
5351    // bare prefix is narrowed to its tree-ish candidates.
5352    let rev_oid = resolve_revision_inner(
5353        git_dir,
5354        format,
5355        reader,
5356        rev,
5357        None,
5358        ObjectDisambiguation::Treeish,
5359    )?;
5360    let tree_oid = peel_to_tree(reader, format, &rev_oid)?;
5361    resolve_tree_path_entry(reader, format, &tree_oid, path)
5362        .ok_or_else(|| GitError::not_found(format!("path '{path}' does not exist in '{rev}'")))
5363}
5364
5365/// Walk `path` within the tree `tree_oid`, returning the id of the entry it
5366/// names, or `None` if any component is missing or a component before the last
5367/// is not a tree. An empty `path` returns `tree_oid` unchanged.
5368pub fn resolve_tree_path_entry<R: ObjectReader>(
5369    reader: &R,
5370    format: ObjectFormat,
5371    tree_oid: &ObjectId,
5372    path: &str,
5373) -> Option<ResolvedTreePath> {
5374    let mut current = *tree_oid;
5375    let components = normalize_treeish_path_components(path);
5376    if components.is_empty() {
5377        return Some(ResolvedTreePath {
5378            oid: current,
5379            mode: None,
5380            object_type: ObjectType::Tree,
5381            name: BString::default(),
5382        });
5383    }
5384    let last = components.len() - 1;
5385    for (idx, component) in components.iter().enumerate() {
5386        let object = reader.read_object(&current).ok()?;
5387        if object.object_type != ObjectType::Tree {
5388            // Cannot descend through a blob (or anything non-tree).
5389            return None;
5390        }
5391        let mut found = None;
5392        for entry in TreeEntries::new(format, &object.body) {
5393            let entry = entry.ok()?;
5394            if found.is_none() && entry.name == component.as_bytes() {
5395                found = Some((entry.mode, entry.oid, entry.name.into()));
5396            }
5397        }
5398        let (mode, oid, name) = found?;
5399        let object_type = sley_object::tree_entry_object_type(mode);
5400        if idx == last {
5401            return Some(ResolvedTreePath {
5402                oid,
5403                mode: Some(mode),
5404                object_type,
5405                name,
5406            });
5407        }
5408        // Intermediate component must itself be a tree to keep descending.
5409        if object_type != ObjectType::Tree {
5410            return None;
5411        }
5412        current = oid;
5413    }
5414    None
5415}
5416
5417fn normalize_treeish_path(path: &str) -> String {
5418    normalize_treeish_path_components(path).join("/")
5419}
5420
5421fn normalize_treeish_path_components(path: &str) -> Vec<&str> {
5422    // Split on '/', skipping empty and "." components so leading/trailing/
5423    // duplicate separators ("a//b", "/a", "dir/") and explicit current-dir
5424    // spellings ("./a", "a/./b") behave the way git's tree/index lookup does.
5425    path.split('/')
5426        .filter(|part| !part.is_empty() && *part != ".")
5427        .collect()
5428}
5429
5430/// Outcome of a `--follow-symlinks` tree-path walk (upstream's
5431/// `get_tree_entry_follow_symlinks`, tree-walk.c).
5432#[derive(Debug, Clone, PartialEq, Eq)]
5433pub enum SymlinkedTreePath {
5434    /// The walk ended on an in-repo object (the symlink chain — if any —
5435    /// resolved to a blob or tree inside the repository).
5436    Found(ObjectId),
5437    /// The symlink chain leaves the repository: an absolute link target, or
5438    /// `..` escaping past the root. The unresolvable remainder of the link
5439    /// path is reported verbatim (upstream sets `*mode = 0` and fills
5440    /// `result_path`).
5441    OutOfRepo(Vec<u8>),
5442    /// A path component was not found before any symlink had been followed
5443    /// (upstream `MISSING_OBJECT`).
5444    Missing,
5445    /// A path component was not found after at least one symlink had been
5446    /// followed (upstream `DANGLING_SYMLINK`).
5447    Dangling,
5448    /// More than [`FOLLOW_SYMLINKS_MAX_LINKS`] symlinks were followed
5449    /// (upstream `SYMLINK_LOOP`).
5450    Loop,
5451    /// A non-final path component resolved to a regular file (upstream
5452    /// `NOT_DIR`).
5453    NotDir,
5454}
5455
5456/// Linux's built-in cap on the number of symlinks to follow; upstream adopts
5457/// the same value (`GET_TREE_ENTRY_FOLLOW_SYMLINKS_MAX_LINKS`).
5458pub const FOLLOW_SYMLINKS_MAX_LINKS: u32 = 40;
5459
5460/// Resolve `<rev>:<path>` like [`resolve_rev_path_entry`], but follow in-tree
5461/// symlinks the way `git cat-file --follow-symlinks` does (upstream
5462/// `get_tree_entry_follow_symlinks`). Failures on the `<rev>` side (unknown
5463/// revision, non-treeish) report [`SymlinkedTreePath::Missing`], matching
5464/// upstream where a failed treeish lookup yields `MISSING_OBJECT`.
5465pub fn resolve_rev_path_follow_symlinks<R: ObjectReader>(
5466    git_dir: &Path,
5467    format: ObjectFormat,
5468    reader: &R,
5469    rev: &str,
5470    path: &str,
5471) -> SymlinkedTreePath {
5472    let Ok(rev_oid) = resolve_revision_with_reader(git_dir, format, reader, rev) else {
5473        return SymlinkedTreePath::Missing;
5474    };
5475    resolve_tree_path_follow_symlinks(reader, format, &rev_oid, path)
5476}
5477
5478/// Walk `path` within the tree of `treeish` (peeled as needed), following
5479/// symlink entries. This mirrors upstream's `get_tree_entry_follow_symlinks`
5480/// loop: path components are consumed left to right against a stack of parent
5481/// trees rooted at the repository root; a symlink entry splices its target in
5482/// front of the remaining path; `..` pops a parent (escaping the root reports
5483/// the remainder as out-of-repo, like an absolute link target does).
5484pub fn resolve_tree_path_follow_symlinks<R: ObjectReader>(
5485    reader: &R,
5486    format: ObjectFormat,
5487    treeish: &ObjectId,
5488    path: &str,
5489) -> SymlinkedTreePath {
5490    // Stack of (tree oid, tree object) from the root down to the directory
5491    // currently being walked. Lookups always run against the top entry.
5492    let mut parents: Vec<(ObjectId, Arc<EncodedObject>)> = Vec::new();
5493    let mut namebuf: Vec<u8> = path.as_bytes().to_vec();
5494    let mut current_oid = *treeish;
5495    let mut follows_remaining = FOLLOW_SYMLINKS_MAX_LINKS;
5496    // Once a symlink has been followed, a failed lookup is a dangling link
5497    // rather than a missing path (upstream flips `retval` the same way).
5498    let mut followed_symlink = false;
5499    let mut need_load = true;
5500
5501    loop {
5502        let fail = if followed_symlink {
5503            SymlinkedTreePath::Dangling
5504        } else {
5505            SymlinkedTreePath::Missing
5506        };
5507
5508        if need_load {
5509            let Ok(tree_oid) = peel_to_tree(reader, format, &current_oid) else {
5510                return fail;
5511            };
5512            let Ok(object) = reader.read_object(&tree_oid) else {
5513                return fail;
5514            };
5515            if object.object_type != ObjectType::Tree {
5516                return fail;
5517            }
5518            parents.push((tree_oid, object));
5519            if namebuf.is_empty() {
5520                // `<rev>:` (or a symlink chain that consumed the whole path)
5521                // names the tree just loaded.
5522                return SymlinkedTreePath::Found(tree_oid);
5523            }
5524            if parents
5525                .last()
5526                .is_some_and(|(_, object)| object.body.is_empty())
5527            {
5528                return fail;
5529            }
5530            need_load = false;
5531        }
5532
5533        // Handle symlinks to e.g. `a//b` by removing leading slashes.
5534        while namebuf.first() == Some(&b'/') {
5535            namebuf.remove(0);
5536        }
5537
5538        // Split namebuf into a first component and an optional remainder.
5539        let slash = namebuf.iter().position(|&byte| byte == b'/');
5540        let (component_len, has_remainder) = match slash {
5541            Some(index) => (index, true),
5542            None => (namebuf.len(), false),
5543        };
5544
5545        // `..` can appear in namebuf when a symlink target contains it.
5546        if &namebuf[..component_len] == b".." {
5547            if parents.len() == 1 {
5548                // `..` at the repository root: the rest of the path (the
5549                // `..` included) escapes the repository.
5550                return SymlinkedTreePath::OutOfRepo(namebuf);
5551            }
5552            parents.pop();
5553            namebuf.drain(..if has_remainder { 3 } else { 2 });
5554            continue;
5555        }
5556
5557        // A symlink to `dir/..` leaves an empty path: the current tree.
5558        if component_len == 0 {
5559            let Some((tree_oid, _)) = parents.last() else {
5560                return fail;
5561            };
5562            return SymlinkedTreePath::Found(*tree_oid);
5563        }
5564
5565        // Look up the first (or only) path component in the current tree.
5566        let mut found = None;
5567        if let Some((_, object)) = parents.last() {
5568            for entry in TreeEntries::new(format, &object.body) {
5569                let Ok(entry) = entry else {
5570                    return fail;
5571                };
5572                if entry.name == &namebuf[..component_len] {
5573                    found = Some((entry.mode, entry.oid));
5574                    break;
5575                }
5576            }
5577        }
5578        let Some((mode, oid)) = found else {
5579            return fail;
5580        };
5581
5582        match mode & 0o170000 {
5583            0o040000 => {
5584                // Directory: done if it is the last component, else descend.
5585                if !has_remainder {
5586                    return SymlinkedTreePath::Found(oid);
5587                }
5588                current_oid = oid;
5589                need_load = true;
5590                namebuf.drain(..component_len + 1);
5591            }
5592            0o100000 => {
5593                // Regular file: done if last component, otherwise the path
5594                // tries to descend through a non-directory.
5595                if !has_remainder {
5596                    return SymlinkedTreePath::Found(oid);
5597                }
5598                return SymlinkedTreePath::NotDir;
5599            }
5600            0o120000 => {
5601                // Follow a symlink.
5602                if follows_remaining == 0 {
5603                    return SymlinkedTreePath::Loop;
5604                }
5605                follows_remaining -= 1;
5606                followed_symlink = true;
5607                let Ok(link) = reader.read_object(&oid) else {
5608                    return SymlinkedTreePath::Dangling;
5609                };
5610                let target = link.body.clone();
5611                if target.first() == Some(&b'/') {
5612                    // An absolute link target leaves the repository; any
5613                    // remainder is dropped, exactly like upstream.
5614                    return SymlinkedTreePath::OutOfRepo(target);
5615                }
5616                // Splice the target in front of the remainder and re-walk
5617                // from the current directory (top of the parent stack).
5618                let mut spliced = target;
5619                if has_remainder {
5620                    spliced.push(b'/');
5621                    spliced.extend_from_slice(&namebuf[component_len + 1..]);
5622                }
5623                namebuf = spliced;
5624            }
5625            _ => {
5626                // Gitlink (or unknown mode): upstream's loop falls through and
5627                // re-scans its already-consumed tree descriptor, failing to
5628                // find the entry again — the walk ends missing/dangling.
5629                return fail;
5630            }
5631        }
5632    }
5633}
5634
5635/// Split `<rev>:<path>` into its revision and path halves.
5636///
5637/// Returns `None` when the spec is not a rev/path form, i.e. when there is no
5638/// colon, when the colon is part of a leading `:` index spec (handled
5639/// elsewhere), or when the left side is empty. The split uses the first colon
5640/// so paths may themselves contain colons.
5641pub fn split_rev_path_spec(rev: &str) -> Option<(&str, &str)> {
5642    split_rev_path(rev)
5643}
5644
5645fn split_rev_path(rev: &str) -> Option<(&str, &str)> {
5646    RevisionSpecRef::parse(rev).ok()?.tree_path()
5647}
5648
5649fn split_top_level_rev_path(rev: &str) -> Option<(&str, &str)> {
5650    let bytes = rev.as_bytes();
5651    let mut braced_selector_depth = 0usize;
5652    for (index, byte) in bytes.iter().copied().enumerate() {
5653        match byte {
5654            b'{' if index > 0 && matches!(bytes[index - 1], b'^' | b'@') => {
5655                braced_selector_depth = braced_selector_depth.saturating_add(1);
5656            }
5657            b'}' if braced_selector_depth > 0 => {
5658                braced_selector_depth -= 1;
5659            }
5660            b':' if braced_selector_depth == 0 && index > 0 => {
5661                return Some((&rev[..index], &rev[index + 1..]));
5662            }
5663            _ => {}
5664        }
5665    }
5666    None
5667}
5668
5669// ---------------------------------------------------------------------------
5670// `:[N:]<path>` index-stage resolution
5671// ---------------------------------------------------------------------------
5672
5673/// Parse the portion after a leading `:` into `(stage, path)`.
5674///
5675/// `:<path>` selects stage 0; `:N:<path>` (N in 0..=3) selects stage N. When
5676/// the leading token is not a single 0-3 digit followed by a colon the whole
5677/// string is treated as a stage-0 path.
5678fn parse_index_stage_path(rest: &str) -> (u8, &str) {
5679    let bytes = rest.as_bytes();
5680    if bytes.len() >= 2 && bytes[1] == b':' && matches!(bytes[0], b'0'..=b'3') {
5681        return (bytes[0] - b'0', &rest[2..]);
5682    }
5683    (0, rest)
5684}
5685
5686/// Resolve `path` at `stage` in the on-disk index, returning the recorded blob
5687/// id. Reports git-style errors for a missing index, a path absent from the
5688/// index, and a path present only at other stages.
5689fn resolve_index_path<R: ObjectReader>(
5690    git_dir: &Path,
5691    format: sley_core::ObjectFormat,
5692    reader: &R,
5693    stage: u8,
5694    path: &str,
5695) -> Result<ObjectId> {
5696    let normalized_path = normalize_treeish_path(path);
5697    let index_path = repository_index_path(git_dir);
5698    let bytes = match fs::read(&index_path) {
5699        Ok(bytes) => bytes,
5700        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
5701            return Err(GitError::not_found(format!(
5702                "path '{path}' is not in the index"
5703            )));
5704        }
5705        Err(err) => return Err(GitError::Io(err.to_string())),
5706    };
5707    let index = Index::parse(&bytes, format)?;
5708    let mut path_exists = false;
5709    for entry in &index.entries {
5710        if entry.path != normalized_path.as_bytes() {
5711            continue;
5712        }
5713        path_exists = true;
5714        if index_entry_stage(entry) == stage {
5715            return Ok(entry.oid);
5716        }
5717    }
5718    if stage == 0
5719        && let Some(oid) =
5720            resolve_index_path_in_sparse_dir(&index, reader, format, &normalized_path)
5721    {
5722        return Ok(oid);
5723    }
5724    if path_exists {
5725        Err(GitError::not_found(format!(
5726            "path '{path}' is in the index, but not at stage {stage}"
5727        )))
5728    } else {
5729        Err(GitError::not_found(format!(
5730            "path '{path}' is not in the index"
5731        )))
5732    }
5733}
5734
5735fn resolve_index_path_in_sparse_dir<R: ObjectReader>(
5736    index: &Index,
5737    reader: &R,
5738    format: ObjectFormat,
5739    normalized_path: &str,
5740) -> Option<ObjectId> {
5741    for entry in &index.entries {
5742        if !entry.is_sparse_dir() {
5743            continue;
5744        }
5745        let Ok(sparse_dir) = std::str::from_utf8(entry.path.as_bytes()) else {
5746            continue;
5747        };
5748        let Some(remainder) = normalized_path.strip_prefix(sparse_dir) else {
5749            continue;
5750        };
5751        if remainder.is_empty() {
5752            continue;
5753        }
5754        let Some(resolved) = resolve_tree_path_entry(reader, format, &entry.oid, remainder) else {
5755            continue;
5756        };
5757        if resolved.object_type == ObjectType::Tree {
5758            continue;
5759        }
5760        sley_core::trace2::region("index", "ensure_full_index");
5761        return Some(resolved.oid);
5762    }
5763    None
5764}
5765
5766/// Extract the merge stage (0-3) from an index entry's flags (bits 12-13).
5767fn index_entry_stage(entry: &sley_index::IndexEntry) -> u8 {
5768    ((entry.flags >> 12) & 0x3) as u8
5769}
5770
5771/// Locate the index file, honoring `GIT_INDEX_FILE` like the rest of git.
5772fn repository_index_path(git_dir: &Path) -> PathBuf {
5773    std::env::var_os("GIT_INDEX_FILE")
5774        .map(PathBuf::from)
5775        .unwrap_or_else(|| git_dir.join("index"))
5776}
5777
5778// ---------------------------------------------------------------------------
5779// Commit-message search (`:/text` and `<rev>^{/text}`)
5780// ---------------------------------------------------------------------------
5781//
5782// Matching is a plain substring test against the raw commit message; this crate
5783// has no regex dependency, so `:/text` and `^{/text}` find commits whose
5784// message *contains* `text` rather than matching it as a POSIX regular
5785// expression. An empty pattern matches the most recent candidate, mirroring
5786// git's "return the youngest commit" behavior for `:/`.
5787
5788/// `:/text` — newest commit (across all refs) whose message contains `text`.
5789///
5790/// "Newest" is approximated by committer timestamp, falling back to the order
5791/// commits are discovered when timestamps are unavailable, which matches git's
5792/// observable behavior for the common case. The committer date is taken from the
5793/// commit-graph when available (it equals the value on the object's committer
5794/// line, so the chosen commit is unchanged) and parsed from the commit body
5795/// otherwise.
5796fn search_commit_message_all<R: ObjectReader>(
5797    git_dir: &Path,
5798    format: sley_core::ObjectFormat,
5799    reader: &R,
5800    text: &str,
5801) -> Result<ObjectId> {
5802    let starts = all_ref_commit_starts(git_dir, format, reader)?;
5803    let mut graph = CommitGraphContext::load(git_dir, format);
5804    let mut seen = HashSet::new();
5805    let mut pending: VecDeque<ObjectId> = starts.into_iter().collect();
5806    let mut best: Option<(i64, ObjectId)> = None;
5807    while let Some(oid) = pending.pop_front() {
5808        if !seen.insert(oid) {
5809            continue;
5810        }
5811        let object = read_revision_object(reader, &oid)?;
5812        if object.object_type != ObjectType::Commit {
5813            return Err(GitError::InvalidObject(format!(
5814                "expected commit {oid}, found {}",
5815                object.object_type.as_str()
5816            )));
5817        }
5818        let commit = Commit::parse_ref(format, &object.body)?;
5819        pending.extend(commit.parents.iter().cloned());
5820        if commit_message_contains(commit.message, text) {
5821            let when = graph
5822                .commit_time(&oid)?
5823                .or_else(|| commit_committer_time(commit.committer))
5824                .unwrap_or(i64::MIN);
5825            if best
5826                .as_ref()
5827                .is_none_or(|(best_when, _)| when >= *best_when)
5828            {
5829                best = Some((when, oid));
5830            }
5831        }
5832    }
5833    best.map(|(_, oid)| oid)
5834        .ok_or_else(|| GitError::not_found(format!("no commit matching ':/{text}'")))
5835}
5836
5837/// `<rev>^{/text}` — first commit reachable from `base` along the first-parent
5838/// chain whose message contains `text`.
5839fn search_commit_message_first_parent<R: ObjectReader>(
5840    git_dir: &Path,
5841    reader: &R,
5842    format: sley_core::ObjectFormat,
5843    base: &ObjectId,
5844    text: &str,
5845) -> Result<ObjectId> {
5846    let start = peel_to_commit(reader, format, base)?;
5847    // Commit *messages* are not stored in the commit-graph, so each candidate's
5848    // body is still read; the graph is only consulted to follow the first-parent
5849    // edge, avoiding a second parse of the same object for the linkage.
5850    let mut graph = CommitGraphContext::load(git_dir, format);
5851    let mut current = Some(start);
5852    let mut seen = HashSet::new();
5853    while let Some(oid) = current {
5854        if !seen.insert(oid) {
5855            break;
5856        }
5857        let object = read_revision_object(reader, &oid)?;
5858        if object.object_type != ObjectType::Commit {
5859            return Err(GitError::InvalidObject(format!(
5860                "expected commit {oid}, found {}",
5861                object.object_type.as_str()
5862            )));
5863        }
5864        let commit = Commit::parse_ref(format, &object.body)?;
5865        if commit_message_contains(commit.message, text) {
5866            return Ok(oid);
5867        }
5868        current = if reader.is_shallow_graft(&oid) {
5869            None
5870        } else {
5871            match graph.first_parent(&oid)? {
5872                Some(parent) => parent,
5873                None => commit.parents.into_iter().next(),
5874            }
5875        };
5876    }
5877    Err(GitError::not_found(format!(
5878        "no commit matching '^{{/{text}}}' in first-parent history"
5879    )))
5880}
5881
5882fn commit_message_contains(message: &[u8], text: &str) -> bool {
5883    if text.is_empty() {
5884        return true;
5885    }
5886    // Search the raw bytes so non-UTF-8 messages still match where possible.
5887    message
5888        .windows(text.len())
5889        .any(|window| window == text.as_bytes())
5890}
5891
5892/// Best-effort committer timestamp (seconds since epoch) from a commit's
5893/// committer line, used only to order `:/text` candidates.
5894fn commit_committer_time(committer: &[u8]) -> Option<i64> {
5895    let line = std::str::from_utf8(committer).ok()?;
5896    // Format: "Name <email> <seconds> <tz>"; the timestamp is the
5897    // second-to-last whitespace-separated field.
5898    let mut fields = line.rsplit(' ');
5899    let _tz = fields.next()?;
5900    fields.next()?.parse::<i64>().ok()
5901}
5902
5903/// Collect commit starting points from every ref (peeling tags to commits) for
5904/// a repository-wide `:/text` search.
5905fn all_ref_commit_starts<R: ObjectReader>(
5906    git_dir: &Path,
5907    format: sley_core::ObjectFormat,
5908    reader: &R,
5909) -> Result<Vec<ObjectId>> {
5910    let refs = FileRefStore::new(git_dir.to_path_buf(), format);
5911    let mut starts = Vec::new();
5912    let mut seen = HashSet::new();
5913    for reference in refs.list_refs()? {
5914        let oid = match reference.target {
5915            RefTarget::Direct(oid) => oid,
5916            RefTarget::Symbolic(_) => continue,
5917        };
5918        // Skip refs whose objects (or tag targets) are not present/commit-ish.
5919        let Ok(commit) = peel_to_commit(reader, format, &oid) else {
5920            continue;
5921        };
5922        if seen.insert(commit) {
5923            starts.push(commit);
5924        }
5925    }
5926    Ok(starts)
5927}
5928
5929// ---------------------------------------------------------------------------
5930// Revision ranges (`A..B` and `A...B`)
5931// ---------------------------------------------------------------------------
5932
5933/// A parsed revision range expression.
5934#[derive(Debug, Clone, PartialEq, Eq)]
5935pub enum RevisionRange {
5936    /// `A..B` — commits reachable from `B` but not from `A`.
5937    Asymmetric { start: String, end: String },
5938    /// `A...B` — commits reachable from exactly one of `A`/`B` (symmetric
5939    /// difference).
5940    Symmetric { left: String, right: String },
5941}
5942
5943/// Parse `A..B` / `A...B` / `A^-N` range syntax.
5944///
5945/// Returns `None` when `spec` is not a range. An omitted side defaults to
5946/// `HEAD` (so `..B`, `A..`, `...B`, etc. behave like git). `...` is checked
5947/// before `..` so the symmetric form is not misread as an asymmetric one. A
5948/// trailing `..`/`...` with the wrong number of dots (more than two/three) is
5949/// rejected as a malformed range. `A^-` expands to `A^1..A`, and `A^-N`
5950/// expands to `A^N..A`.
5951pub fn parse_revision_range(spec: &str) -> Option<RevisionRange> {
5952    if spec.starts_with(':') {
5953        return None;
5954    }
5955    if let Some(range) = parse_parent_revision_range(spec) {
5956        return Some(range);
5957    }
5958    if let Some((left, right)) = spec.split_once("...") {
5959        if range_operator_is_inside_tree_path(spec, left.len()) {
5960            return None;
5961        }
5962        if left.contains("..") || right.contains("..") {
5963            return None;
5964        }
5965        return Some(RevisionRange::Symmetric {
5966            left: default_range_side(left).to_string(),
5967            right: default_range_side(right).to_string(),
5968        });
5969    }
5970    if let Some((left, right)) = spec.split_once("..") {
5971        if left.is_empty() && right.is_empty() {
5972            return None;
5973        }
5974        if range_operator_is_inside_tree_path(spec, left.len()) {
5975            return None;
5976        }
5977        if left.contains("..") || right.contains("..") {
5978            return None;
5979        }
5980        return Some(RevisionRange::Asymmetric {
5981            start: default_range_side(left).to_string(),
5982            end: default_range_side(right).to_string(),
5983        });
5984    }
5985    None
5986}
5987
5988fn parse_parent_revision_range(spec: &str) -> Option<RevisionRange> {
5989    let (base, parent) = spec.rsplit_once("^-")?;
5990    if range_operator_is_inside_tree_path(spec, base.len()) {
5991        return None;
5992    }
5993    if base.is_empty() {
5994        return None;
5995    }
5996    let parent = if parent.is_empty() {
5997        1
5998    } else if parent.bytes().all(|byte| byte.is_ascii_digit()) {
5999        parent.parse::<usize>().ok()?
6000    } else {
6001        return None;
6002    };
6003    if parent == 0 {
6004        return None;
6005    }
6006    Some(RevisionRange::Asymmetric {
6007        start: format!("{base}^{parent}"),
6008        end: base.to_string(),
6009    })
6010}
6011
6012fn range_operator_is_inside_tree_path(spec: &str, operator_pos: usize) -> bool {
6013    top_level_tree_path_colon(spec).is_some_and(|colon| colon < operator_pos)
6014}
6015
6016fn top_level_tree_path_colon(spec: &str) -> Option<usize> {
6017    let bytes = spec.as_bytes();
6018    let mut braced_selector_depth = 0usize;
6019    for (index, byte) in bytes.iter().copied().enumerate() {
6020        match byte {
6021            b'{' if index > 0 && matches!(bytes[index - 1], b'^' | b'@') => {
6022                braced_selector_depth = braced_selector_depth.saturating_add(1);
6023            }
6024            b'}' if braced_selector_depth > 0 => {
6025                braced_selector_depth -= 1;
6026            }
6027            b':' if braced_selector_depth == 0 && index > 0 => return Some(index),
6028            _ => {}
6029        }
6030    }
6031    None
6032}
6033
6034fn default_range_side(side: &str) -> &str {
6035    if side.is_empty() { "HEAD" } else { side }
6036}
6037
6038/// A small builder for rev-list-style revision selection arguments.
6039///
6040/// Specs added through [`RevisionSelection::add_spec`] understand bare includes
6041/// (`B`), caret excludes (`^A`), asymmetric ranges (`A..B`), symmetric ranges
6042/// (`A...B`), and the `HEAD` defaults accepted by [`parse_revision_range`].
6043#[derive(Debug, Clone, PartialEq, Eq, Default)]
6044pub struct RevisionSelection {
6045    items: Vec<RevisionSelectionItem>,
6046}
6047
6048/// One item in a [`RevisionSelection`].
6049#[derive(Debug, Clone, PartialEq, Eq)]
6050pub enum RevisionSelectionItem {
6051    /// Include commits reachable from this revision.
6052    Include(String),
6053    /// Exclude commits reachable from this revision.
6054    Exclude(String),
6055    /// Include/exclude according to a parsed `A..B` or `A...B` range.
6056    Range(RevisionRange),
6057}
6058
6059/// Resolved commit starts plus the full set of excluded commits.
6060///
6061/// `excluded` contains the complete ancestry closure of each exclude tip (and
6062/// symmetric-range merge base), so callers can walk from `starts` and filter any
6063/// commit whose oid is present here.
6064#[derive(Debug, Clone, PartialEq, Eq, Default)]
6065pub struct ResolvedRevisionSelection {
6066    pub starts: Vec<ObjectId>,
6067    pub excluded: HashSet<ObjectId>,
6068}
6069
6070impl RevisionSelection {
6071    pub fn new() -> Self {
6072        Self::default()
6073    }
6074
6075    pub fn from_specs<I, S>(specs: I) -> Result<Self>
6076    where
6077        I: IntoIterator<Item = S>,
6078        S: AsRef<str>,
6079    {
6080        let mut selection = Self::new();
6081        for spec in specs {
6082            selection.add_spec(spec.as_ref())?;
6083        }
6084        Ok(selection)
6085    }
6086
6087    pub fn is_empty(&self) -> bool {
6088        self.items.is_empty()
6089    }
6090
6091    pub fn items(&self) -> &[RevisionSelectionItem] {
6092        &self.items
6093    }
6094
6095    pub fn add_spec(&mut self, spec: impl AsRef<str>) -> Result<&mut Self> {
6096        let spec = spec.as_ref();
6097        if spec.is_empty() {
6098            return Err(GitError::InvalidFormat("empty revision spec".into()));
6099        }
6100        if let Some(rev) = spec.strip_prefix('^') {
6101            if rev.is_empty() {
6102                return Err(GitError::InvalidFormat("empty exclude revision".into()));
6103            }
6104            return self.exclude(rev.to_string());
6105        }
6106        if let Some(range) = parse_revision_range(spec) {
6107            self.range(range);
6108            return Ok(self);
6109        }
6110        self.include(spec.to_string())
6111    }
6112
6113    pub fn include(&mut self, rev: impl Into<String>) -> Result<&mut Self> {
6114        let rev = RevisionSpec::parse(rev)?.raw;
6115        self.items.push(RevisionSelectionItem::Include(rev));
6116        Ok(self)
6117    }
6118
6119    pub fn exclude(&mut self, rev: impl Into<String>) -> Result<&mut Self> {
6120        let rev = RevisionSpec::parse(rev)?.raw;
6121        self.items.push(RevisionSelectionItem::Exclude(rev));
6122        Ok(self)
6123    }
6124
6125    pub fn range(&mut self, range: RevisionRange) -> &mut Self {
6126        self.items.push(RevisionSelectionItem::Range(range));
6127        self
6128    }
6129
6130    pub fn resolve<R: ObjectReader>(
6131        &self,
6132        git_dir: &Path,
6133        format: sley_core::ObjectFormat,
6134        reader: &R,
6135    ) -> Result<ResolvedRevisionSelection> {
6136        let mut resolved = ResolvedRevisionSelection::default();
6137        for item in &self.items {
6138            match item {
6139                RevisionSelectionItem::Include(rev) => {
6140                    resolved
6141                        .starts
6142                        .push(resolve_range_endpoint(git_dir, format, reader, rev)?);
6143                }
6144                RevisionSelectionItem::Exclude(rev) => {
6145                    let oid = resolve_range_endpoint(git_dir, format, reader, rev)?;
6146                    extend_excluded_ancestors(
6147                        git_dir,
6148                        format,
6149                        reader,
6150                        &mut resolved.excluded,
6151                        &oid,
6152                    )?;
6153                }
6154                RevisionSelectionItem::Range(range) => {
6155                    resolve_selection_range(git_dir, format, reader, range, &mut resolved)?;
6156                }
6157            }
6158        }
6159        Ok(resolved)
6160    }
6161}
6162
6163impl ResolvedRevisionSelection {
6164    /// Walk from the resolved starts and return selected commit ids after
6165    /// applying the excluded set.
6166    pub fn selected_commit_oids<R: ObjectReader>(
6167        &self,
6168        git_dir: &Path,
6169        format: sley_core::ObjectFormat,
6170        reader: &R,
6171        first_parent: bool,
6172    ) -> Result<Vec<ObjectId>> {
6173        let mut graph = CommitGraphContext::load(git_dir, format);
6174        let mut seen = HashSet::new();
6175        let mut pending: VecDeque<ObjectId> = self.starts.clone().into();
6176        let mut out = Vec::new();
6177        while let Some(oid) = pending.pop_front() {
6178            if !seen.insert(oid) || self.excluded.contains(&oid) {
6179                continue;
6180            }
6181            if first_parent {
6182                pending.extend(graph.commit_first_parent(reader, &oid)?);
6183                out.push(oid);
6184                continue;
6185            }
6186            for parent in graph.commit_parent_ids(reader, &oid)? {
6187                pending.push_back(parent);
6188            }
6189            out.push(oid);
6190        }
6191        Ok(out)
6192    }
6193}
6194
6195/// Resolve a parsed range to the set of commit oids it selects.
6196///
6197/// `A..B` yields commits reachable from `B` but not `A`; `A...B` yields the
6198/// symmetric difference (reachable from `A` or `B` but not both). Endpoints are
6199/// resolved as revisions and peeled to commits before traversal. The returned
6200/// vector is unordered.
6201pub fn resolve_revision_range<R: ObjectReader>(
6202    git_dir: &Path,
6203    format: sley_core::ObjectFormat,
6204    reader: &R,
6205    range: &RevisionRange,
6206) -> Result<Vec<ObjectId>> {
6207    match range {
6208        RevisionRange::Asymmetric { start, end } => {
6209            let start_oid = resolve_range_endpoint(git_dir, format, reader, start)?;
6210            let end_oid = resolve_range_endpoint(git_dir, format, reader, end)?;
6211            let excluded = ancestor_set(git_dir, reader, format, &start_oid)?;
6212            let included = ancestor_set(git_dir, reader, format, &end_oid)?;
6213            Ok(included
6214                .into_iter()
6215                .filter(|oid| !excluded.contains(oid))
6216                .collect())
6217        }
6218        RevisionRange::Symmetric { left, right } => {
6219            let left_oid = resolve_range_endpoint(git_dir, format, reader, left)?;
6220            let right_oid = resolve_range_endpoint(git_dir, format, reader, right)?;
6221            let left_set = ancestor_set(git_dir, reader, format, &left_oid)?;
6222            let right_set = ancestor_set(git_dir, reader, format, &right_oid)?;
6223            let mut out = Vec::new();
6224            for oid in &left_set {
6225                if !right_set.contains(oid) {
6226                    out.push(*oid);
6227                }
6228            }
6229            for oid in &right_set {
6230                if !left_set.contains(oid) {
6231                    out.push(*oid);
6232                }
6233            }
6234            Ok(out)
6235        }
6236    }
6237}
6238
6239fn resolve_selection_range<R: ObjectReader>(
6240    git_dir: &Path,
6241    format: sley_core::ObjectFormat,
6242    reader: &R,
6243    range: &RevisionRange,
6244    resolved: &mut ResolvedRevisionSelection,
6245) -> Result<()> {
6246    match range {
6247        RevisionRange::Asymmetric { start, end } => {
6248            let start_oid = resolve_range_endpoint(git_dir, format, reader, start)?;
6249            let end_oid = resolve_range_endpoint(git_dir, format, reader, end)?;
6250            extend_excluded_ancestors(git_dir, format, reader, &mut resolved.excluded, &start_oid)?;
6251            resolved.starts.push(end_oid);
6252        }
6253        RevisionRange::Symmetric { left, right } => {
6254            let left_oid = resolve_range_endpoint(git_dir, format, reader, left)?;
6255            let right_oid = resolve_range_endpoint(git_dir, format, reader, right)?;
6256            resolved.starts.push(left_oid);
6257            resolved.starts.push(right_oid);
6258            for base in merge_bases(git_dir, format, reader, &left_oid, &right_oid)? {
6259                extend_excluded_ancestors(git_dir, format, reader, &mut resolved.excluded, &base)?;
6260            }
6261        }
6262    }
6263    Ok(())
6264}
6265
6266fn resolve_range_endpoint<R: ObjectReader>(
6267    git_dir: &Path,
6268    format: sley_core::ObjectFormat,
6269    reader: &R,
6270    rev: &str,
6271) -> Result<ObjectId> {
6272    let oid = resolve_revision_with_reader(git_dir, format, reader, rev)?;
6273    peel_to_commit(reader, format, &oid)
6274}
6275
6276fn extend_excluded_ancestors<R: ObjectReader>(
6277    git_dir: &Path,
6278    format: sley_core::ObjectFormat,
6279    reader: &R,
6280    excluded: &mut HashSet<ObjectId>,
6281    start: &ObjectId,
6282) -> Result<()> {
6283    excluded.extend(ancestor_set(git_dir, reader, format, start)?);
6284    Ok(())
6285}
6286
6287/// Compute the set of commits reachable from `start` (inclusive) following all
6288/// parent edges. Uses the commit-graph for parent lookups when available.
6289fn ancestor_set<R: ObjectReader>(
6290    git_dir: &Path,
6291    reader: &R,
6292    format: sley_core::ObjectFormat,
6293    start: &ObjectId,
6294) -> Result<HashSet<ObjectId>> {
6295    let mut graph = CommitGraphContext::load(git_dir, format);
6296    ancestor_set_with_graph(&mut graph, reader, start)
6297}
6298
6299/// Reachability set of `start` (inclusive) over all parent edges, using a
6300/// pre-loaded graph context for parent lookups. A full reachability query
6301/// admits no generation-based pruning -- every reachable commit is part of the
6302/// answer -- so this is a plain BFS that simply reads parents from the graph
6303/// when available.
6304fn ancestor_set_with_graph<R: ObjectReader>(
6305    graph: &mut CommitGraphContext<'_>,
6306    reader: &R,
6307    start: &ObjectId,
6308) -> Result<HashSet<ObjectId>> {
6309    let mut seen = HashSet::new();
6310    let mut pending = VecDeque::from([*start]);
6311    while let Some(oid) = pending.pop_front() {
6312        if !seen.insert(oid) {
6313            continue;
6314        }
6315        for parent in graph.commit_parents(reader, &oid)? {
6316            pending.push_back(parent);
6317        }
6318    }
6319    Ok(seen)
6320}
6321
6322/// Count commits reachable from `local` but not `target` (`ahead`) and from
6323/// `target` but not `local` (`behind`).
6324///
6325/// This is the tracking-count primitive used by porcelain such as
6326/// `status --branch`. It avoids materializing parsed commits: equality and
6327/// simple linear fast-forward/behind cases return after a tiny parent walk, and
6328/// the general case falls back to OID-only ancestry sets backed by one shared
6329/// commit-graph context.
6330pub fn ahead_behind_counts<R: ObjectReader>(
6331    git_dir: &Path,
6332    format: sley_core::ObjectFormat,
6333    reader: &R,
6334    local: &ObjectId,
6335    target: &ObjectId,
6336) -> Result<(usize, usize)> {
6337    if local == target {
6338        return Ok((0, 0));
6339    }
6340
6341    let mut graph = CommitGraphContext::load(git_dir, format);
6342    if let Some(ahead) = linear_unique_count(&mut graph, reader, local, target)? {
6343        return Ok((ahead, 0));
6344    }
6345    if let Some(behind) = linear_unique_count(&mut graph, reader, target, local)? {
6346        return Ok((0, behind));
6347    }
6348
6349    let local_reachable = ancestor_set_with_graph(&mut graph, reader, local)?;
6350    let target_reachable = ancestor_set_with_graph(&mut graph, reader, target)?;
6351    let ahead = local_reachable.difference(&target_reachable).count();
6352    let behind = target_reachable.difference(&local_reachable).count();
6353    Ok((ahead, behind))
6354}
6355
6356fn linear_unique_count<R: ObjectReader>(
6357    graph: &mut CommitGraphContext<'_>,
6358    reader: &R,
6359    descendant: &ObjectId,
6360    ancestor: &ObjectId,
6361) -> Result<Option<usize>> {
6362    let mut current = *descendant;
6363    let mut count = 0usize;
6364    let mut seen = HashSet::new();
6365    loop {
6366        if &current == ancestor {
6367            return Ok(Some(count));
6368        }
6369        if !seen.insert(current) {
6370            return Ok(None);
6371        }
6372
6373        let mut parents = graph.commit_parent_ids(reader, &current)?;
6374        let Some(parent) = parents.next() else {
6375            return Ok(None);
6376        };
6377        if parents.next().is_some() {
6378            return Ok(None);
6379        }
6380        count += 1;
6381        current = parent;
6382    }
6383}
6384
6385/// Determine whether `ancestor` is reachable from `descendant` via parent
6386/// edges (an ancestor check). A commit is considered its own ancestor.
6387pub fn is_ancestor<R: ObjectReader>(
6388    git_dir: &Path,
6389    format: sley_core::ObjectFormat,
6390    reader: &R,
6391    ancestor: &ObjectId,
6392    descendant: &ObjectId,
6393) -> Result<bool> {
6394    if ancestor == descendant {
6395        return Ok(true);
6396    }
6397    let mut graph = CommitGraphContext::load(git_dir, format);
6398
6399    // Generation-based shortcut: a commit's generation is strictly greater than
6400    // any of its ancestors', so if `ancestor` sits at or above `descendant` in
6401    // the generation order it cannot be a (proper) ancestor of it. This only
6402    // fires when both generations are known; otherwise we fall through to the
6403    // walk. (`min_generation` doubles as the pruning floor below.)
6404    let min_generation = graph.generation(ancestor)?;
6405    if let (Some(anc_gen), Some(desc_gen)) = (min_generation, graph.generation(descendant)?)
6406        && anc_gen >= desc_gen
6407    {
6408        return Ok(false);
6409    }
6410
6411    let mut seen = HashSet::new();
6412    let mut pending = VecDeque::from([*descendant]);
6413    while let Some(oid) = pending.pop_front() {
6414        if !seen.insert(oid) {
6415            continue;
6416        }
6417        // Prune: if `oid`'s generation is below `ancestor`'s, then `oid` and all
6418        // of its own ancestors have a generation strictly smaller than
6419        // `ancestor`'s, so none of them can be `ancestor`. Stop descending here.
6420        // Only applies when both generations are known.
6421        if let (Some(floor), Some(here)) = (min_generation, graph.generation(&oid)?)
6422            && here < floor
6423        {
6424            continue;
6425        }
6426        for parent in graph.commit_parents(reader, &oid)? {
6427            if &parent == ancestor {
6428                return Ok(true);
6429            }
6430            pending.push_back(parent);
6431        }
6432    }
6433    Ok(false)
6434}
6435
6436/// Compute the merge bases (best common ancestors) of two commits, mirroring
6437/// the generation-free history walk used elsewhere in the project. Self-contained
6438/// so callers do not need the CLI's merge-base machinery.
6439pub fn merge_bases<R: ObjectReader>(
6440    git_dir: &Path,
6441    format: sley_core::ObjectFormat,
6442    reader: &R,
6443    left: &ObjectId,
6444    right: &ObjectId,
6445) -> Result<Vec<ObjectId>> {
6446    // One graph context is shared by both ancestry walks so the commit-graph is
6447    // read and parsed at most once for the whole merge-base computation; parents
6448    // and commit dates come from the graph when present and fall back to object
6449    // reads otherwise. The depth-based lowest-common-ancestor reduction below is
6450    // left unchanged so the selected bases are bit-for-bit identical to the
6451    // pure object-reading walk.
6452    let mut graph = CommitGraphContext::load(git_dir, format);
6453    let left_depths = ancestor_depths_with_graph(&mut graph, reader, left)?;
6454    let right_depths = ancestor_depths_with_graph(&mut graph, reader, right)?;
6455    let candidates: Vec<ObjectId> = left_depths
6456        .keys()
6457        .filter(|oid| right_depths.contains_key(*oid))
6458        .cloned()
6459        .collect();
6460    // Keep only maximal common ancestors. Because every parent of a common
6461    // ancestor is also common, a candidate is dominated exactly when it is the
6462    // direct parent of another common candidate.
6463    let candidate_set: HashSet<ObjectId> = candidates.iter().copied().collect();
6464    let mut dominated = HashSet::new();
6465    for candidate in &candidates {
6466        for parent in graph.commit_parents(reader, candidate)? {
6467            if candidate_set.contains(&parent) {
6468                dominated.insert(parent);
6469            }
6470        }
6471    }
6472    let mut bases: Vec<ObjectId> = candidates
6473        .into_iter()
6474        .filter(|candidate| !dominated.contains(candidate))
6475        .collect();
6476    bases.sort_by_key(|oid| oid.to_hex());
6477    Ok(bases)
6478}
6479
6480/// BFS the ancestry of `start`, recording the shortest distance to each commit,
6481/// using a pre-loaded graph context for parent lookups so several walks can
6482/// share one parsed commit-graph. The traversal is an unpruned BFS by design:
6483/// the recorded depths feed the merge-base lowest-common-ancestor reduction,
6484/// which depends on every reachable commit's shortest distance, so dropping
6485/// nodes would change the result.
6486fn ancestor_depths_with_graph<R: ObjectReader>(
6487    graph: &mut CommitGraphContext<'_>,
6488    reader: &R,
6489    start: &ObjectId,
6490) -> Result<HashMap<ObjectId, usize>> {
6491    let mut depths = HashMap::new();
6492    let mut pending = VecDeque::from([(*start, 0usize)]);
6493    while let Some((oid, depth)) = pending.pop_front() {
6494        if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
6495            continue;
6496        }
6497        depths.insert(oid, depth);
6498        for parent in graph.commit_parents(reader, &oid)? {
6499            pending.push_back((parent, depth + 1));
6500        }
6501    }
6502    Ok(depths)
6503}
6504
6505#[cfg(test)]
6506mod tests {
6507    use super::*;
6508    use sley_core::ObjectFormat;
6509    use sley_object::EncodedObject;
6510    use sley_odb::{ObjectDatabase, ObjectWriter};
6511    use sley_refs::{RefTarget, RefUpdate, ReflogEntry};
6512    use std::cell::Cell;
6513    use std::fs;
6514    use std::sync::atomic::{AtomicU64, Ordering};
6515
6516    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
6517
6518    #[test]
6519    fn setup_revisions_parses_ranges_carets_and_not() {
6520        let fixture = setup_revisions_fixture();
6521        let setup = run_setup(&fixture, ["base..main", "^side", "--not", "base", "^main"])
6522            .expect("setup should parse");
6523        assert_eq!(
6524            setup
6525                .options
6526                .positives
6527                .iter()
6528                .map(|tip| tip.oid)
6529                .collect::<Vec<_>>(),
6530            vec![fixture.tip, fixture.tip]
6531        );
6532        assert_oid_set(
6533            setup.options.negatives,
6534            [fixture.base, fixture.side, fixture.base],
6535        );
6536    }
6537
6538    #[test]
6539    fn setup_revisions_parses_symmetric_difference() {
6540        let fixture = setup_revisions_fixture();
6541        let setup = run_setup(&fixture, ["left...right"]).expect("setup should parse");
6542        assert_oid_set(
6543            setup.options.positives.iter().map(|tip| tip.oid),
6544            [fixture.left, fixture.right],
6545        );
6546        assert_eq!(setup.options.negatives, vec![fixture.base]);
6547        assert_eq!(
6548            setup.options.symmetric_ranges,
6549            vec![RevisionSymmetricRange {
6550                left: fixture.left,
6551                right: fixture.right,
6552                negated: false,
6553            }]
6554        );
6555    }
6556
6557    #[test]
6558    fn setup_revisions_parses_parent_shorthand_range() {
6559        let git_dir = temp_git_dir();
6560        let worktree = git_dir.with_extension("worktree");
6561        fs::create_dir_all(&worktree).expect("test operation should succeed");
6562        let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6563        let tree = write_tree(&mut db, &[]);
6564        let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6565        let first = write_test_commit(&mut db, tree, vec![base], b"first\n");
6566        let second = write_test_commit(&mut db, tree, vec![base], b"second\n");
6567        let merge = write_test_commit(&mut db, tree, vec![first, second], b"merge\n");
6568        set_branch(&git_dir, "merge", &merge);
6569
6570        let args = ["merge^-2".to_string()];
6571        let setup = setup_revisions(
6572            &args,
6573            &RevisionSetupContext {
6574                git_dir: &git_dir,
6575                worktree_root: Some(&worktree),
6576                cwd: &worktree,
6577                format: ObjectFormat::Sha1,
6578                reader: &db,
6579                config: None,
6580            },
6581        )
6582        .expect("setup should parse parent shorthand range");
6583        assert_eq!(
6584            setup
6585                .options
6586                .positives
6587                .iter()
6588                .map(|tip| tip.oid)
6589                .collect::<Vec<_>>(),
6590            vec![merge]
6591        );
6592        assert_eq!(setup.options.negatives, vec![second]);
6593        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6594        fs::remove_dir_all(worktree).expect("test operation should succeed");
6595    }
6596
6597    #[test]
6598    fn setup_revisions_expands_all_with_scoped_exclude() {
6599        let fixture = setup_revisions_fixture();
6600        // `--exclude` matches like git's `wildmatch(pattern, name, 0)`: a bare
6601        // `skip` is an exact match (it would NOT drop `skip/topic`), so excluding
6602        // the nested branch needs the `skip/*` glob — matching git's behavior.
6603        let setup =
6604            run_setup(&fixture, ["--exclude=skip/*", "--branches"]).expect("setup should parse");
6605        assert_oid_set(
6606            setup.options.positives.iter().map(|tip| tip.oid),
6607            [
6608                fixture.tip,
6609                fixture.left,
6610                fixture.right,
6611                fixture.base,
6612                fixture.side,
6613            ],
6614        );
6615        assert!(
6616            !setup
6617                .options
6618                .positives
6619                .iter()
6620                .any(|tip| tip.oid == fixture.skipped)
6621        );
6622    }
6623
6624    #[test]
6625    fn setup_revisions_collects_pathspecs_after_boundary() {
6626        let fixture = setup_revisions_fixture();
6627        let setup =
6628            run_setup(&fixture, ["HEAD", "--", "missing-path"]).expect("setup should parse");
6629        assert_eq!(setup.options.positives[0].oid, fixture.tip);
6630        assert_eq!(setup.pathspecs, vec!["missing-path".to_string()]);
6631    }
6632
6633    #[test]
6634    fn setup_revisions_reports_ambiguous_argument() {
6635        let fixture = setup_revisions_fixture();
6636        let err = run_setup(&fixture, ["not-a-rev-or-path"]).expect_err("setup should fail");
6637        assert!(matches!(err, GitError::Exit(128)));
6638        assert_eq!(
6639            ambiguous_argument_message("not-a-rev-or-path"),
6640            "fatal: ambiguous argument 'not-a-rev-or-path': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git <command> [<revision>...] -- [<file>...]'"
6641        );
6642    }
6643
6644    #[test]
6645    fn walk_commits_missing_start_reports_revision_walk_context() {
6646        let db = ObjectDatabase::new(ObjectFormat::Sha1);
6647        let missing = ObjectId::from_hex(
6648            ObjectFormat::Sha1,
6649            "1111111111111111111111111111111111111111",
6650        )
6651        .expect("test operation should succeed");
6652
6653        let err = walk_commits(&db, ObjectFormat::Sha1, [missing])
6654            .expect_err("missing commit should error");
6655        let kind = err.not_found_kind().expect("typed not found");
6656        assert_eq!(kind.object_id(), Some(missing));
6657        assert_eq!(
6658            kind.missing_object_context(),
6659            Some(MissingObjectContext::RevisionWalk)
6660        );
6661    }
6662
6663    #[test]
6664    fn resolve_revision_reads_symbolic_head_and_tags() {
6665        let git_dir = temp_git_dir();
6666        let oid = ObjectId::from_hex(
6667            ObjectFormat::Sha1,
6668            "ce013625030ba8dba906f756967f9e9ca394464a",
6669        )
6670        .expect("test operation should succeed");
6671        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
6672            .expect("test operation should succeed");
6673        let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6674        let mut tx = refs.transaction();
6675        tx.update(RefUpdate {
6676            name: "refs/heads/main".into(),
6677            expected: None,
6678            new: RefTarget::Direct(oid),
6679            reflog: None,
6680        });
6681        tx.update(RefUpdate {
6682            name: "refs/tags/v1.0".into(),
6683            expected: None,
6684            new: RefTarget::Direct(oid),
6685            reflog: None,
6686        });
6687        tx.commit().expect("test operation should succeed");
6688        assert_eq!(
6689            resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD")
6690                .expect("test operation should succeed"),
6691            oid
6692        );
6693        assert_eq!(
6694            resolve_revision(&git_dir, ObjectFormat::Sha1, "v1.0")
6695                .expect("test operation should succeed"),
6696            oid
6697        );
6698        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6699    }
6700
6701    #[test]
6702    fn resolve_revision_supports_parent_suffixes() {
6703        let git_dir = temp_git_dir();
6704        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
6705        let tree = ObjectId::from_hex(
6706            ObjectFormat::Sha1,
6707            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6708        )
6709        .expect("test operation should succeed");
6710        let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6711        let first_parent = write_test_commit(&mut db, tree, vec![base], b"main\n");
6712        let second_parent = write_test_commit(&mut db, tree, vec![base], b"side\n");
6713        let merge = write_test_commit(&mut db, tree, vec![first_parent, second_parent], b"merge\n");
6714        assert_eq!(
6715            resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{merge}^"))
6716                .expect("test operation should succeed"),
6717            first_parent
6718        );
6719        assert_eq!(
6720            resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{merge}^2"))
6721                .expect("test operation should succeed"),
6722            second_parent
6723        );
6724        assert_eq!(
6725            resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{merge}~2"))
6726                .expect("test operation should succeed"),
6727            base
6728        );
6729        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6730    }
6731
6732    #[test]
6733    fn resolve_revision_parent_suffix_honors_commit_grafts() {
6734        let git_dir = temp_git_dir();
6735        fs::create_dir_all(git_dir.join("info")).expect("test operation should succeed");
6736        let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6737        let tree = ObjectId::from_hex(
6738            ObjectFormat::Sha1,
6739            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6740        )
6741        .expect("test operation should succeed");
6742        let root = write_test_commit(&mut db, tree, Vec::new(), b"root\n");
6743        let first = write_test_commit(&mut db, tree, vec![root], b"first\n");
6744        let second = write_test_commit(&mut db, tree, vec![root], b"second\n");
6745        let third = write_test_commit(&mut db, tree, vec![root], b"third\n");
6746        fs::write(
6747            git_dir.join("info").join("grafts"),
6748            format!("{first} {second} {third}\n"),
6749        )
6750        .expect("test operation should succeed");
6751
6752        assert_eq!(
6753            resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{first}^2"))
6754                .expect("test operation should succeed"),
6755            third
6756        );
6757        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6758    }
6759
6760    #[test]
6761    fn resolve_revision_supports_abbreviated_loose_object_ids() {
6762        let git_dir = temp_git_dir();
6763        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6764        let oid = db
6765            .write_object(EncodedObject::new(ObjectType::Blob, b"abbrev\n".to_vec()))
6766            .expect("test operation should succeed");
6767
6768        assert_eq!(
6769            resolve_revision(&git_dir, ObjectFormat::Sha1, &oid.to_hex()[..8])
6770                .expect("test operation should succeed"),
6771            oid
6772        );
6773        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6774    }
6775
6776    #[test]
6777    fn resolve_revision_prefers_ref_over_abbreviated_object_id() {
6778        let git_dir = temp_git_dir();
6779        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6780        let object = db
6781            .write_object(EncodedObject::new(
6782                ObjectType::Blob,
6783                b"abbrev conflict\n".to_vec(),
6784            ))
6785            .expect("test operation should succeed");
6786        let target = ObjectId::from_hex(
6787            ObjectFormat::Sha1,
6788            "1111111111111111111111111111111111111111",
6789        )
6790        .expect("test operation should succeed");
6791        let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6792        let mut tx = refs.transaction();
6793        tx.update(RefUpdate {
6794            name: format!("refs/heads/{}", &object.to_hex()[..4]),
6795            expected: None,
6796            new: RefTarget::Direct(target),
6797            reflog: None,
6798        });
6799        tx.commit().expect("test operation should succeed");
6800
6801        assert_eq!(
6802            resolve_revision(&git_dir, ObjectFormat::Sha1, &object.to_hex()[..4])
6803                .expect("test operation should succeed"),
6804            target
6805        );
6806        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6807    }
6808
6809    #[test]
6810    fn resolve_revision_uses_commit_graph_for_parent_suffixes() {
6811        let git_dir = temp_git_dir();
6812        fs::create_dir_all(git_dir.join("objects").join("info"))
6813            .expect("test operation should succeed");
6814        let parent = ObjectId::from_hex(
6815            ObjectFormat::Sha1,
6816            "1111111111111111111111111111111111111111",
6817        )
6818        .expect("test operation should succeed");
6819        let child = ObjectId::from_hex(
6820            ObjectFormat::Sha1,
6821            "2222222222222222222222222222222222222222",
6822        )
6823        .expect("test operation should succeed");
6824        fs::write(git_dir.join("HEAD"), format!("{child}\n"))
6825            .expect("test operation should succeed");
6826        fs::write(
6827            git_dir.join("objects").join("info").join("commit-graph"),
6828            test_commit_graph(ObjectFormat::Sha1, &parent, &child),
6829        )
6830        .expect("test operation should succeed");
6831
6832        struct MissingReader;
6833        impl ObjectReader for MissingReader {
6834            fn read_object(&self, oid: &ObjectId) -> Result<std::sync::Arc<EncodedObject>> {
6835                Err(GitError::not_found(format!(
6836                    "object reader should not be used for {oid}"
6837                )))
6838            }
6839        }
6840
6841        assert_eq!(
6842            resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &MissingReader, "HEAD^",)
6843                .expect("test operation should succeed"),
6844            parent
6845        );
6846        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6847    }
6848
6849    #[test]
6850    fn peel_to_tree_handles_commits_and_tags() {
6851        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
6852        let tree = ObjectId::from_hex(
6853            ObjectFormat::Sha1,
6854            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6855        )
6856        .expect("test operation should succeed");
6857        db.write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
6858            .expect("test operation should succeed");
6859        let commit = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6860        let tag = Tag {
6861            object: commit,
6862            object_type: ObjectType::Commit,
6863            name: b"v1.0".to_vec(),
6864            tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
6865            message: b"release\n".to_vec(),
6866            raw_body: None,
6867        };
6868        let tag = db
6869            .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
6870            .expect("test operation should succeed");
6871        assert_eq!(
6872            peel_to_tree(&db, ObjectFormat::Sha1, &commit).expect("test operation should succeed"),
6873            tree
6874        );
6875        assert_eq!(
6876            peel_to_tree(&db, ObjectFormat::Sha1, &tag).expect("test operation should succeed"),
6877            tree
6878        );
6879    }
6880
6881    #[test]
6882    fn peel_to_commit_handles_annotated_tags() {
6883        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
6884        let tree = ObjectId::from_hex(
6885            ObjectFormat::Sha1,
6886            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6887        )
6888        .expect("test operation should succeed");
6889        db.write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
6890            .expect("test operation should succeed");
6891        let commit = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6892        let tag = Tag {
6893            object: commit,
6894            object_type: ObjectType::Commit,
6895            name: b"v1.0".to_vec(),
6896            tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
6897            message: b"release\n".to_vec(),
6898            raw_body: None,
6899        };
6900        let tag = db
6901            .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
6902            .expect("test operation should succeed");
6903        assert_eq!(
6904            peel_to_commit(&db, ObjectFormat::Sha1, &tag).expect("test operation should succeed"),
6905            commit
6906        );
6907    }
6908
6909    #[test]
6910    fn resolve_revision_supports_peel_suffixes() {
6911        let git_dir = temp_git_dir();
6912        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
6913        let tree = ObjectId::from_hex(
6914            ObjectFormat::Sha1,
6915            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
6916        )
6917        .expect("test operation should succeed");
6918        db.write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
6919            .expect("test operation should succeed");
6920        let commit = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
6921        let tag = Tag {
6922            object: commit,
6923            object_type: ObjectType::Commit,
6924            name: b"v1.0".to_vec(),
6925            tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
6926            message: b"release\n".to_vec(),
6927            raw_body: None,
6928        };
6929        let tag = db
6930            .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
6931            .expect("test operation should succeed");
6932        assert_eq!(
6933            resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, &format!("{tag}^{{}}"))
6934                .expect("test operation should succeed"),
6935            commit
6936        );
6937        assert_eq!(
6938            resolve_revision_with_reader(
6939                &git_dir,
6940                ObjectFormat::Sha1,
6941                &db,
6942                &format!("{tag}^{{commit}}")
6943            )
6944            .expect("test operation should succeed"),
6945            commit
6946        );
6947        assert_eq!(
6948            resolve_revision_with_reader(
6949                &git_dir,
6950                ObjectFormat::Sha1,
6951                &db,
6952                &format!("{tag}^{{tree}}")
6953            )
6954            .expect("test operation should succeed"),
6955            tree
6956        );
6957        assert_eq!(
6958            resolve_revision_with_reader(
6959                &git_dir,
6960                ObjectFormat::Sha1,
6961                &db,
6962                &format!("{tag}^{{tag}}")
6963            )
6964            .expect("test operation should succeed"),
6965            tag
6966        );
6967        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6968    }
6969
6970    #[test]
6971    fn pack_refs_with_auto_peel_writes_peeled_tag() {
6972        let git_dir = temp_git_dir();
6973        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
6974        let tree = db
6975            .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
6976            .expect("test operation should succeed");
6977        let commit = Commit {
6978            tree,
6979            parents: Vec::new(),
6980            author: b"Example User <example@example.invalid> 0 +0000".to_vec(),
6981            committer: b"Example User <example@example.invalid> 0 +0000".to_vec(),
6982            encoding: None,
6983            message: b"base\n".to_vec(),
6984        };
6985        let commit = db
6986            .write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
6987            .expect("test operation should succeed");
6988        let tag = Tag {
6989            object: commit,
6990            object_type: ObjectType::Commit,
6991            name: b"v1.0".to_vec(),
6992            tagger: Some(b"Example User <example@example.invalid> 0 +0000".to_vec()),
6993            message: b"release\n".to_vec(),
6994            raw_body: None,
6995        };
6996        let tag = db
6997            .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
6998            .expect("test operation should succeed");
6999        let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7000        let mut tx = refs.transaction();
7001        tx.update(RefUpdate {
7002            name: "refs/tags/v1.0".into(),
7003            expected: None,
7004            new: RefTarget::Direct(tag),
7005            reflog: None,
7006        });
7007        tx.commit().expect("test operation should succeed");
7008
7009        let packed = pack_refs_with_auto_peel(&git_dir, ObjectFormat::Sha1, true)
7010            .expect("test operation should succeed");
7011        let packed_tag = packed
7012            .iter()
7013            .find(|packed| packed.reference.name == "refs/tags/v1.0")
7014            .expect("test operation should succeed");
7015        assert_eq!(packed_tag.peeled, Some(commit));
7016        assert_eq!(
7017            refs.read_ref("refs/tags/v1.0")
7018                .expect("test operation should succeed"),
7019            Some(RefTarget::Direct(tag))
7020        );
7021        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
7022        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7023    }
7024
7025    #[test]
7026    fn resolve_rev_path_finds_nested_blob_and_subtree() {
7027        let git_dir = temp_git_dir();
7028        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7029        let blob = db
7030            .write_object(EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec()))
7031            .expect("test operation should succeed");
7032        let sub = write_tree(&mut db, &[(0o100644, b"file.txt", &blob)]);
7033        let dir = write_tree(&mut db, &[(0o040000, b"sub", &sub)]);
7034        let root = write_tree(&mut db, &[(0o040000, b"dir", &dir)]);
7035        let commit = write_test_commit(&mut db, root, Vec::new(), b"init\n");
7036
7037        // Nested blob via `<rev>:<path>`.
7038        assert_eq!(
7039            resolve_rev_path(
7040                &git_dir,
7041                ObjectFormat::Sha1,
7042                &db,
7043                &commit.to_hex(),
7044                "dir/sub/file.txt"
7045            )
7046            .expect("test operation should succeed"),
7047            blob
7048        );
7049        assert_eq!(
7050            resolve_rev_path(
7051                &git_dir,
7052                ObjectFormat::Sha1,
7053                &db,
7054                &commit.to_hex(),
7055                "./dir/./sub/file.txt"
7056            )
7057            .expect("test operation should succeed"),
7058            blob
7059        );
7060        // Subtree path resolves to the subtree id.
7061        assert_eq!(
7062            resolve_rev_path(
7063                &git_dir,
7064                ObjectFormat::Sha1,
7065                &db,
7066                &commit.to_hex(),
7067                "dir/sub"
7068            )
7069            .expect("test operation should succeed"),
7070            sub
7071        );
7072        // Empty path resolves to the commit's tree.
7073        assert_eq!(
7074            resolve_rev_path(&git_dir, ObjectFormat::Sha1, &db, &commit.to_hex(), "")
7075                .expect("test operation should succeed"),
7076            root
7077        );
7078        let entry = resolve_rev_path_entry(
7079            &git_dir,
7080            ObjectFormat::Sha1,
7081            &db,
7082            &commit.to_hex(),
7083            "dir/sub/file.txt",
7084        )
7085        .expect("test operation should succeed");
7086        assert_eq!(entry.oid, blob);
7087        assert_eq!(entry.mode, Some(0o100644));
7088        assert_eq!(entry.object_type, ObjectType::Blob);
7089        assert_eq!(entry.name, b"file.txt");
7090        let entry = resolve_rev_path_entry(&git_dir, ObjectFormat::Sha1, &db, &commit.to_hex(), "")
7091            .expect("test operation should succeed");
7092        assert_eq!(entry.oid, root);
7093        assert_eq!(entry.mode, None);
7094        assert_eq!(entry.object_type, ObjectType::Tree);
7095        assert!(entry.name.is_empty());
7096        // Resolvable through the unified string resolver too.
7097        assert_eq!(
7098            resolve_revision_with_reader(
7099                &git_dir,
7100                ObjectFormat::Sha1,
7101                &db,
7102                &format!("{commit}:dir/sub/file.txt"),
7103            )
7104            .expect("test operation should succeed"),
7105            blob
7106        );
7107        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7108    }
7109
7110    #[test]
7111    fn resolve_rev_path_reports_missing_and_non_tree_paths() {
7112        let git_dir = temp_git_dir();
7113        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7114        let blob = db
7115            .write_object(EncodedObject::new(ObjectType::Blob, b"root\n".to_vec()))
7116            .expect("test operation should succeed");
7117        let root = write_tree(&mut db, &[(0o100644, b"root.txt", &blob)]);
7118        let commit = write_test_commit(&mut db, root, Vec::new(), b"init\n");
7119
7120        // Missing path.
7121        let missing = resolve_rev_path(
7122            &git_dir,
7123            ObjectFormat::Sha1,
7124            &db,
7125            &commit.to_hex(),
7126            "nope.txt",
7127        )
7128        .expect_err("test operation should fail");
7129        assert!(
7130            matches!(&missing, GitError::NotFound(kind) if kind.to_string().contains("does not exist")),
7131            "unexpected error: {missing:?}"
7132        );
7133
7134        // Descending through a blob is "not a tree" -> reported as not found.
7135        let not_tree = resolve_rev_path(
7136            &git_dir,
7137            ObjectFormat::Sha1,
7138            &db,
7139            &commit.to_hex(),
7140            "root.txt/x",
7141        )
7142        .expect_err("test operation should fail");
7143        assert!(
7144            matches!(&not_tree, GitError::NotFound(kind) if kind.to_string().contains("does not exist")),
7145            "unexpected error: {not_tree:?}"
7146        );
7147        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7148    }
7149
7150    #[test]
7151    fn resolve_index_path_reads_stage_entries() {
7152        let git_dir = temp_git_dir();
7153        let oid_zero = ObjectId::from_hex(
7154            ObjectFormat::Sha1,
7155            "1111111111111111111111111111111111111111",
7156        )
7157        .expect("test operation should succeed");
7158        let oid_two = ObjectId::from_hex(
7159            ObjectFormat::Sha1,
7160            "2222222222222222222222222222222222222222",
7161        )
7162        .expect("test operation should succeed");
7163        let index = Index {
7164            version: 2,
7165            entries: vec![
7166                test_index_entry(b"file.txt", &oid_zero, 0),
7167                test_index_entry(b"conflict.txt", &oid_two, 2),
7168            ],
7169            extensions: Vec::new(),
7170            checksum: None,
7171        };
7172        fs::write(
7173            git_dir.join("index"),
7174            index
7175                .write(ObjectFormat::Sha1)
7176                .expect("test operation should succeed"),
7177        )
7178        .expect("test operation should succeed");
7179
7180        // `:path` defaults to stage 0.
7181        assert_eq!(
7182            resolve_revision_with_reader(
7183                &git_dir,
7184                ObjectFormat::Sha1,
7185                &ObjectDatabase::new(ObjectFormat::Sha1),
7186                ":file.txt",
7187            )
7188            .expect("test operation should succeed"),
7189            oid_zero
7190        );
7191        assert_eq!(
7192            resolve_revision_with_reader(
7193                &git_dir,
7194                ObjectFormat::Sha1,
7195                &ObjectDatabase::new(ObjectFormat::Sha1),
7196                ":./file.txt",
7197            )
7198            .expect("test operation should succeed"),
7199            oid_zero
7200        );
7201        // `:N:path` selects a specific stage.
7202        assert_eq!(
7203            resolve_revision_with_reader(
7204                &git_dir,
7205                ObjectFormat::Sha1,
7206                &ObjectDatabase::new(ObjectFormat::Sha1),
7207                ":2:conflict.txt",
7208            )
7209            .expect("test operation should succeed"),
7210            oid_two
7211        );
7212        // Wrong stage reports a stage-specific error.
7213        let wrong_stage = resolve_revision_with_reader(
7214            &git_dir,
7215            ObjectFormat::Sha1,
7216            &ObjectDatabase::new(ObjectFormat::Sha1),
7217            ":1:conflict.txt",
7218        )
7219        .expect_err("test operation should fail");
7220        assert!(
7221            matches!(&wrong_stage, GitError::NotFound(kind) if kind.to_string().contains("not at stage 1")),
7222            "unexpected error: {wrong_stage:?}"
7223        );
7224        // Unknown path reports "not in the index".
7225        let unknown = resolve_revision_with_reader(
7226            &git_dir,
7227            ObjectFormat::Sha1,
7228            &ObjectDatabase::new(ObjectFormat::Sha1),
7229            ":missing.txt",
7230        )
7231        .expect_err("test operation should fail");
7232        assert!(
7233            matches!(&unknown, GitError::NotFound(kind) if kind.to_string().contains("not in the index")),
7234            "unexpected error: {unknown:?}"
7235        );
7236        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7237    }
7238
7239    #[test]
7240    fn resolve_index_path_reads_blobs_beneath_sparse_directory_entries() {
7241        let git_dir = temp_git_dir();
7242        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7243        let blob = db
7244            .write_object(EncodedObject::new(ObjectType::Blob, b"sparse\n".to_vec()))
7245            .expect("test operation should succeed");
7246        let nested = write_tree(&mut db, &[]);
7247        let sparse_tree = write_tree(
7248            &mut db,
7249            &[(0o100644, b"a", &blob), (0o040000, b"nested", &nested)],
7250        );
7251        let mut sparse_dir = test_index_entry(b"folder1/", &sparse_tree, 0);
7252        sparse_dir.mode = sley_index::SPARSE_DIR_MODE;
7253        sparse_dir.set_skip_worktree(true);
7254        let index = Index {
7255            version: 3,
7256            entries: vec![sparse_dir],
7257            extensions: Vec::new(),
7258            checksum: None,
7259        };
7260        fs::write(
7261            git_dir.join("index"),
7262            index
7263                .write(ObjectFormat::Sha1)
7264                .expect("test operation should succeed"),
7265        )
7266        .expect("test operation should succeed");
7267
7268        assert_eq!(
7269            resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, ":folder1/a")
7270                .expect("test operation should succeed"),
7271            blob
7272        );
7273        for spec in [":folder1/", ":folder1/nested/"] {
7274            let err = resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, spec)
7275                .expect_err("test operation should fail");
7276            assert!(
7277                matches!(&err, GitError::NotFound(kind) if kind.to_string().contains("not in the index")),
7278                "unexpected error for {spec}: {err:?}"
7279            );
7280        }
7281        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7282    }
7283
7284    #[test]
7285    fn search_commit_message_all_finds_matching_commit() {
7286        let git_dir = temp_git_dir();
7287        let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
7288        let tree = db
7289            .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
7290            .expect("test operation should succeed");
7291        let first = write_dated_commit(&mut db, tree, Vec::new(), b"add feature\n", 1000);
7292        let second = write_dated_commit(&mut db, tree, vec![first], b"fix the widget bug\n", 2000);
7293        let third = write_dated_commit(&mut db, tree, vec![second], b"unrelated change\n", 3000);
7294        let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7295        let mut tx = refs.transaction();
7296        tx.update(RefUpdate {
7297            name: "refs/heads/main".into(),
7298            expected: None,
7299            new: RefTarget::Direct(third),
7300            reflog: None,
7301        });
7302        tx.commit().expect("test operation should succeed");
7303
7304        assert_eq!(
7305            resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, ":/widget bug")
7306                .expect("test operation should succeed"),
7307            second
7308        );
7309        // `^{/regex}` over first-parent history finds the same commit from the tip.
7310        assert_eq!(
7311            resolve_revision_with_reader(
7312                &git_dir,
7313                ObjectFormat::Sha1,
7314                &db,
7315                &format!("{third}^{{/widget bug}}"),
7316            )
7317            .expect("test operation should succeed"),
7318            second
7319        );
7320        // No match is an error.
7321        let miss = resolve_revision_with_reader(&git_dir, ObjectFormat::Sha1, &db, ":/zzznomatch")
7322            .expect_err("test operation should fail");
7323        assert!(
7324            matches!(miss, GitError::NotFound(_)),
7325            "unexpected: {miss:?}"
7326        );
7327        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7328    }
7329
7330    #[test]
7331    fn revision_spec_ref_splits_only_top_level_tree_path_colons() {
7332        assert_eq!(
7333            RevisionSpecRef::parse("HEAD:hello")
7334                .expect("test operation should succeed")
7335                .tree_path(),
7336            Some(("HEAD", "hello"))
7337        );
7338        assert_eq!(
7339            RevisionSpecRef::parse("HEAD^{/testing:}:hello")
7340                .expect("test operation should succeed")
7341                .tree_path(),
7342            Some(("HEAD^{/testing:}", "hello"))
7343        );
7344        assert_eq!(
7345            RevisionSpecRef::parse("HEAD@{2024-01-01 10:00:00}:hello")
7346                .expect("test operation should succeed")
7347                .tree_path(),
7348            Some(("HEAD@{2024-01-01 10:00:00}", "hello"))
7349        );
7350        assert_eq!(
7351            RevisionSpecRef::parse(":/testing: message")
7352                .expect("test operation should succeed")
7353                .kind(),
7354            RevisionSpecKind::MessageSearch {
7355                text: "testing: message"
7356            }
7357        );
7358    }
7359
7360    #[test]
7361    fn read_bisect_terms_defaults_and_matches_custom_refs() {
7362        let git_dir = temp_git_dir();
7363        let terms = read_bisect_terms(&git_dir).expect("test operation should succeed");
7364        assert_eq!(terms, BisectTerms::default());
7365        assert!(terms.is_bad_ref("refs/bisect/bad"));
7366        assert!(terms.is_good_ref("refs/bisect/good-1234"));
7367
7368        fs::write(git_dir.join("BISECT_TERMS"), b"curious\nknown\n")
7369            .expect("test operation should succeed");
7370        let terms = read_bisect_terms(&git_dir).expect("test operation should succeed");
7371        assert_eq!(terms.bad, "curious");
7372        assert_eq!(terms.good, "known");
7373        assert!(terms.is_bad_ref("refs/bisect/curious-1"));
7374        assert!(terms.is_good_ref("refs/bisect/known-3"));
7375        assert!(!terms.is_bad_ref("refs/bisect/bad"));
7376        assert!(!terms.is_good_ref("refs/bisect/good"));
7377
7378        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7379    }
7380
7381    #[test]
7382    fn resolve_rev_path_after_commit_message_search_suffix() {
7383        let git_dir = temp_git_dir();
7384        let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
7385        let blob = db
7386            .write_object(EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec()))
7387            .expect("test operation should succeed");
7388        let tree = write_tree(&mut db, &[(0o100644, b"hello", &blob)]);
7389        let base = write_dated_commit(&mut db, tree, Vec::new(), b"base\n", 1000);
7390        let searched =
7391            write_dated_commit(&mut db, tree, vec![base], b"testing: path search\n", 2000);
7392        let tip = write_dated_commit(&mut db, tree, vec![searched], b"tip\n", 3000);
7393        set_branch(&git_dir, "other", &tip);
7394
7395        assert_eq!(
7396            resolve_revision_with_reader(
7397                &git_dir,
7398                ObjectFormat::Sha1,
7399                &db,
7400                "other^{/testing:}:hello",
7401            )
7402            .expect("test operation should succeed"),
7403            blob
7404        );
7405        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7406    }
7407
7408    #[test]
7409    fn parse_revision_range_recognizes_dot_forms() {
7410        assert_eq!(
7411            parse_revision_range("a..b"),
7412            Some(RevisionRange::Asymmetric {
7413                start: "a".into(),
7414                end: "b".into(),
7415            })
7416        );
7417        assert_eq!(
7418            parse_revision_range("a...b"),
7419            Some(RevisionRange::Symmetric {
7420                left: "a".into(),
7421                right: "b".into(),
7422            })
7423        );
7424        assert_eq!(
7425            parse_revision_range("..b"),
7426            Some(RevisionRange::Asymmetric {
7427                start: "HEAD".into(),
7428                end: "b".into(),
7429            })
7430        );
7431        assert_eq!(
7432            parse_revision_range("a.."),
7433            Some(RevisionRange::Asymmetric {
7434                start: "a".into(),
7435                end: "HEAD".into(),
7436            })
7437        );
7438        assert_eq!(
7439            parse_revision_range("merge^-"),
7440            Some(RevisionRange::Asymmetric {
7441                start: "merge^1".into(),
7442                end: "merge".into(),
7443            })
7444        );
7445        assert_eq!(
7446            parse_revision_range("merge^-2"),
7447            Some(RevisionRange::Asymmetric {
7448                start: "merge^2".into(),
7449                end: "merge".into(),
7450            })
7451        );
7452        assert_eq!(parse_revision_range("merge^-0"), None);
7453        assert_eq!(parse_revision_range("merge^-2x"), None);
7454        assert_eq!(parse_revision_range("plain"), None);
7455        assert_eq!(parse_revision_range(".."), None);
7456        assert_eq!(parse_revision_range(":../file.txt"), None);
7457        assert_eq!(parse_revision_range(":/message..text"), None);
7458        assert_eq!(parse_revision_range("HEAD:../top"), None);
7459        assert_eq!(parse_revision_range("HEAD:path..with-dots"), None);
7460        assert_eq!(parse_revision_range("HEAD:path...with-dots"), None);
7461        assert_eq!(parse_revision_range("HEAD:path^-"), None);
7462    }
7463
7464    #[test]
7465    fn resolve_revision_range_excludes_ancestors_and_symmetric_difference() {
7466        let git_dir = temp_git_dir();
7467        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7468        let tree = ObjectId::from_hex(
7469            ObjectFormat::Sha1,
7470            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
7471        )
7472        .expect("test operation should succeed");
7473        // base -> a -> b   (left line)
7474        //   \--> c -> d    (right line)
7475        let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
7476        let a = write_test_commit(&mut db, tree, vec![base], b"a\n");
7477        let b = write_test_commit(&mut db, tree, vec![a], b"b\n");
7478        let c = write_test_commit(&mut db, tree, vec![base], b"c\n");
7479        let d = write_test_commit(&mut db, tree, vec![c.clone()], b"d\n");
7480
7481        // A..B: reachable from B (a..b line) but not from A (base only here) ->
7482        // {a, b}; base and earlier are excluded.
7483        let range = RevisionRange::Asymmetric {
7484            start: a.to_hex(),
7485            end: b.to_hex(),
7486        };
7487        let mut got = resolve_revision_range(&git_dir, ObjectFormat::Sha1, &db, &range)
7488            .expect("test operation should succeed");
7489        got.sort_by_key(|x| x.to_hex());
7490        assert_eq!(got, vec![b]);
7491        assert!(!got.contains(&a), "A itself is excluded");
7492        assert!(!got.contains(&base), "A's ancestors are excluded");
7493
7494        // b...d: symmetric difference excludes the shared `base` while keeping
7495        // both unique sides {a, b} and {c, d}.
7496        let sym = RevisionRange::Symmetric {
7497            left: b.to_hex(),
7498            right: d.to_hex(),
7499        };
7500        let got_sym: HashSet<ObjectId> =
7501            resolve_revision_range(&git_dir, ObjectFormat::Sha1, &db, &sym)
7502                .expect("test operation should succeed")
7503                .into_iter()
7504                .collect();
7505        let expected: HashSet<ObjectId> = [a, b, c, d].into_iter().collect();
7506        assert_eq!(got_sym, expected);
7507        assert!(!got_sym.contains(&base), "shared base excluded from ...");
7508        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7509    }
7510
7511    #[test]
7512    fn revision_selection_resolves_asymmetric_range() {
7513        let git_dir = temp_git_dir();
7514        let format = ObjectFormat::Sha1;
7515        let (db, all) = build_history(&git_dir, format);
7516        let root = all[0].clone();
7517        let a = all[1].clone();
7518        let c = all[3].clone();
7519
7520        let selection = RevisionSelection::from_specs([format!("{a}..{c}")])
7521            .expect("test operation should succeed");
7522        let resolved = selection
7523            .resolve(&git_dir, format, &db)
7524            .expect("test operation should succeed");
7525
7526        assert_eq!(resolved.starts, vec![c.clone()]);
7527        assert_eq!(resolved.excluded, oid_set([root, a]));
7528        assert_oid_set(
7529            resolved
7530                .selected_commit_oids(&git_dir, format, &db, false)
7531                .expect("test operation should succeed"),
7532            [c],
7533        );
7534        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7535    }
7536
7537    #[test]
7538    fn revision_selection_resolves_default_left_range() {
7539        let git_dir = temp_git_dir();
7540        let format = ObjectFormat::Sha1;
7541        let (db, all) = build_history(&git_dir, format);
7542        let root = all[0].clone();
7543        let a = all[1].clone();
7544        let c = all[3].clone();
7545        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7546            .expect("test operation should succeed");
7547        set_branch(&git_dir, "main", &a);
7548
7549        let selection = RevisionSelection::from_specs([format!("..{c}")])
7550            .expect("test operation should succeed");
7551        let resolved = selection
7552            .resolve(&git_dir, format, &db)
7553            .expect("test operation should succeed");
7554
7555        assert_eq!(resolved.starts, vec![c.clone()]);
7556        assert_eq!(resolved.excluded, oid_set([root, a]));
7557        assert_oid_set(
7558            resolved
7559                .selected_commit_oids(&git_dir, format, &db, false)
7560                .expect("test operation should succeed"),
7561            [c],
7562        );
7563        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7564    }
7565
7566    #[test]
7567    fn revision_selection_resolves_default_right_range() {
7568        let git_dir = temp_git_dir();
7569        let format = ObjectFormat::Sha1;
7570        let (db, all) = build_history(&git_dir, format);
7571        let root = all[0].clone();
7572        let a = all[1].clone();
7573        let c = all[3].clone();
7574        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7575            .expect("test operation should succeed");
7576        set_branch(&git_dir, "main", &c);
7577
7578        let selection = RevisionSelection::from_specs([format!("{a}..")])
7579            .expect("test operation should succeed");
7580        let resolved = selection
7581            .resolve(&git_dir, format, &db)
7582            .expect("test operation should succeed");
7583
7584        assert_eq!(resolved.starts, vec![c.clone()]);
7585        assert_eq!(resolved.excluded, oid_set([root, a]));
7586        assert_oid_set(
7587            resolved
7588                .selected_commit_oids(&git_dir, format, &db, false)
7589                .expect("test operation should succeed"),
7590            [c],
7591        );
7592        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7593    }
7594
7595    #[test]
7596    fn revision_selection_resolves_symmetric_range() {
7597        let git_dir = temp_git_dir();
7598        let format = ObjectFormat::Sha1;
7599        let (db, all) = build_history(&git_dir, format);
7600        let root = all[0].clone();
7601        let a = all[1].clone();
7602        let b = all[2].clone();
7603
7604        let selection = RevisionSelection::from_specs([format!("{a}...{b}")])
7605            .expect("test operation should succeed");
7606        let resolved = selection
7607            .resolve(&git_dir, format, &db)
7608            .expect("test operation should succeed");
7609
7610        assert_eq!(resolved.starts, vec![a, b]);
7611        assert_eq!(resolved.excluded, oid_set([root]));
7612        assert_oid_set(
7613            resolved
7614                .selected_commit_oids(&git_dir, format, &db, false)
7615                .expect("test operation should succeed"),
7616            [a, b],
7617        );
7618        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7619    }
7620
7621    #[test]
7622    fn revision_selection_resolves_caret_exclude() {
7623        let git_dir = temp_git_dir();
7624        let format = ObjectFormat::Sha1;
7625        let (db, all) = build_history(&git_dir, format);
7626        let root = all[0].clone();
7627        let a = all[1].clone();
7628
7629        let selection = RevisionSelection::from_specs([format!("^{a}")])
7630            .expect("test operation should succeed");
7631        let resolved = selection
7632            .resolve(&git_dir, format, &db)
7633            .expect("test operation should succeed");
7634
7635        assert!(resolved.starts.is_empty());
7636        assert_eq!(resolved.excluded, oid_set([root, a]));
7637        assert!(
7638            resolved
7639                .selected_commit_oids(&git_dir, format, &db, false)
7640                .expect("test operation should succeed")
7641                .is_empty()
7642        );
7643        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7644    }
7645
7646    #[test]
7647    fn revision_selection_resolves_bare_include() {
7648        let git_dir = temp_git_dir();
7649        let format = ObjectFormat::Sha1;
7650        let (db, all) = build_history(&git_dir, format);
7651        let root = all[0].clone();
7652        let a = all[1].clone();
7653        let c = all[3].clone();
7654
7655        let selection =
7656            RevisionSelection::from_specs([c.to_hex()]).expect("test operation should succeed");
7657        let resolved = selection
7658            .resolve(&git_dir, format, &db)
7659            .expect("test operation should succeed");
7660
7661        assert_eq!(resolved.starts, vec![c.clone()]);
7662        assert!(resolved.excluded.is_empty());
7663        assert_oid_set(
7664            resolved
7665                .selected_commit_oids(&git_dir, format, &db, false)
7666                .expect("test operation should succeed"),
7667            [root, a, c],
7668        );
7669        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7670    }
7671
7672    #[test]
7673    fn merge_bases_finds_common_ancestor() {
7674        let git_dir = temp_git_dir();
7675        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7676        let tree = ObjectId::from_hex(
7677            ObjectFormat::Sha1,
7678            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
7679        )
7680        .expect("test operation should succeed");
7681        let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
7682        let left = write_test_commit(&mut db, tree, vec![base], b"left\n");
7683        let right = write_test_commit(&mut db, tree, vec![base], b"right\n");
7684        assert_eq!(
7685            merge_bases(&git_dir, ObjectFormat::Sha1, &db, &left, &right)
7686                .expect("test operation should succeed"),
7687            vec![base]
7688        );
7689        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7690    }
7691
7692    #[test]
7693    fn merge_bases_drop_common_ancestors_of_better_common_ancestors() {
7694        let git_dir = temp_git_dir();
7695        let mut db = ObjectDatabase::new(ObjectFormat::Sha1);
7696        let tree = ObjectId::from_hex(
7697            ObjectFormat::Sha1,
7698            "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
7699        )
7700        .expect("test operation should succeed");
7701
7702        // E---D---C---B---A
7703        // |           \   \
7704        // |            G   \
7705        // F----------------H
7706        let e = write_test_commit(&mut db, tree, Vec::new(), b"E\n");
7707        let d = write_test_commit(&mut db, tree, vec![e], b"D\n");
7708        let f = write_test_commit(&mut db, tree, vec![e], b"F\n");
7709        let c = write_test_commit(&mut db, tree, vec![d], b"C\n");
7710        let b = write_test_commit(&mut db, tree, vec![c], b"B\n");
7711        let a = write_test_commit(&mut db, tree, vec![b], b"A\n");
7712        let g = write_test_commit(&mut db, tree, vec![b, e], b"G\n");
7713        let h = write_test_commit(&mut db, tree, vec![a, f], b"H\n");
7714
7715        assert_eq!(
7716            merge_bases(&git_dir, ObjectFormat::Sha1, &db, &g, &h)
7717                .expect("test operation should succeed"),
7718            vec![b]
7719        );
7720        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7721    }
7722
7723    #[test]
7724    fn resolve_bare_at_is_head() {
7725        let git_dir = temp_git_dir();
7726        let oid = test_oid(0xaa);
7727        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7728            .expect("test operation should succeed");
7729        set_branch(&git_dir, "main", &oid);
7730        assert_eq!(
7731            resolve_revision(&git_dir, ObjectFormat::Sha1, "@")
7732                .expect("test operation should succeed"),
7733            oid
7734        );
7735        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7736    }
7737
7738    #[test]
7739    fn resolve_head_reflog_nth() {
7740        let git_dir = temp_git_dir();
7741        let c0 = test_oid(0x10);
7742        let c1 = test_oid(0x11);
7743        let c2 = test_oid(0x12);
7744        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7745            .expect("test operation should succeed");
7746        set_branch(&git_dir, "main", &c2);
7747        // Oldest-first reflog: c0 -> c1 -> c2 (c2 is the current value).
7748        write_head_reflog(
7749            &git_dir,
7750            &[
7751                (&zero_oid(), &c0, "commit (initial): c0"),
7752                (&c0, &c1, "commit: c1"),
7753                (&c1, &c2, "commit: c2"),
7754            ],
7755        );
7756        write_branch_reflog(
7757            &git_dir,
7758            "main",
7759            &[
7760                (&zero_oid(), &c0, "commit (initial): c0"),
7761                (&c0, &c1, "commit: c1"),
7762                (&c1, &c2, "commit: c2"),
7763            ],
7764        );
7765
7766        // `@{0}` is the current value, `@{1}`/`@{2}` walk back through the log.
7767        assert_eq!(
7768            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{0}")
7769                .expect("test operation should succeed"),
7770            c2
7771        );
7772        assert_eq!(
7773            resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD@{1}")
7774                .expect("test operation should succeed"),
7775            c1
7776        );
7777        assert_eq!(
7778            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{2}")
7779                .expect("test operation should succeed"),
7780            c0
7781        );
7782        // Out-of-range reports a git-style "only has N entries" error.
7783        let err = resolve_revision(&git_dir, ObjectFormat::Sha1, "@{5}")
7784            .expect_err("test operation should fail");
7785        assert!(
7786            matches!(&err, GitError::NotFound(kind) if kind.to_string().contains("only has 3 entries")),
7787            "unexpected error: {err:?}"
7788        );
7789        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7790    }
7791
7792    #[test]
7793    fn resolve_branch_reflog_nth() {
7794        let git_dir = temp_git_dir();
7795        let old = test_oid(0x20);
7796        let new = test_oid(0x21);
7797        set_branch(&git_dir, "topic", &new);
7798        write_branch_reflog(
7799            &git_dir,
7800            "topic",
7801            &[
7802                (&zero_oid(), &old, "branch: Created"),
7803                (&old, &new, "commit: work"),
7804            ],
7805        );
7806        assert_eq!(
7807            resolve_revision(&git_dir, ObjectFormat::Sha1, "topic@{0}")
7808                .expect("test operation should succeed"),
7809            new
7810        );
7811        assert_eq!(
7812            resolve_revision(&git_dir, ObjectFormat::Sha1, "topic@{1}")
7813                .expect("test operation should succeed"),
7814            old
7815        );
7816        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7817    }
7818
7819    #[test]
7820    fn resolve_upstream_via_branch_config() {
7821        let git_dir = temp_git_dir();
7822        let tip = test_oid(0x30);
7823        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7824            .expect("test operation should succeed");
7825        set_branch(&git_dir, "main", &tip);
7826        set_ref(&git_dir, "refs/remotes/origin/main", &tip);
7827        fs::write(
7828            git_dir.join("config"),
7829            b"[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
7830        )
7831        .expect("test operation should succeed");
7832
7833        for spec in ["@{u}", "@{upstream}", "main@{upstream}"] {
7834            assert_eq!(
7835                resolve_revision(&git_dir, ObjectFormat::Sha1, spec)
7836                    .expect("test operation should succeed"),
7837                tip,
7838                "spec {spec}"
7839            );
7840        }
7841        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7842    }
7843
7844    #[test]
7845    fn resolve_push_falls_back_to_upstream_then_uses_push_remote() {
7846        let git_dir = temp_git_dir();
7847        let up = test_oid(0x40);
7848        let pushed = test_oid(0x41);
7849        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7850            .expect("test operation should succeed");
7851        set_branch(&git_dir, "main", &up);
7852        set_ref(&git_dir, "refs/remotes/origin/main", &up);
7853
7854        // No push-specific config: `@{push}` mirrors `@{u}` (origin/main).
7855        fs::write(
7856            git_dir.join("config"),
7857            b"[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n",
7858        )
7859        .expect("test operation should succeed");
7860        assert_eq!(
7861            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{push}")
7862                .expect("test operation should succeed"),
7863            up
7864        );
7865
7866        // With a pushRemote, `@{push}` follows refs/remotes/<pushRemote>/<short>.
7867        // git only resolves the triangular push.default ∈ {current, matching}
7868        // case to the push remote; under `simple` it refuses because the push
7869        // destination (fork/main) differs from the upstream (origin/main).
7870        // Verified against git 2.54: `git rev-parse main@{push}` returns
7871        // refs/remotes/fork/main with push.default=current and errors under
7872        // simple ("cannot resolve 'simple' push to a single destination").
7873        set_ref(&git_dir, "refs/remotes/fork/main", &pushed);
7874        fs::write(
7875            git_dir.join("config"),
7876            b"[push]\n\tdefault = current\n[branch \"main\"]\n\tremote = origin\n\tpushRemote = fork\n\tmerge = refs/heads/main\n",
7877        )
7878        .expect("test operation should succeed");
7879        assert_eq!(
7880            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{push}")
7881                .expect("test operation should succeed"),
7882            pushed
7883        );
7884        // `@{u}` still uses the upstream remote, not the push remote.
7885        assert_eq!(
7886            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{u}")
7887                .expect("test operation should succeed"),
7888            up
7889        );
7890
7891        // Under the default push.default=simple, a triangular pushRemote that
7892        // differs from the upstream remote refuses to resolve, matching git.
7893        fs::write(
7894            git_dir.join("config"),
7895            b"[branch \"main\"]\n\tremote = origin\n\tpushRemote = fork\n\tmerge = refs/heads/main\n",
7896        )
7897        .expect("test operation should succeed");
7898        let err = resolve_revision(&git_dir, ObjectFormat::Sha1, "@{push}")
7899            .expect_err("triangular simple push must not resolve");
7900        assert!(
7901            matches!(&err, GitError::NotFound(kind) if kind.to_string().contains("simple")),
7902            "unexpected error: {err:?}"
7903        );
7904        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7905    }
7906
7907    #[test]
7908    fn resolve_previous_checkout_branch() {
7909        let git_dir = temp_git_dir();
7910        let main_tip = test_oid(0x50);
7911        let feature_tip = test_oid(0x51);
7912        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/feature\n")
7913            .expect("test operation should succeed");
7914        set_branch(&git_dir, "main", &main_tip);
7915        set_branch(&git_dir, "feature", &feature_tip);
7916        // Checkout history: ... -> feature -> main -> feature (newest last).
7917        write_head_reflog(
7918            &git_dir,
7919            &[
7920                (
7921                    &feature_tip,
7922                    &feature_tip,
7923                    "checkout: moving from main to feature",
7924                ),
7925                (
7926                    &feature_tip,
7927                    &main_tip,
7928                    "checkout: moving from feature to main",
7929                ),
7930                (
7931                    &main_tip,
7932                    &feature_tip,
7933                    "checkout: moving from main to feature",
7934                ),
7935            ],
7936        );
7937        // `@{-1}` = branch we left most recently (main) -> its current tip.
7938        assert_eq!(
7939            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{-1}")
7940                .expect("test operation should succeed"),
7941            main_tip
7942        );
7943        // `@{-2}` = the checkout before that (feature).
7944        assert_eq!(
7945            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{-2}")
7946                .expect("test operation should succeed"),
7947            feature_tip
7948        );
7949        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7950    }
7951
7952    #[test]
7953    fn empty_base_reflog_uses_current_branch_not_head() {
7954        let git_dir = temp_git_dir();
7955        let old_one = test_oid(0x52);
7956        let old_two = test_oid(0x53);
7957        let new_two = test_oid(0x54);
7958        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/old-branch\n")
7959            .expect("test operation should succeed");
7960        set_branch(&git_dir, "old-branch", &old_two);
7961        write_branch_reflog(
7962            &git_dir,
7963            "old-branch",
7964            &[
7965                (&zero_oid(), &old_one, "commit (initial): old-one"),
7966                (&old_one, &old_two, "commit: old-two"),
7967            ],
7968        );
7969        write_head_reflog(
7970            &git_dir,
7971            &[
7972                (
7973                    &old_two,
7974                    &new_two,
7975                    "checkout: moving from old-branch to new-branch",
7976                ),
7977                (
7978                    &new_two,
7979                    &old_two,
7980                    "checkout: moving from new-branch to old-branch",
7981                ),
7982            ],
7983        );
7984        assert_eq!(
7985            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{1}")
7986                .expect("test operation should succeed"),
7987            old_one
7988        );
7989        assert_eq!(
7990            resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD@{1}")
7991                .expect("test operation should succeed"),
7992            new_two
7993        );
7994        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7995    }
7996
7997    #[test]
7998    fn reflog_nth_requires_reflog_but_uses_oldest_fallback() {
7999        let git_dir = temp_git_dir();
8000        let base = test_oid(0x55);
8001        let tip = test_oid(0x56);
8002        set_branch(&git_dir, "newbranch", &tip);
8003        assert!(
8004            resolve_revision(&git_dir, ObjectFormat::Sha1, "newbranch@{0}").is_err(),
8005            "branch without reflog must not resolve @{{0}}"
8006        );
8007        write_branch_reflog(&git_dir, "newbranch", &[(&base, &tip, "commit: tip")]);
8008        assert_eq!(
8009            resolve_revision(&git_dir, ObjectFormat::Sha1, "newbranch@{1}")
8010                .expect("test operation should succeed"),
8011            base
8012        );
8013        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8014    }
8015
8016    #[test]
8017    fn prior_checkout_and_head_alias_compose_with_at_marks() {
8018        let git_dir = temp_git_dir();
8019        let main_tip = test_oid(0x57);
8020        let old_one = test_oid(0x58);
8021        let old_two = test_oid(0x59);
8022        let new_tip = test_oid(0x5a);
8023        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/new-branch\n")
8024            .expect("test operation should succeed");
8025        set_branch(&git_dir, "main", &main_tip);
8026        set_branch(&git_dir, "old-branch", &old_two);
8027        set_branch(&git_dir, "new-branch", &new_tip);
8028        write_branch_reflog(
8029            &git_dir,
8030            "old-branch",
8031            &[
8032                (&zero_oid(), &old_one, "commit (initial): old-one"),
8033                (&old_one, &old_two, "commit: old-two"),
8034            ],
8035        );
8036        write_head_reflog(
8037            &git_dir,
8038            &[(
8039                &old_two,
8040                &new_tip,
8041                "checkout: moving from old-branch to new-branch",
8042            )],
8043        );
8044        fs::write(
8045            git_dir.join("config"),
8046            b"[branch \"old-branch\"]\n\tremote = .\n\tmerge = refs/heads/main\n[branch \"new-branch\"]\n\tremote = .\n\tmerge = refs/heads/main\n",
8047        )
8048        .expect("test operation should succeed");
8049        assert_eq!(
8050            resolve_revision_symbolic_full_name(&git_dir, ObjectFormat::Sha1, "@{-1}")
8051                .expect("test operation should succeed"),
8052            Some("refs/heads/old-branch".to_string())
8053        );
8054        assert_eq!(
8055            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{-1}@{0}")
8056                .expect("test operation should succeed"),
8057            old_two
8058        );
8059        assert_eq!(
8060            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{-1}@{1}")
8061                .expect("test operation should succeed"),
8062            old_one
8063        );
8064        assert_eq!(
8065            resolve_revision_symbolic_full_name(&git_dir, ObjectFormat::Sha1, "HEAD@{u}")
8066                .expect("test operation should succeed"),
8067            Some("refs/heads/main".to_string())
8068        );
8069        assert_eq!(
8070            resolve_revision_symbolic_full_name(&git_dir, ObjectFormat::Sha1, "@@{u}")
8071                .expect("test operation should succeed"),
8072            Some("refs/heads/main".to_string())
8073        );
8074        assert_eq!(
8075            resolve_revision_symbolic_full_name(&git_dir, ObjectFormat::Sha1, "@{-1}@{u}")
8076                .expect("test operation should succeed"),
8077            Some("refs/heads/main".to_string())
8078        );
8079        let nested = resolve_revision(&git_dir, ObjectFormat::Sha1, "@{0}@{0}")
8080            .expect_err("test operation should fail");
8081        assert!(
8082            matches!(&nested, GitError::InvalidFormat(_)),
8083            "unexpected error: {nested:?}"
8084        );
8085        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8086    }
8087
8088    #[test]
8089    fn at_selector_composes_with_parent_suffix() {
8090        // `@{0}^` must resolve the reflog value first, then apply `^`: the
8091        // suffix splitter peels the `^` and recurses back into the `@{...}` base.
8092        let git_dir = temp_git_dir();
8093        let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
8094        let tree = db
8095            .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
8096            .expect("test operation should succeed");
8097        let parent = write_dated_commit(&mut db, tree, Vec::new(), b"parent\n", 1000);
8098        let child = write_dated_commit(&mut db, tree, vec![parent], b"child\n", 2000);
8099        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
8100            .expect("test operation should succeed");
8101        set_branch(&git_dir, "main", &child);
8102        write_head_reflog(
8103            &git_dir,
8104            &[
8105                (&zero_oid(), &parent, "commit (initial): parent"),
8106                (&parent, &child, "commit: child"),
8107            ],
8108        );
8109        write_branch_reflog(
8110            &git_dir,
8111            "main",
8112            &[
8113                (&zero_oid(), &parent, "commit (initial): parent"),
8114                (&parent, &child, "commit: child"),
8115            ],
8116        );
8117        assert_eq!(
8118            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{0}")
8119                .expect("test operation should succeed"),
8120            child
8121        );
8122        assert_eq!(
8123            resolve_revision(&git_dir, ObjectFormat::Sha1, "@{0}^")
8124                .expect("test operation should succeed"),
8125            parent
8126        );
8127        assert_eq!(
8128            resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD@{0}~1")
8129                .expect("test operation should succeed"),
8130            parent
8131        );
8132        assert_eq!(
8133            resolve_revision(&git_dir, ObjectFormat::Sha1, "HEAD@{0}^{tree}")
8134                .expect("test operation should succeed"),
8135            tree
8136        );
8137        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8138    }
8139
8140    #[test]
8141    fn resolve_at_selector_rejects_unsupported_and_malformed() {
8142        let git_dir = temp_git_dir();
8143        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
8144            .expect("test operation should succeed");
8145        set_branch(&git_dir, "main", &test_oid(0x60));
8146        // Date-based selectors are not implemented.
8147        let unsupported = resolve_revision(&git_dir, ObjectFormat::Sha1, "@{yesterday}")
8148            .expect_err("test operation should fail");
8149        assert!(
8150            matches!(&unsupported, GitError::Unsupported(_)),
8151            "unexpected error: {unsupported:?}"
8152        );
8153        // `@{-N}` only applies to a bare base.
8154        let bad_base = resolve_revision(&git_dir, ObjectFormat::Sha1, "main@{-1}")
8155            .expect_err("test operation should fail");
8156        assert!(
8157            matches!(&bad_base, GitError::InvalidFormat(_)),
8158            "unexpected error: {bad_base:?}"
8159        );
8160        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8161    }
8162
8163    fn test_oid(byte: u8) -> ObjectId {
8164        ObjectId::from_hex(ObjectFormat::Sha1, &format!("{byte:02x}").repeat(20))
8165            .expect("test operation should succeed")
8166    }
8167
8168    fn zero_oid() -> ObjectId {
8169        ObjectId::from_hex(ObjectFormat::Sha1, &"0".repeat(40))
8170            .expect("test operation should succeed")
8171    }
8172
8173    fn oid_set(oids: impl IntoIterator<Item = ObjectId>) -> HashSet<ObjectId> {
8174        oids.into_iter().collect()
8175    }
8176
8177    fn assert_oid_set(
8178        actual: impl IntoIterator<Item = ObjectId>,
8179        expected: impl IntoIterator<Item = ObjectId>,
8180    ) {
8181        assert_eq!(oid_set(actual), oid_set(expected));
8182    }
8183
8184    struct SetupRevisionsFixture {
8185        git_dir: PathBuf,
8186        worktree: PathBuf,
8187        db: FileObjectDatabase,
8188        base: ObjectId,
8189        tip: ObjectId,
8190        left: ObjectId,
8191        right: ObjectId,
8192        side: ObjectId,
8193        skipped: ObjectId,
8194    }
8195
8196    fn setup_revisions_fixture() -> SetupRevisionsFixture {
8197        let git_dir = temp_git_dir();
8198        let worktree = git_dir.with_extension("worktree");
8199        fs::create_dir_all(&worktree).expect("test operation should succeed");
8200        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
8201            .expect("test operation should succeed");
8202        let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
8203        let tree = write_tree(&mut db, &[]);
8204        let base = write_test_commit(&mut db, tree, Vec::new(), b"base\n");
8205        let tip = write_test_commit(&mut db, tree, vec![base], b"tip\n");
8206        let left = write_test_commit(&mut db, tree, vec![base], b"left\n");
8207        let right = write_test_commit(&mut db, tree, vec![base], b"right\n");
8208        let side = write_test_commit(&mut db, tree, Vec::new(), b"side\n");
8209        let skipped = write_test_commit(&mut db, tree, Vec::new(), b"skipped\n");
8210        set_branch(&git_dir, "main", &tip);
8211        set_branch(&git_dir, "base", &base);
8212        set_branch(&git_dir, "left", &left);
8213        set_branch(&git_dir, "right", &right);
8214        set_branch(&git_dir, "side", &side);
8215        set_ref(&git_dir, "refs/heads/skip/topic", &skipped);
8216        SetupRevisionsFixture {
8217            git_dir,
8218            worktree,
8219            db,
8220            base,
8221            tip,
8222            left,
8223            right,
8224            side,
8225            skipped,
8226        }
8227    }
8228
8229    fn run_setup<const N: usize>(
8230        fixture: &SetupRevisionsFixture,
8231        args: [&str; N],
8232    ) -> Result<SetupRevisions> {
8233        let args = args.iter().map(|arg| arg.to_string()).collect::<Vec<_>>();
8234        setup_revisions(
8235            &args,
8236            &RevisionSetupContext {
8237                git_dir: &fixture.git_dir,
8238                worktree_root: Some(&fixture.worktree),
8239                cwd: &fixture.worktree,
8240                format: ObjectFormat::Sha1,
8241                reader: &fixture.db,
8242                config: None,
8243            },
8244        )
8245    }
8246
8247    fn set_branch(git_dir: &Path, branch: &str, oid: &ObjectId) {
8248        set_ref(git_dir, &format!("refs/heads/{branch}"), oid);
8249    }
8250
8251    fn set_ref(git_dir: &Path, name: &str, oid: &ObjectId) {
8252        let refs = FileRefStore::new(git_dir, ObjectFormat::Sha1);
8253        let mut tx = refs.transaction();
8254        tx.update(RefUpdate {
8255            name: name.to_string(),
8256            expected: None,
8257            new: RefTarget::Direct(*oid),
8258            reflog: None,
8259        });
8260        tx.commit().expect("test operation should succeed");
8261    }
8262
8263    fn write_head_reflog(git_dir: &Path, entries: &[(&ObjectId, &ObjectId, &str)]) {
8264        write_reflog_for(git_dir, "HEAD", entries);
8265    }
8266
8267    fn write_branch_reflog(git_dir: &Path, branch: &str, entries: &[(&ObjectId, &ObjectId, &str)]) {
8268        write_reflog_for(git_dir, &format!("refs/heads/{branch}"), entries);
8269    }
8270
8271    fn write_reflog_for(git_dir: &Path, name: &str, entries: &[(&ObjectId, &ObjectId, &str)]) {
8272        let refs = FileRefStore::new(git_dir, ObjectFormat::Sha1);
8273        let entries: Vec<ReflogEntry> = entries
8274            .iter()
8275            .map(|(old, new, message)| ReflogEntry {
8276                old_oid: (*old).clone(),
8277                new_oid: (*new).clone(),
8278                committer: b"Example User <example@example.invalid> 1000 +0000".to_vec(),
8279                message: message.as_bytes().to_vec(),
8280            })
8281            .collect();
8282        refs.write_reflog(name, &entries)
8283            .expect("test operation should succeed");
8284    }
8285
8286    fn write_test_commit<W: ObjectWriter>(
8287        db: &mut W,
8288        tree: ObjectId,
8289        parents: Vec<ObjectId>,
8290        message: &[u8],
8291    ) -> ObjectId {
8292        let commit = Commit {
8293            tree,
8294            parents,
8295            author: b"Example User <example@example.invalid> 0 +0000".to_vec(),
8296            committer: b"Example User <example@example.invalid> 0 +0000".to_vec(),
8297            encoding: None,
8298            message: message.to_vec(),
8299        };
8300        db.write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
8301            .expect("test operation should succeed")
8302    }
8303
8304    fn write_dated_commit<W: ObjectWriter>(
8305        db: &mut W,
8306        tree: ObjectId,
8307        parents: Vec<ObjectId>,
8308        message: &[u8],
8309        when: i64,
8310    ) -> ObjectId {
8311        let ident = format!("Example User <example@example.invalid> {when} +0000");
8312        let commit = Commit {
8313            tree,
8314            parents,
8315            author: ident.clone().into_bytes(),
8316            committer: ident.into_bytes(),
8317            encoding: None,
8318            message: message.to_vec(),
8319        };
8320        db.write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
8321            .expect("test operation should succeed")
8322    }
8323
8324    fn write_tree<W: ObjectWriter>(db: &mut W, entries: &[(u32, &[u8], &ObjectId)]) -> ObjectId {
8325        let tree = sley_object::Tree {
8326            entries: entries
8327                .iter()
8328                .map(|(mode, name, oid)| sley_object::TreeEntry {
8329                    mode: *mode,
8330                    name: BString::from(*name),
8331                    oid: (*oid).clone(),
8332                })
8333                .collect(),
8334        };
8335        db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
8336            .expect("test operation should succeed")
8337    }
8338
8339    fn test_index_entry(path: &[u8], oid: &ObjectId, stage: u16) -> sley_index::IndexEntry {
8340        sley_index::IndexEntry {
8341            ctime_seconds: 0,
8342            ctime_nanoseconds: 0,
8343            mtime_seconds: 0,
8344            mtime_nanoseconds: 0,
8345            dev: 0,
8346            ino: 0,
8347            mode: 0o100644,
8348            uid: 0,
8349            gid: 0,
8350            size: 0,
8351            oid: *oid,
8352            flags: (stage & 0x3) << 12,
8353            flags_extended: 0,
8354            path: BString::from(path),
8355        }
8356    }
8357
8358    fn temp_git_dir() -> std::path::PathBuf {
8359        let path = std::env::temp_dir().join(format!(
8360            "sley-rev-{}-{}",
8361            std::process::id(),
8362            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
8363        ));
8364        fs::create_dir_all(&path).expect("test operation should succeed");
8365        path
8366    }
8367
8368    /// An object reader that refuses every read, used to prove a query was
8369    /// answered entirely from the commit-graph (parent/ancestry lookups never
8370    /// touched the odb).
8371    struct PanicReader;
8372    impl ObjectReader for PanicReader {
8373        fn read_object(&self, oid: &ObjectId) -> Result<std::sync::Arc<EncodedObject>> {
8374            Err(GitError::not_found(format!(
8375                "object reader must not be used for {oid}; graph should cover it"
8376            )))
8377        }
8378    }
8379
8380    struct CountingReader<'a> {
8381        inner: &'a FileObjectDatabase,
8382        reads: Cell<usize>,
8383    }
8384
8385    impl<'a> CountingReader<'a> {
8386        fn new(inner: &'a FileObjectDatabase) -> Self {
8387            Self {
8388                inner,
8389                reads: Cell::new(0),
8390            }
8391        }
8392    }
8393
8394    impl ObjectReader for CountingReader<'_> {
8395        fn read_object(&self, oid: &ObjectId) -> Result<std::sync::Arc<EncodedObject>> {
8396            self.reads.set(self.reads.get() + 1);
8397            self.inner.read_object(oid)
8398        }
8399    }
8400
8401    /// Compute topological generation numbers for `parents` (a child -> parents
8402    /// map). A root commit has generation 1; every other commit is one greater
8403    /// than the maximum generation among its parents -- exactly git's definition.
8404    fn generation_numbers(parents: &HashMap<ObjectId, Vec<ObjectId>>) -> HashMap<ObjectId, u32> {
8405        let mut generations: HashMap<ObjectId, u32> = HashMap::new();
8406        // Repeatedly relax until a fixpoint; histories here are tiny so a simple
8407        // loop is plenty and avoids an explicit topological sort.
8408        loop {
8409            let mut changed = false;
8410            for (oid, oid_parents) in parents {
8411                let candidate = oid_parents
8412                    .iter()
8413                    .map(|parent| generations.get(parent).copied().unwrap_or(0))
8414                    .max()
8415                    .unwrap_or(0)
8416                    + 1;
8417                if generations.get(oid).copied() != Some(candidate) {
8418                    // Only advance upward so the fixpoint is monotone.
8419                    let current = generations.get(oid).copied().unwrap_or(0);
8420                    if candidate > current {
8421                        generations.insert(*oid, candidate);
8422                        changed = true;
8423                    }
8424                }
8425            }
8426            if !changed {
8427                break;
8428            }
8429        }
8430        generations
8431    }
8432
8433    /// Write a real commit-graph (via `sley_formats::CommitGraph::write`) covering
8434    /// `commits` into `<git_dir>/objects/info/commit-graph`, with correct
8435    /// topological generation numbers and committer dates pulled from each
8436    /// commit object.
8437    fn write_commit_graph_file(
8438        git_dir: &Path,
8439        format: ObjectFormat,
8440        reader: &impl ObjectReader,
8441        commits: &[ObjectId],
8442    ) {
8443        let mut parents_map: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
8444        for oid in commits {
8445            parents_map.insert(
8446                *oid,
8447                commit_parents(reader, format, oid).expect("test operation should succeed"),
8448            );
8449        }
8450        let generations = generation_numbers(&parents_map);
8451        let entries: Vec<sley_formats::CommitGraphWriteEntry> = commits
8452            .iter()
8453            .map(|oid| {
8454                let object = reader
8455                    .read_object(oid)
8456                    .expect("test operation should succeed");
8457                let commit =
8458                    Commit::parse_ref(format, &object.body).expect("test operation should succeed");
8459                let commit_time =
8460                    commit_committer_time(commit.committer).unwrap_or(0).max(0) as u64;
8461                sley_formats::CommitGraphWriteEntry {
8462                    oid: *oid,
8463                    tree: commit.tree,
8464                    parents: commit.parents,
8465                    generation: generations.get(oid).copied().unwrap_or(1),
8466                    commit_time,
8467                    bloom_filter: None,
8468                }
8469            })
8470            .collect();
8471        let bytes = CommitGraph::write(format, &entries).expect("test operation should succeed");
8472        let info = git_dir.join("objects").join("info");
8473        fs::create_dir_all(&info).expect("test operation should succeed");
8474        fs::write(info.join("commit-graph"), bytes).expect("test operation should succeed");
8475    }
8476
8477    fn remove_commit_graph(git_dir: &Path) {
8478        let path = git_dir.join("objects").join("info").join("commit-graph");
8479        if path.exists() {
8480            fs::remove_file(path).expect("test operation should succeed");
8481        }
8482    }
8483
8484    /// Build a fixed multi-shape history and return the database plus the named
8485    /// commits. Shape (arrows point child -> parent):
8486    ///
8487    /// ```text
8488    ///   root
8489    ///   /  \
8490    ///  a    b
8491    ///  |    |\
8492    ///  c    d e
8493    ///   \  / \|
8494    ///    m1   f      m1 = merge(c, d)   (two-parent merge)
8495    ///     \   |
8496    ///      \  g
8497    ///       \ |
8498    ///        oct = merge(m1, g, f)      (octopus, three parents)
8499    /// ```
8500    ///
8501    /// plus a criss-cross pair `x1 = merge(a, b)` and `x2 = merge(b, a)` whose
8502    /// two merge bases are `a`'s and `b`'s shared ancestor structure (root).
8503    fn build_history(git_dir: &Path, format: ObjectFormat) -> (FileObjectDatabase, Vec<ObjectId>) {
8504        let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
8505        let tree = db
8506            .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
8507            .expect("test operation should succeed");
8508        let mut t = 1000i64;
8509        let mut commit = |db: &mut FileObjectDatabase, parents: Vec<ObjectId>, msg: &[u8]| {
8510            t += 1;
8511            write_dated_commit(db, tree, parents, msg, t)
8512        };
8513        let root = commit(&mut db, vec![], b"root\n");
8514        let a = commit(&mut db, vec![root], b"a\n");
8515        let b = commit(&mut db, vec![root], b"b\n");
8516        let c = commit(&mut db, vec![a], b"c\n");
8517        let d = commit(&mut db, vec![b], b"d\n");
8518        let e = commit(&mut db, vec![b], b"e\n");
8519        let m1 = commit(&mut db, vec![c.clone(), d.clone()], b"m1\n");
8520        let f = commit(&mut db, vec![d.clone(), e.clone()], b"f\n");
8521        let g = commit(&mut db, vec![f.clone()], b"g\n");
8522        let oct = commit(&mut db, vec![m1.clone(), g.clone(), f.clone()], b"oct\n");
8523        let x1 = commit(&mut db, vec![a, b], b"x1\n");
8524        let x2 = commit(&mut db, vec![b, a], b"x2\n");
8525        let all = vec![root, a, b, c, d, e, m1, f, g, oct, x1, x2];
8526        (db, all)
8527    }
8528
8529    #[test]
8530    fn graph_backed_walks_match_object_only_walks() {
8531        let git_dir = temp_git_dir();
8532        let format = ObjectFormat::Sha1;
8533        let (db, all) = build_history(&git_dir, format);
8534
8535        // Exercise every ordered pair of commits across is_ancestor, merge_bases
8536        // (both orders), and both range forms; capture the object-only baseline
8537        // (no graph file), then the graph-backed result, and require equality.
8538        remove_commit_graph(&git_dir);
8539        let baseline = collect_walk_results(&git_dir, format, &db, &all);
8540
8541        write_commit_graph_file(&git_dir, format, &db, &all);
8542        let with_graph = collect_walk_results(&git_dir, format, &db, &all);
8543
8544        assert_eq!(
8545            baseline, with_graph,
8546            "graph-backed walk diverged from object-only walk"
8547        );
8548        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8549    }
8550
8551    type WalkResult = (String, String, bool, Vec<String>, Vec<String>, Vec<String>);
8552
8553    /// Run is_ancestor, merge_bases (both orders), and the `A..B`/`A...B` ranges
8554    /// over all pairs, returning a deterministic snapshot for comparison.
8555    fn collect_walk_results(
8556        git_dir: &Path,
8557        format: ObjectFormat,
8558        reader: &impl ObjectReader,
8559        all: &[ObjectId],
8560    ) -> Vec<WalkResult> {
8561        let mut out = Vec::new();
8562        for left in all {
8563            for right in all {
8564                let anc = is_ancestor(git_dir, format, reader, left, right)
8565                    .expect("test operation should succeed");
8566                let mut bases: Vec<String> = merge_bases(git_dir, format, reader, left, right)
8567                    .expect("test operation should succeed")
8568                    .iter()
8569                    .map(|oid| oid.to_hex())
8570                    .collect();
8571                bases.sort();
8572                let asym = RevisionRange::Asymmetric {
8573                    start: left.to_hex(),
8574                    end: right.to_hex(),
8575                };
8576                let mut asym_set: Vec<String> =
8577                    resolve_revision_range(git_dir, format, reader, &asym)
8578                        .expect("test operation should succeed")
8579                        .iter()
8580                        .map(|oid| oid.to_hex())
8581                        .collect();
8582                asym_set.sort();
8583                let sym = RevisionRange::Symmetric {
8584                    left: left.to_hex(),
8585                    right: right.to_hex(),
8586                };
8587                let mut sym_set: Vec<String> =
8588                    resolve_revision_range(git_dir, format, reader, &sym)
8589                        .expect("test operation should succeed")
8590                        .iter()
8591                        .map(|oid| oid.to_hex())
8592                        .collect();
8593                sym_set.sort();
8594                out.push((left.to_hex(), right.to_hex(), anc, bases, asym_set, sym_set));
8595            }
8596        }
8597        out
8598    }
8599
8600    #[test]
8601    fn graph_backed_merge_base_handles_octopus_and_criss_cross() {
8602        let git_dir = temp_git_dir();
8603        let format = ObjectFormat::Sha1;
8604        let (db, all) = build_history(&git_dir, format);
8605        // Names in build order: [root,a,b,c,d,e,m1,f,g,oct,x1,x2].
8606        let (a, b) = (all[1].clone(), all[2].clone());
8607        let (m1, oct) = (all[6].clone(), all[9].clone());
8608        let (x1, x2) = (all[10].clone(), all[11].clone());
8609
8610        write_commit_graph_file(&git_dir, format, &db, &all);
8611
8612        // Criss-cross: x1 = merge(a,b), x2 = merge(b,a) -> two merge bases {a,b}.
8613        let mut xbases =
8614            merge_bases(&git_dir, format, &db, &x1, &x2).expect("test operation should succeed");
8615        xbases.sort_by_key(|oid| oid.to_hex());
8616        let mut expected = vec![a, b];
8617        expected.sort_by_key(|oid| oid.to_hex());
8618        assert_eq!(xbases, expected, "criss-cross must yield two merge bases");
8619
8620        // Octopus child reaches m1 along its first parent edge.
8621        assert!(
8622            is_ancestor(&git_dir, format, &db, &m1, &oct).expect("test operation should succeed")
8623        );
8624        // m1 is a merge base of itself and the octopus.
8625        assert_eq!(
8626            merge_bases(&git_dir, format, &db, &m1, &oct).expect("test operation should succeed"),
8627            vec![m1.clone()]
8628        );
8629        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8630    }
8631
8632    #[test]
8633    fn graph_backed_queries_avoid_object_reads() {
8634        let git_dir = temp_git_dir();
8635        let format = ObjectFormat::Sha1;
8636        let (db, all) = build_history(&git_dir, format);
8637        write_commit_graph_file(&git_dir, format, &db, &all);
8638        let (root, a, oct, x1, x2) = (
8639            all[0].clone(),
8640            all[1].clone(),
8641            all[9].clone(),
8642            all[10].clone(),
8643            all[11].clone(),
8644        );
8645
8646        // With a complete graph, ancestry/merge-base queries must be answerable
8647        // without ever reading a commit object: PanicReader errors on any read.
8648        assert!(
8649            is_ancestor(&git_dir, format, &PanicReader, &root, &oct)
8650                .expect("test operation should succeed")
8651        );
8652        assert!(
8653            !is_ancestor(&git_dir, format, &PanicReader, &oct, &root)
8654                .expect("test operation should succeed")
8655        );
8656        assert!(
8657            is_ancestor(&git_dir, format, &PanicReader, &a, &oct)
8658                .expect("test operation should succeed")
8659        );
8660
8661        let bases = merge_bases(&git_dir, format, &PanicReader, &x1, &x2)
8662            .expect("test operation should succeed");
8663        assert_eq!(bases.len(), 2, "criss-cross bases via graph only");
8664
8665        // Range resolution peels its two endpoints from the odb (the graph does
8666        // not record object types), but the ancestry *walk* between them is
8667        // graph-backed. Verify the result matches the object-only walk.
8668        let range = RevisionRange::Asymmetric {
8669            start: a.to_hex(),
8670            end: oct.to_hex(),
8671        };
8672        let mut included: Vec<String> = resolve_revision_range(&git_dir, format, &db, &range)
8673            .expect("test operation should succeed")
8674            .iter()
8675            .map(|oid| oid.to_hex())
8676            .collect();
8677        included.sort();
8678        assert!(included.contains(&oct.to_hex()));
8679        assert!(
8680            !included.contains(&root.to_hex()),
8681            "root is an ancestor of A, excluded"
8682        );
8683
8684        // Merge-base and range results via the graph still equal the object-only
8685        // walk for the same queries.
8686        remove_commit_graph(&git_dir);
8687        let object_bases =
8688            merge_bases(&git_dir, format, &db, &x1, &x2).expect("test operation should succeed");
8689        let mut object_range: Vec<String> = resolve_revision_range(&git_dir, format, &db, &range)
8690            .expect("test operation should succeed")
8691            .iter()
8692            .map(|oid| oid.to_hex())
8693            .collect();
8694        object_range.sort();
8695        write_commit_graph_file(&git_dir, format, &db, &all);
8696        let graph_bases = merge_bases(&git_dir, format, &PanicReader, &x1, &x2)
8697            .expect("test operation should succeed");
8698        assert_eq!(object_bases, graph_bases);
8699        assert_eq!(object_range, included, "range walk diverged with graph");
8700        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8701    }
8702
8703    #[test]
8704    fn graph_backed_parent_suffix_matches_object_walk() {
8705        let git_dir = temp_git_dir();
8706        let format = ObjectFormat::Sha1;
8707        let (db, all) = build_history(&git_dir, format);
8708        let oct = all[9].clone();
8709        let (m1, g, f) = (all[6].clone(), all[8].clone(), all[7].clone());
8710
8711        // Object-only baseline for the octopus merge's parent navigation.
8712        remove_commit_graph(&git_dir);
8713        let base_p1 = resolve_revision_with_reader(&git_dir, format, &db, &format!("{oct}^1"))
8714            .expect("test operation should succeed");
8715        let base_p2 = resolve_revision_with_reader(&git_dir, format, &db, &format!("{oct}^2"))
8716            .expect("test operation should succeed");
8717        let base_p3 = resolve_revision_with_reader(&git_dir, format, &db, &format!("{oct}^3"))
8718            .expect("test operation should succeed");
8719        let base_first = resolve_revision_with_reader(&git_dir, format, &db, &format!("{oct}~1"))
8720            .expect("test operation should succeed");
8721        assert_eq!((&base_p1, &base_p2, &base_p3), (&m1, &g, &f));
8722        assert_eq!(base_first, m1);
8723
8724        // With the graph present, the same suffixes resolve without object reads.
8725        write_commit_graph_file(&git_dir, format, &db, &all);
8726        assert_eq!(
8727            resolve_revision_with_reader(&git_dir, format, &PanicReader, &format!("{oct}^2"))
8728                .expect("test operation should succeed"),
8729            base_p2
8730        );
8731        assert_eq!(
8732            resolve_revision_with_reader(&git_dir, format, &PanicReader, &format!("{oct}~1"))
8733                .expect("test operation should succeed"),
8734            base_first
8735        );
8736        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8737    }
8738
8739    #[test]
8740    fn missing_or_unparseable_graph_falls_back_to_objects() {
8741        let git_dir = temp_git_dir();
8742        let format = ObjectFormat::Sha1;
8743        let (db, all) = build_history(&git_dir, format);
8744        let (a, oct) = (all[1].clone(), all[9].clone());
8745        let object_answer =
8746            is_ancestor(&git_dir, format, &db, &a, &oct).expect("test operation should succeed");
8747
8748        // A corrupt graph file must be ignored (not error), falling back to the
8749        // odb so the answer is unchanged.
8750        let info = git_dir.join("objects").join("info");
8751        fs::create_dir_all(&info).expect("test operation should succeed");
8752        fs::write(info.join("commit-graph"), b"not a real commit graph")
8753            .expect("test operation should succeed");
8754        assert_eq!(
8755            is_ancestor(&git_dir, format, &db, &a, &oct).expect("test operation should succeed"),
8756            object_answer
8757        );
8758        // A graph that omits some commits must also fall back per-missing-commit.
8759        write_commit_graph_file(&git_dir, format, &db, &all[..3]);
8760        assert_eq!(
8761            is_ancestor(&git_dir, format, &db, &a, &oct).expect("test operation should succeed"),
8762            object_answer
8763        );
8764        assert_eq!(
8765            merge_bases(&git_dir, format, &db, &all[10], &all[11])
8766                .expect("test operation should succeed"),
8767            {
8768                remove_commit_graph(&git_dir);
8769                merge_bases(&git_dir, format, &db, &all[10], &all[11])
8770                    .expect("test operation should succeed")
8771            }
8772        );
8773        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8774    }
8775
8776    #[test]
8777    fn commit_graph_chain_is_consulted() {
8778        let git_dir = temp_git_dir();
8779        let format = ObjectFormat::Sha1;
8780        // A short linear history whose single chain layer is self-contained
8781        // (no cross-layer parent edges), so the chain reader can resolve it.
8782        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
8783        let tree = db
8784            .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
8785            .expect("test operation should succeed");
8786        let root = write_dated_commit(&mut db, tree, vec![], b"root\n", 1000);
8787        let mid = write_dated_commit(&mut db, tree, vec![root], b"mid\n", 1001);
8788        let tip = write_dated_commit(&mut db, tree, vec![mid.clone()], b"tip\n", 1002);
8789        let commits = [root, mid.clone(), tip.clone()];
8790
8791        let parents_map: HashMap<ObjectId, Vec<ObjectId>> = commits
8792            .iter()
8793            .map(|oid| {
8794                (
8795                    *oid,
8796                    commit_parents(&db, format, oid).expect("test operation should succeed"),
8797                )
8798            })
8799            .collect();
8800        let generations = generation_numbers(&parents_map);
8801        let entries: Vec<sley_formats::CommitGraphWriteEntry> = commits
8802            .iter()
8803            .map(|oid| sley_formats::CommitGraphWriteEntry {
8804                oid: *oid,
8805                tree,
8806                parents: parents_map[oid].clone(),
8807                generation: generations[oid],
8808                commit_time: 0,
8809                bloom_filter: None,
8810            })
8811            .collect();
8812        let bytes = CommitGraph::write(format, &entries).expect("test operation should succeed");
8813
8814        // Lay the bytes out as a one-layer chain.
8815        let graphs = git_dir.join("objects").join("info").join("commit-graphs");
8816        fs::create_dir_all(&graphs).expect("test operation should succeed");
8817        let hash = sley_core::digest_bytes(format, &bytes)
8818            .expect("test operation should succeed")
8819            .to_hex();
8820        fs::write(graphs.join(format!("graph-{hash}.graph")), &bytes)
8821            .expect("test operation should succeed");
8822        fs::write(graphs.join("commit-graph-chain"), format!("{hash}\n"))
8823            .expect("test operation should succeed");
8824
8825        // No monolithic commit-graph present, only the chain: queries must be
8826        // answerable from the chain without reading objects.
8827        assert!(
8828            !git_dir
8829                .join("objects")
8830                .join("info")
8831                .join("commit-graph")
8832                .exists()
8833        );
8834        assert!(
8835            is_ancestor(&git_dir, format, &PanicReader, &root, &tip)
8836                .expect("test operation should succeed")
8837        );
8838        assert_eq!(
8839            merge_bases(&git_dir, format, &PanicReader, &mid, &tip)
8840                .expect("test operation should succeed"),
8841            vec![mid.clone()]
8842        );
8843
8844        // Linked worktrees keep commit-graphs in the common object directory,
8845        // not under the per-worktree gitdir. The graph fast path must find that
8846        // common location too, otherwise linked worktrees silently fall back to
8847        // packed commit reads.
8848        let linked = git_dir.join("worktrees").join("linked");
8849        fs::create_dir_all(&linked).expect("test operation should succeed");
8850        fs::write(linked.join("commondir"), "../..\n").expect("test operation should succeed");
8851        assert!(
8852            is_ancestor(&linked, format, &PanicReader, &root, &tip)
8853                .expect("test operation should succeed")
8854        );
8855        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8856    }
8857
8858    #[test]
8859    fn count_commit_metadata_uses_partial_direct_commit_graph() {
8860        let git_dir = temp_git_dir();
8861        let format = ObjectFormat::Sha1;
8862        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
8863        let commits = build_linear_history(&git_dir, 5);
8864        write_commit_graph_file(&git_dir, format, &db, &commits[..3]);
8865
8866        let reader = CountingReader::new(&db);
8867        let count = count_commit_metadata(&git_dir, format, &reader, [commits[4]], false)
8868            .expect("count should succeed");
8869        assert_eq!(count, 5);
8870        assert_eq!(
8871            reader.reads.get(),
8872            2,
8873            "only commits newer than the partial graph should be object-read"
8874        );
8875        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8876    }
8877
8878    #[test]
8879    fn commit_graph_tree_oid_returns_tree_without_object_read() {
8880        let git_dir = temp_git_dir();
8881        let format = ObjectFormat::Sha1;
8882        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
8883        let commits = build_linear_history(&git_dir, 3);
8884        write_commit_graph_file(&git_dir, format, &db, &commits);
8885
8886        for oid in &commits {
8887            let object = db.read_object(oid).expect("test operation should succeed");
8888            let commit =
8889                Commit::parse_ref(format, &object.body).expect("test operation should succeed");
8890            assert_eq!(
8891                commit_graph_tree_oid(&git_dir, format, oid)
8892                    .expect("test operation should succeed"),
8893                Some(commit.tree)
8894            );
8895        }
8896        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8897    }
8898
8899    fn test_commit_graph(format: ObjectFormat, parent: &ObjectId, child: &ObjectId) -> Vec<u8> {
8900        let tree = ObjectId::from_hex(format, "4b825dc642cb6eb9a060e54bf8d69288fbee4904")
8901            .expect("test operation should succeed");
8902        let mut oidf = vec![0u8; 256 * 4];
8903        let parent_first = parent.as_bytes()[0] as usize;
8904        let child_first = child.as_bytes()[0] as usize;
8905        for idx in 0..256 {
8906            let count = u32::from(idx >= parent_first) + u32::from(idx >= child_first);
8907            oidf[idx * 4..idx * 4 + 4].copy_from_slice(&count.to_be_bytes());
8908        }
8909        let mut oidl = Vec::new();
8910        oidl.extend_from_slice(parent.as_bytes());
8911        oidl.extend_from_slice(child.as_bytes());
8912        let mut cdat = Vec::new();
8913        cdat.extend_from_slice(&commit_graph_cdat_entry(
8914            &tree,
8915            0x7000_0000,
8916            0x7000_0000,
8917            1,
8918            1,
8919        ));
8920        cdat.extend_from_slice(&commit_graph_cdat_entry(&tree, 0, 0x7000_0000, 2, 2));
8921        commit_graph_file(
8922            format,
8923            &[(*b"OIDF", oidf), (*b"OIDL", oidl), (*b"CDAT", cdat)],
8924        )
8925    }
8926
8927    fn commit_graph_cdat_entry(
8928        tree: &ObjectId,
8929        parent_one: u32,
8930        parent_two: u32,
8931        generation: u32,
8932        commit_time: u64,
8933    ) -> Vec<u8> {
8934        let mut out = Vec::new();
8935        out.extend_from_slice(tree.as_bytes());
8936        out.extend_from_slice(&parent_one.to_be_bytes());
8937        out.extend_from_slice(&parent_two.to_be_bytes());
8938        let high = (generation << 2) | ((commit_time >> 32) as u32 & 0x3);
8939        out.extend_from_slice(&high.to_be_bytes());
8940        out.extend_from_slice(&(commit_time as u32).to_be_bytes());
8941        out
8942    }
8943
8944    fn commit_graph_file(format: ObjectFormat, chunks: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
8945        let lookup_len = (chunks.len() + 1) * 12;
8946        let mut out = Vec::new();
8947        out.extend_from_slice(b"CGPH");
8948        out.push(1);
8949        out.push(match format {
8950            ObjectFormat::Sha1 => 1,
8951            ObjectFormat::Sha256 => 2,
8952        });
8953        out.push(chunks.len() as u8);
8954        out.push(0);
8955        let mut offset = (8 + lookup_len) as u64;
8956        for (id, data) in chunks {
8957            out.extend_from_slice(id);
8958            out.extend_from_slice(&offset.to_be_bytes());
8959            offset += data.len() as u64;
8960        }
8961        out.extend_from_slice(&[0, 0, 0, 0]);
8962        out.extend_from_slice(&offset.to_be_bytes());
8963        for (_id, data) in chunks {
8964            out.extend_from_slice(data);
8965        }
8966        let checksum =
8967            sley_core::digest_bytes(format, &out).expect("test operation should succeed");
8968        out.extend_from_slice(checksum.as_bytes());
8969        out
8970    }
8971
8972    // --- RevWalk skeleton (STAGE-A) -------------------------------------
8973
8974    /// Build a linear chain c0 <- c1 <- ... with strictly increasing committer
8975    /// times, returning the oids oldest-first. The empty tree is reused.
8976    fn build_linear_history(git_dir: &std::path::Path, n: usize) -> Vec<ObjectId> {
8977        let mut db = FileObjectDatabase::from_git_dir(git_dir, ObjectFormat::Sha1);
8978        let tree = db
8979            .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
8980            .expect("write empty tree");
8981        let mut oids = Vec::new();
8982        let mut parents = Vec::new();
8983        for i in 0..n {
8984            let oid = write_dated_commit(
8985                &mut db,
8986                tree,
8987                parents.clone(),
8988                format!("c{i}\n").as_bytes(),
8989                100 + i as i64,
8990            );
8991            parents = vec![oid];
8992            oids.push(oid);
8993        }
8994        oids
8995    }
8996
8997    fn walk_oids<R: ObjectReader>(walk: RevWalk<'_, R>) -> Vec<ObjectId> {
8998        walk.collect_all()
8999            .expect("walk succeeds")
9000            .into_iter()
9001            .map(|m| m.oid)
9002            .collect()
9003    }
9004
9005    #[test]
9006    fn revwalk_commit_date_order_newest_first() {
9007        let git_dir = temp_git_dir();
9008        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9009        let oids = build_linear_history(&git_dir, 4); // oldest..newest
9010        let tip = *oids.last().expect("tip");
9011        let got = walk_oids(RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip]));
9012        let mut expected = oids.clone();
9013        expected.reverse(); // newest committer-date first
9014        assert_eq!(got, expected);
9015        fs::remove_dir_all(git_dir).expect("cleanup");
9016    }
9017
9018    #[test]
9019    fn revwalk_max_count_limits_output() {
9020        let git_dir = temp_git_dir();
9021        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9022        let oids = build_linear_history(&git_dir, 5);
9023        let tip = *oids.last().expect("tip");
9024        let got =
9025            walk_oids(RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip]).max_count(Some(2)));
9026        assert_eq!(got, vec![oids[4], oids[3]]);
9027        fs::remove_dir_all(git_dir).expect("cleanup");
9028    }
9029
9030    #[test]
9031    fn revwalk_skip_then_limit() {
9032        let git_dir = temp_git_dir();
9033        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9034        let oids = build_linear_history(&git_dir, 5);
9035        let tip = *oids.last().expect("tip");
9036        let got = walk_oids(
9037            RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip])
9038                .skip(1)
9039                .max_count(Some(2)),
9040        );
9041        // newest..oldest is c4,c3,c2,c1,c0; skip 1 -> c3,c2.
9042        assert_eq!(got, vec![oids[3], oids[2]]);
9043        fs::remove_dir_all(git_dir).expect("cleanup");
9044    }
9045
9046    #[test]
9047    fn revwalk_delegates_match_old_limited_walk() {
9048        // The thin-wrapper invariant: walk_commit_metadata_date_ordered_limited
9049        // (now RevWalk-backed) is byte-identical to a direct RevWalk in
9050        // CommitDate order with the same limit.
9051        let git_dir = temp_git_dir();
9052        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9053        let oids = build_linear_history(&git_dir, 6);
9054        let tip = *oids.last().expect("tip");
9055        let via_fn = walk_commit_metadata_date_ordered_limited(
9056            &git_dir,
9057            ObjectFormat::Sha1,
9058            &db,
9059            [tip],
9060            false,
9061            3,
9062        )
9063        .expect("limited walk")
9064        .into_iter()
9065        .map(|m| m.oid)
9066        .collect::<Vec<_>>();
9067        let via_walk = walk_oids(
9068            RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip])
9069                .order(RevWalkOrder::CommitDate)
9070                .max_count(Some(3)),
9071        );
9072        assert_eq!(via_fn, via_walk);
9073        fs::remove_dir_all(git_dir).expect("cleanup");
9074    }
9075
9076    #[test]
9077    fn revwalk_first_parent_follows_one_line() {
9078        let git_dir = temp_git_dir();
9079        let mut db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9080        let tree = db
9081            .write_object(EncodedObject::new(ObjectType::Tree, Vec::new()))
9082            .expect("tree");
9083        let base = write_dated_commit(&mut db, tree, vec![], b"base\n", 100);
9084        let side = write_dated_commit(&mut db, tree, vec![base], b"side\n", 110);
9085        let main = write_dated_commit(&mut db, tree, vec![base], b"main\n", 120);
9086        let merge = write_dated_commit(&mut db, tree, vec![main, side], b"merge\n", 130);
9087        let first_parent =
9088            walk_oids(RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [merge]).first_parent(true));
9089        // first-parent line: merge -> main -> base; `side` is skipped.
9090        assert_eq!(first_parent, vec![merge, main, base]);
9091        assert!(!first_parent.contains(&side));
9092        fs::remove_dir_all(git_dir).expect("cleanup");
9093    }
9094
9095    #[test]
9096    fn revwalk_date_window_filters_and_prunes() {
9097        let git_dir = temp_git_dir();
9098        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9099        let oids = build_linear_history(&git_dir, 5); // times 100..104
9100        let tip = *oids.last().expect("tip");
9101        // since=102 (>=102), until=103 (<=103) -> times 102,103 -> oids[3],oids[2].
9102        let got = walk_oids(
9103            RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip]).date_window(RevWalkDateWindow {
9104                min_time: Some(102),
9105                max_time: Some(103),
9106            }),
9107        );
9108        assert_eq!(got, vec![oids[3], oids[2]]);
9109        fs::remove_dir_all(git_dir).expect("cleanup");
9110    }
9111
9112    #[test]
9113    fn revwalk_pathspec_is_carried_but_not_pruning() {
9114        // STAGE-A: a pathspec is attached and round-trips, but does not yet
9115        // prune (TREESAME simplification is STAGE-B). The full history is still
9116        // returned.
9117        let git_dir = temp_git_dir();
9118        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
9119        let oids = build_linear_history(&git_dir, 3);
9120        let tip = *oids.last().expect("tip");
9121        let spec = Pathspec::parse(
9122            [b"does/not/exist".as_slice()],
9123            PathspecMatchMagic::default(),
9124        )
9125        .expect("pathspec");
9126        let walk = RevWalk::new(&git_dir, ObjectFormat::Sha1, &db, [tip]).pathspec(spec.clone());
9127        assert_eq!(walk.pathspec_ref(), &spec);
9128        let got = walk_oids(walk);
9129        assert_eq!(got.len(), 3, "pathspec must not prune in STAGE-A");
9130        fs::remove_dir_all(git_dir).expect("cleanup");
9131    }
9132}