Skip to main content

grit_lib/
rev_list.rs

1//! Commit traversal and output planning for `rev-list`.
2//!
3//! This module implements a focused `rev-list` subset used by the v2 test
4//! wave: revision ranges, `--all`, `--stdin` argument ingestion, commit walk
5//! limits, ordering (`--topo-order`, `--date-order`, `--reverse`), and basic
6//! output shaping (`--count`, `--parents`, `--format`).
7
8use std::cmp::{Ordering, Reverse};
9use std::collections::{BTreeSet, BinaryHeap, HashMap, HashSet, VecDeque};
10use std::fs;
11use std::io::Write;
12use std::path::Path;
13
14use crate::commit_graph_file::{BloomPrecheck, BloomWalkStatsHandle, CommitGraphChain};
15use crate::config::ConfigSet;
16use crate::diff::zero_oid;
17use crate::error::{Error, Result};
18use crate::ident::{committer_unix_seconds_for_ordering, parse_signature_times};
19use crate::ignore::{parse_sparse_patterns_from_blob, path_in_sparse_checkout};
20use crate::index::{CacheTreeNode, Index, MODE_GITLINK, MODE_TREE};
21use crate::objects::{parse_commit, parse_tag, parse_tree, ObjectId, ObjectKind};
22use crate::pack;
23use crate::patch_ids::{compute_patch_id, compute_patch_id_for_paths};
24use crate::ref_exclusions::{git_namespace_prefix, strip_git_namespace, RefExclusions};
25use crate::reflog::{list_reflog_refs, read_reflog};
26use crate::refs;
27use crate::repo::Repository;
28use crate::rev_parse::{resolve_revision_for_range_end, resolve_treeish_path, split_treeish_spec};
29
30/// User-facing output mode for `rev-list`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum OutputMode {
33    /// Print only object IDs.
34    OidOnly,
35    /// Print object ID followed by all parent IDs.
36    Parents,
37    /// Print a custom `%` placeholder format.
38    Format(String),
39}
40
41/// Behavior when reachable objects are missing.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MissingAction {
44    /// Fail traversal when a referenced object is missing.
45    Error,
46    /// Continue traversal and report each missing object.
47    Print,
48    /// Continue traversal and report missing objects with inferred metadata.
49    PrintInfo,
50    /// Continue traversal and silently ignore missing objects.
51    Allow,
52}
53
54impl MissingAction {
55    fn reports_missing(self) -> bool {
56        matches!(self, Self::Print | Self::PrintInfo)
57    }
58}
59
60/// Kind selector for `object:type=<kind>` filters.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum FilterObjectKind {
63    Blob,
64    Tree,
65    Commit,
66    Tag,
67}
68
69/// Object filter specification for `--filter=`.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ObjectFilter {
72    /// `blob:none` — omit all blobs.
73    BlobNone,
74    /// `blob:limit=<n>` — omit blobs larger than `n` bytes.
75    BlobLimit(u64),
76    /// `tree:<depth>` — omit trees deeper than `depth`.
77    TreeDepth(u64),
78    /// `sparse:oid=<rev>:<path>` or raw hex — sparse-checkout style path filter from a blob.
79    SparseOid(String),
80    /// `object:type=(blob|tree|commit|tag)` — keep only objects of that type.
81    ObjectType(FilterObjectKind),
82    /// `combine:<filter>+<filter>+…` — apply multiple filters.
83    Combine(Vec<ObjectFilter>),
84}
85
86impl ObjectFilter {
87    /// Parse a `--filter=<spec>` value.
88    pub fn parse(spec: &str) -> std::result::Result<Self, String> {
89        Self::parse_inner(spec.trim(), false)
90    }
91
92    fn parse_inner(spec: &str, from_combine_subfilter: bool) -> std::result::Result<Self, String> {
93        if spec == "blob:none" {
94            return Ok(ObjectFilter::BlobNone);
95        }
96        if let Some(rest) = spec.strip_prefix("blob:limit=") {
97            let bytes = parse_size_suffix(rest)
98                .ok_or_else(|| format!("invalid blob:limit value: {rest}"))?;
99            return Ok(ObjectFilter::BlobLimit(bytes));
100        }
101        if let Some(rest) = spec.strip_prefix("tree:") {
102            if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit()) {
103                return Err(if from_combine_subfilter {
104                    "expected 'tree:<depth>'.".to_owned()
105                } else {
106                    format!("invalid tree depth: {rest}")
107                });
108            }
109            let depth: u64 = rest.parse().map_err(|_| {
110                if from_combine_subfilter {
111                    "expected 'tree:<depth>'.".to_owned()
112                } else {
113                    format!("invalid tree depth: {rest}")
114                }
115            })?;
116            return Ok(ObjectFilter::TreeDepth(depth));
117        }
118        if let Some(rest) = spec.strip_prefix("object:type=") {
119            let kind = match rest {
120                "blob" => FilterObjectKind::Blob,
121                "tree" => FilterObjectKind::Tree,
122                "commit" => FilterObjectKind::Commit,
123                "tag" => FilterObjectKind::Tag,
124                "" => return Err("invalid object type".to_owned()),
125                _ => return Err(format!("invalid object type: {rest}")),
126            };
127            return Ok(ObjectFilter::ObjectType(kind));
128        }
129        if let Some(rest) = spec.strip_prefix("sparse:oid=") {
130            if rest.is_empty() {
131                return Err("invalid sparse:oid value: ".to_owned());
132            }
133            return Ok(ObjectFilter::SparseOid(rest.to_owned()));
134        }
135        if let Some(rest) = spec.strip_prefix("combine:") {
136            if rest.is_empty() {
137                return Err("expected something after combine:".to_owned());
138            }
139            let parts = split_combine_raw_parts(rest);
140            if parts.is_empty() {
141                return Err("expected something after combine:".to_owned());
142            }
143            let mut filters = Vec::new();
144            for part in parts {
145                filters.push(Self::parse_from_combine_subfilter(part)?);
146            }
147            return Ok(ObjectFilter::Combine(filters));
148        }
149        Err(format!("invalid filter-spec '{spec}'"))
150    }
151
152    fn parse_from_combine_subfilter(encoded: &str) -> std::result::Result<Self, String> {
153        if let Some(ch) = combine_subfilter_has_reserved(encoded) {
154            return Err(format!("must escape char in sub-filter-spec: '{ch}'"));
155        }
156        let decoded = url_decode(encoded);
157        Self::parse_inner(&decoded, true)
158    }
159
160    /// Merge another `--filter` argument (Git joins multiple filters with AND).
161    #[must_use]
162    pub fn merge_with(self, other: Self) -> Self {
163        match (self, other) {
164            (ObjectFilter::Combine(mut a), ObjectFilter::Combine(mut b)) => {
165                a.append(&mut b);
166                ObjectFilter::Combine(a)
167            }
168            (ObjectFilter::Combine(mut a), b) => {
169                a.push(b);
170                ObjectFilter::Combine(a)
171            }
172            (a, ObjectFilter::Combine(mut b)) => {
173                let mut v = vec![a];
174                v.append(&mut b);
175                ObjectFilter::Combine(v)
176            }
177            (a, b) => ObjectFilter::Combine(vec![a, b]),
178        }
179    }
180
181    /// Check if a blob should be included given its size.
182    pub fn includes_blob(&self, size: u64) -> bool {
183        match self {
184            ObjectFilter::BlobNone => false,
185            ObjectFilter::BlobLimit(limit) => size < *limit,
186            // Depth is applied via [`ObjectFilter::includes_blob_under_tree`]; this stays permissive
187            // for callers that only have a size (e.g. loose-object scans).
188            ObjectFilter::TreeDepth(_) => true,
189            ObjectFilter::SparseOid(_) => true,
190            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Blob,
191            ObjectFilter::Combine(filters) => filters.iter().all(|f| f.includes_blob(size)),
192        }
193    }
194
195    /// Whether a blob that lives directly under a tree at `parent_tree_depth` passes this filter.
196    ///
197    /// For `tree:<n>` filters, Git assigns blobs the traversal depth after entering the parent tree,
198    /// which matches `parent_tree_depth + 1` in our walk where the commit root tree is depth `0`.
199    #[must_use]
200    pub fn includes_blob_under_tree(&self, size: u64, parent_tree_depth: u64) -> bool {
201        match self {
202            ObjectFilter::BlobNone => false,
203            ObjectFilter::BlobLimit(limit) => size < *limit,
204            ObjectFilter::TreeDepth(max_depth) => parent_tree_depth.saturating_add(1) < *max_depth,
205            ObjectFilter::SparseOid(_) => true,
206            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Blob,
207            ObjectFilter::Combine(filters) => filters
208                .iter()
209                .all(|f| f.includes_blob_under_tree(size, parent_tree_depth)),
210        }
211    }
212
213    /// Check if a tree at given depth should be included.
214    pub fn includes_tree(&self, depth: u64) -> bool {
215        match self {
216            ObjectFilter::BlobNone => true,
217            ObjectFilter::BlobLimit(_) => true,
218            ObjectFilter::TreeDepth(max_depth) => depth < *max_depth,
219            ObjectFilter::SparseOid(_) => true,
220            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Tree,
221            ObjectFilter::Combine(filters) => filters.iter().all(|f| f.includes_tree(depth)),
222        }
223    }
224
225    /// Whether a commit or tag object should appear in a flat object scan (e.g. `cat-file --batch-all-objects`).
226    pub fn includes_commit_or_tag_object(&self, kind: ObjectKind) -> bool {
227        let expected = match kind {
228            ObjectKind::Commit => Some(FilterObjectKind::Commit),
229            ObjectKind::Tag => Some(FilterObjectKind::Tag),
230            _ => None,
231        };
232        match self {
233            ObjectFilter::BlobNone | ObjectFilter::BlobLimit(_) => true,
234            ObjectFilter::TreeDepth(_) => true,
235            ObjectFilter::SparseOid(_) => true,
236            ObjectFilter::ObjectType(t) => expected == Some(*t),
237            ObjectFilter::Combine(filters) => filters
238                .iter()
239                .all(|f| f.includes_commit_or_tag_object(kind)),
240        }
241    }
242
243    /// True if `kind` / `size` pass this filter when enumerating a single object (no tree path).
244    pub fn includes_loose_object(&self, kind: ObjectKind, size: u64) -> bool {
245        match kind {
246            ObjectKind::Blob => self.includes_blob(size),
247            ObjectKind::Tree => self.includes_tree(0),
248            ObjectKind::Commit | ObjectKind::Tag => self.includes_commit_or_tag_object(kind),
249        }
250    }
251
252    /// Whether an object passes this filter for direct OID lookup (`git cat-file --filter`).
253    #[must_use]
254    pub fn passes_for_object(&self, kind: ObjectKind, size: usize) -> bool {
255        self.includes_loose_object(kind, size as u64)
256    }
257}
258
259/// Reachable object IDs enumerated the same way as `git rev-list --objects --no-object-names --all`,
260/// optionally with `--filter` and `--filter-provided-objects` (used by `git cat-file --batch-all-objects`).
261#[must_use]
262pub fn reachable_object_ids_for_cat_file(
263    repo: &Repository,
264    filter: Option<&ObjectFilter>,
265    filter_provided_objects: bool,
266) -> Result<Vec<ObjectId>> {
267    let opts = RevListOptions {
268        all_refs: true,
269        objects: true,
270        no_object_names: true,
271        quiet: true,
272        filter: filter.cloned(),
273        filter_provided_objects,
274        ..Default::default()
275    };
276    let result = rev_list(repo, &[], &[], &opts)?;
277    let mut set = BTreeSet::new();
278    for (i, oid) in result.commits.iter().enumerate() {
279        if result.objects_print_commit.get(i).copied().unwrap_or(true) {
280            set.insert(*oid);
281        }
282    }
283    for (oid, _) in &result.objects {
284        set.insert(*oid);
285    }
286    Ok(set.into_iter().collect())
287}
288
289/// Objects matching `filter`, for `cat-file --batch-all-objects --filter` (same set as
290/// `rev-list --objects --all --filter --filter-provided-objects`).
291#[must_use]
292pub fn object_ids_for_cat_file_filtered(
293    repo: &Repository,
294    filter: &ObjectFilter,
295) -> Result<Vec<ObjectId>> {
296    reachable_object_ids_for_cat_file(repo, Some(filter), true)
297}
298
299/// Parse a size with optional k/m/g suffix.
300fn parse_size_suffix(s: &str) -> Option<u64> {
301    let s = s.trim();
302    if s.is_empty() {
303        return None;
304    }
305    let (num_str, multiplier) = match s.as_bytes().last()? {
306        b'k' | b'K' => (&s[..s.len() - 1], 1024u64),
307        b'm' | b'M' => (&s[..s.len() - 1], 1024 * 1024),
308        b'g' | b'G' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
309        _ => (s, 1u64),
310    };
311    let num: u64 = num_str.parse().ok()?;
312    Some(num * multiplier)
313}
314
315/// Raw substrings between `+` in a `combine:` value (still percent-encoded).
316fn split_combine_raw_parts(spec: &str) -> Vec<&str> {
317    spec.split('+').filter(|s| !s.is_empty()).collect()
318}
319
320/// Characters that must not appear raw inside a `combine:` sub-filter (matches Git
321/// `RESERVED_NON_WS` + whitespace; `%` is allowed because subspecs are percent-encoded).
322fn combine_subfilter_has_reserved(encoded: &str) -> Option<char> {
323    const RESERVED: &str = "~`!@#$^&*()[]{}\\;'\",<>?";
324    for ch in encoded.chars() {
325        if ch.is_control() || ch.is_whitespace() {
326            return Some(ch);
327        }
328        if RESERVED.contains(ch) {
329            return Some(ch);
330        }
331    }
332    None
333}
334
335/// Expand a user filter for protocol lines (`blob:limit=1k` → `blob:limit=1024`).
336#[must_use]
337pub fn expand_object_filter_for_protocol(spec: &str) -> std::result::Result<String, String> {
338    let f = ObjectFilter::parse(spec)?;
339    match f {
340        ObjectFilter::BlobLimit(n) => Ok(format!("blob:limit={n}")),
341        _ => Ok(spec.to_owned()),
342    }
343}
344
345fn combine_filter_allow_unencoded(ch: char) -> bool {
346    if ch.is_control() || ch.is_whitespace() || ch == '%' || ch == '+' {
347        return false;
348    }
349    !"~`!@#$^&*()[]{}\\;'\",<>?".contains(ch)
350}
351
352/// Append URL-encoded `raw` for Git `filter_spec` / trace (matches `allow_unencoded` in Git).
353pub fn url_encode_object_filter_subspec(raw: &str) -> String {
354    let mut out = String::new();
355    for b in raw.as_bytes() {
356        let ch = *b as char;
357        if combine_filter_allow_unencoded(ch) {
358            out.push(ch);
359        } else {
360            out.push_str(&format!("%{:02x}", b));
361        }
362    }
363    out
364}
365
366/// Emit `Add to combine filter-spec: …` when `GIT_TRACE` is enabled (Git `list-objects-filter-options.c`).
367pub fn trace_combine_filter_append(encoded_segment: &str) {
368    let Ok(trace_val) = std::env::var("GIT_TRACE") else {
369        return;
370    };
371    if trace_val.is_empty() || trace_val == "0" || trace_val.eq_ignore_ascii_case("false") {
372        return;
373    }
374    let line = format!("Add to combine filter-spec: {encoded_segment}\n");
375    match trace_val.as_str() {
376        "1" | "true" | "2" => {
377            let _ = std::io::stderr().write_all(line.as_bytes());
378        }
379        path => {
380            if let Ok(mut f) = std::fs::OpenOptions::new()
381                .create(true)
382                .append(true)
383                .open(path)
384            {
385                let _ = f.write_all(line.as_bytes());
386            }
387        }
388    }
389}
390
391/// Simple URL percent-decoding.
392fn url_decode(s: &str) -> String {
393    let mut result = String::new();
394    let mut chars = s.chars();
395    while let Some(ch) = chars.next() {
396        if ch == '%' {
397            let hi = chars.next().unwrap_or('0');
398            let lo = chars.next().unwrap_or('0');
399            let byte = u8::from_str_radix(&format!("{hi}{lo}"), 16).unwrap_or(b'?');
400            result.push(byte as char);
401        } else {
402            result.push(ch);
403        }
404    }
405    result
406}
407
408/// Ordering mode for commit output.
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410pub enum OrderingMode {
411    /// Reverse-chronological by committer date (default `rev-list` / `log` when no ordering flags).
412    Default,
413    /// Commit-date heap walk (`--date-order` without `--topo-order`; matches Git for `t6012`).
414    DateOrderWalk,
415    /// Author-date heap walk (`--author-date-order` without `--topo-order`).
416    AuthorDateWalk,
417    /// Topological walk with committer-date tie-breaks (`--topo-order`, `--simplify-merges`).
418    Topo,
419    /// Topological walk with author-date tie-breaks (`--topo-order --author-date-order`).
420    AuthorDateTopo,
421}
422
423/// Parsed and normalized options for rev-list traversal.
424#[derive(Debug, Clone)]
425pub struct RevListOptions {
426    /// Include all refs (`--all`) as positive tips.
427    pub all_refs: bool,
428    /// Follow only first parent when walking merges.
429    pub first_parent: bool,
430    /// Enable ancestry-path filtering.
431    pub ancestry_path: bool,
432    /// Optional explicit ancestry-path pivot commits.
433    pub ancestry_path_bottoms: Vec<ObjectId>,
434    /// Keep only decorated commits after traversal.
435    pub simplify_by_decoration: bool,
436    /// The set of decorated ("interesting") commit OIDs for `--simplify-by-decoration`.
437    /// When non-empty, proper history simplification (parent rewriting) is applied: a commit is
438    /// kept iff it is decorated, a relevant merge in the rewritten graph, or a root. When empty,
439    /// the legacy crude retain (decorated tips only) is used as a fallback. The caller (CLI)
440    /// computes this set honoring the display decoration filter (`--decorate-refs[-exclude]`,
441    /// `log.excludeDecoration`, and the default hidden-ref behavior), matching Git's
442    /// `load_ref_decorations(NULL, ...)` set used by `rev_compare_tree`.
443    pub simplify_by_decoration_oids: HashSet<ObjectId>,
444    /// Commit output mode.
445    pub output_mode: OutputMode,
446    /// Suppress commit output.
447    pub quiet: bool,
448    /// Print only final count.
449    pub count: bool,
450    /// Skip N commits from selected list.
451    pub skip: usize,
452    /// Optional maximum selected commits.
453    pub max_count: Option<usize>,
454    /// Ordering strategy.
455    pub ordering: OrderingMode,
456    /// Reverse selected output order.
457    pub reverse: bool,
458    /// Keep only commits not reachable from another selected commit (`--maximal-only`).
459    pub maximal_only: bool,
460    /// List reachable objects (trees, blobs) in addition to commits.
461    pub objects: bool,
462    /// Suppress object path names in --objects output.
463    pub no_object_names: bool,
464    /// Show boundary commits with `-` prefix.
465    pub boundary: bool,
466    /// Show left/right markers for symmetric diff.
467    pub left_right: bool,
468    /// True when `--left-right` was explicitly requested by the caller.
469    pub left_right_explicit: bool,
470    /// Filter to left-only commits in symmetric diff.
471    pub left_only: bool,
472    /// Filter to right-only commits in symmetric diff.
473    pub right_only: bool,
474    /// Cherry-mark equivalent commits with `=` instead of `+`.
475    pub cherry_mark: bool,
476    /// Cherry-pick: omit equivalent commits from output.
477    pub cherry_pick: bool,
478    /// Minimum number of parents a commit must have to be included.
479    pub min_parents: Option<usize>,
480    /// Maximum number of parents a commit may have to be included.
481    pub max_parents: Option<usize>,
482    /// Symmetric-diff left OID (set by caller when A...B is used).
483    pub symmetric_left: Option<ObjectId>,
484    /// Symmetric-diff right OID (set by caller when A...B is used).
485    pub symmetric_right: Option<ObjectId>,
486    /// Path filters (files after `--`).
487    pub paths: Vec<String>,
488    /// Show full history (don't simplify) for path-limited walks.
489    pub full_history: bool,
490    /// Parent rewriting was requested (`--parents`, `--children`, or `%P`/`%p` output).
491    pub parent_rewrite: bool,
492    /// Sparse mode: don't prune non-matching commits.
493    pub sparse: bool,
494    /// Further simplify history after path limiting (`--simplify-merges`).
495    pub simplify_merges: bool,
496    /// Preserve path-limited simplify-merge nodes long enough for `log --graph` lane rendering.
497    pub preserve_simplify_merges_graph_merges: bool,
498    /// Include "diverted" merge commits on the first-parent spine (`--show-pulls`).
499    pub show_pulls: bool,
500    /// When walking excluded commits, only follow the first parent (`--exclude-first-parent-only`).
501    pub exclude_first_parent_only: bool,
502    /// Object filter for `--filter=<spec>`.
503    pub filter: Option<ObjectFilter>,
504    /// Raw `--filter=` argument strings in order (for `GIT_TRACE` when Git combines multiple filters).
505    pub filter_raw_specs: Vec<String>,
506    /// When set with `--filter`, explicitly given revision objects are filtered too.
507    pub filter_provided_objects: bool,
508    /// Print omitted objects prefixed with `~`.
509    pub filter_print_omitted: bool,
510    /// Emit objects interleaved with their introducing commit.
511    pub in_commit_order: bool,
512    /// Exclude objects in `.keep` pack files.
513    pub no_kept_objects: bool,
514    /// Behavior when referenced objects are missing.
515    pub missing_action: MissingAction,
516    /// Stop traversal at objects promised by promisor packs.
517    pub exclude_promisor_objects: bool,
518    /// Ignore missing command-line revision/object arguments.
519    pub ignore_missing: bool,
520    /// When set with `--objects`, omit path names from non-commit object lines (bitmap-style output).
521    pub use_bitmap_index: bool,
522    /// When set with `--objects`, list only objects not present in any pack file.
523    pub unpacked_only: bool,
524    /// With `--use-bitmap-index`, emit OID-only object lines (no paths / trailing space) for filters
525    /// that match Git's bitmap object formatting.
526    pub bitmap_oid_only_objects: bool,
527    /// Reorder path-limited results for graph-friendly parent ordering (Git `log` / `rev-list`).
528    /// Internal dense passes for `--sparse` set this to `false` to avoid recursion.
529    pub path_graph_reorder: bool,
530    /// Exclude commits with committer date strictly after this Unix timestamp (`--until` / `--before`).
531    pub until_cutoff: Option<i64>,
532    /// Exclude commits with committer date strictly before this Unix timestamp (`--since` / `--after`).
533    pub since_cutoff: Option<i64>,
534    /// Include OIDs from all reflogs as extra commit tips (`git pack-objects --reflog`).
535    pub include_reflog_entries: bool,
536    /// Include index blobs and valid cache-tree nodes as object roots (`--indexed-objects`).
537    pub include_indexed_objects: bool,
538    /// Exclude index blobs and valid cache-tree nodes as object roots (`--not --indexed-objects`).
539    pub exclude_indexed_objects: bool,
540    /// When true with pathspecs, consult commit-graph Bloom filters (matches `core.commitGraph`).
541    pub use_commit_graph_bloom: bool,
542    /// `commitGraph.readChangedPaths` (default true).
543    pub commit_graph_read_changed_paths: bool,
544    /// `commitGraph.changedPathsVersion` (-1 = autodetect from graph).
545    pub commit_graph_changed_paths_version: i32,
546    /// Optional trace counters for `GIT_TRACE2_PERF` Bloom statistics.
547    pub bloom_stats: Option<BloomWalkStatsHandle>,
548}
549
550impl Default for RevListOptions {
551    fn default() -> Self {
552        Self {
553            all_refs: false,
554            first_parent: false,
555            ancestry_path: false,
556            ancestry_path_bottoms: Vec::new(),
557            simplify_by_decoration: false,
558            simplify_by_decoration_oids: HashSet::new(),
559            output_mode: OutputMode::OidOnly,
560            quiet: false,
561            count: false,
562            skip: 0,
563            max_count: None,
564            ordering: OrderingMode::Default,
565            reverse: false,
566            maximal_only: false,
567            objects: false,
568            no_object_names: false,
569            boundary: false,
570            left_right: false,
571            left_right_explicit: false,
572            left_only: false,
573            right_only: false,
574            cherry_mark: false,
575            cherry_pick: false,
576            min_parents: None,
577            max_parents: None,
578            symmetric_left: None,
579            symmetric_right: None,
580            paths: Vec::new(),
581            full_history: false,
582            parent_rewrite: false,
583            sparse: false,
584            simplify_merges: false,
585            preserve_simplify_merges_graph_merges: false,
586            show_pulls: false,
587            exclude_first_parent_only: false,
588            filter: None,
589            filter_raw_specs: Vec::new(),
590            filter_provided_objects: false,
591            filter_print_omitted: false,
592            in_commit_order: false,
593            no_kept_objects: false,
594            missing_action: MissingAction::Error,
595            exclude_promisor_objects: false,
596            ignore_missing: false,
597            use_bitmap_index: false,
598            unpacked_only: false,
599            bitmap_oid_only_objects: false,
600            path_graph_reorder: true,
601            until_cutoff: None,
602            since_cutoff: None,
603            include_reflog_entries: false,
604            include_indexed_objects: false,
605            exclude_indexed_objects: false,
606            use_commit_graph_bloom: false,
607            commit_graph_read_changed_paths: true,
608            commit_graph_changed_paths_version: -1,
609            bloom_stats: None,
610        }
611    }
612}
613
614/// Final commit selection result.
615#[derive(Debug, Clone)]
616pub struct RevListResult {
617    /// Selected commits in final output order, after skip/max/reverse.
618    pub commits: Vec<ObjectId>,
619    /// Reachable non-commit objects when `--objects` is active.
620    /// Each entry is `(oid, optional_path)`.
621    pub objects: Vec<(ObjectId, String)>,
622    /// Objects omitted by `--filter` (for `--filter-print-omitted`).
623    pub omitted_objects: Vec<ObjectId>,
624    /// Referenced objects missing from the object database.
625    pub missing_objects: Vec<ObjectId>,
626    /// Boundary commits (excluded parents shown with `-` prefix).
627    pub boundary_commits: Vec<ObjectId>,
628    /// For `--left-right`: mapping commit OID -> true=left, false=right.
629    pub left_right_map: HashMap<ObjectId, bool>,
630    /// For `--cherry-mark`: set of commits that are equivalent (patch-id match).
631    pub cherry_equivalent: HashSet<ObjectId>,
632    /// Per-commit object counts (parallel to `commits`) for `--in-commit-order`.
633    /// When non-empty, `objects[sum(counts[..i])..sum(counts[..=i])]` are the objects
634    /// introduced by `commits[i]`.
635    pub per_commit_object_counts: Vec<usize>,
636    /// Commit OIDs given as positive revision tips (for Git `USER_GIVEN` / filter edge cases).
637    pub object_walk_tips: Vec<ObjectId>,
638    /// When `--objects` is active, whether to print the commit line before that commit's objects.
639    /// Aligns with Git marking user-given tips vs `NOT_USER_GIVEN` commits in list-objects.
640    pub objects_print_commit: Vec<bool>,
641    /// When `--objects` is active and not `--in-commit-order`, objects grouped per commit walk plus
642    /// a final segment for explicit `object_roots` (length `commits.len() + 1`).
643    pub object_segments: Vec<Vec<(ObjectId, String)>>,
644    /// True when `--use-bitmap-index --objects` should format trees/blobs as bare OIDs (no paths).
645    pub bitmap_object_format: bool,
646    /// When a positive spec named a ref to an annotated tag of a commit, maps peeled commit → tag OID.
647    pub tip_annotated_tag_by_commit: HashMap<ObjectId, ObjectId>,
648}
649
650/// Per-commit bisection score for `rev-list --bisect*` output.
651#[derive(Debug, Clone, PartialEq, Eq)]
652pub struct BisectEntry {
653    /// Candidate commit object ID.
654    pub oid: ObjectId,
655    /// Number of candidate commits reachable from `oid`, including `oid`.
656    pub reaches: usize,
657    /// Git's bisection distance, `min(reaches, all - reaches)`.
658    pub distance: usize,
659}
660
661/// Bisection analysis result for a selected revision set.
662#[derive(Debug, Clone, PartialEq, Eq)]
663pub struct BisectSelection {
664    /// Best midpoint candidate, if the selected set is non-empty.
665    pub best: Option<BisectEntry>,
666    /// All candidates sorted as `--bisect-all` requires.
667    pub all: Vec<BisectEntry>,
668    /// Total number of commits in the bisection set.
669    pub total: usize,
670}
671
672/// Rank rev-list commits for Git-compatible bisection helpers.
673///
674/// # Parameters
675///
676/// - `repo` - repository used for parent lookup.
677/// - `commits` - selected commit set from a normal `rev-list` traversal.
678/// - `first_parent` - when true, follow only first parents while counting reachability.
679///
680/// # Returns
681///
682/// A [`BisectSelection`] containing the best midpoint plus the complete sorted candidate list.
683///
684/// # Errors
685///
686/// Returns repository/object errors when parent commits cannot be loaded.
687pub fn select_bisect_commits(
688    repo: &Repository,
689    commits: &[ObjectId],
690    first_parent: bool,
691) -> Result<BisectSelection> {
692    let total = commits.len();
693    if total == 0 {
694        return Ok(BisectSelection {
695            best: None,
696            all: Vec::new(),
697            total: 0,
698        });
699    }
700
701    let candidates: HashSet<ObjectId> = commits.iter().copied().collect();
702    let mut graph = CommitGraph::new(repo, first_parent);
703    let mut entries = Vec::with_capacity(commits.len());
704    for &oid in commits {
705        let reaches = count_reachable_candidates(&mut graph, oid, &candidates)?;
706        let distance = reaches.min(total.saturating_sub(reaches));
707        entries.push(BisectEntry {
708            oid,
709            reaches,
710            distance,
711        });
712    }
713
714    let mut sorted = entries.clone();
715    sorted.sort_by(|a, b| b.distance.cmp(&a.distance).then_with(|| a.oid.cmp(&b.oid)));
716
717    let mut best = None;
718    for entry in &entries {
719        if best
720            .as_ref()
721            .is_none_or(|current: &BisectEntry| entry.distance > current.distance)
722        {
723            best = Some(entry.clone());
724        }
725    }
726    Ok(BisectSelection {
727        best,
728        all: sorted,
729        total,
730    })
731}
732
733fn count_reachable_candidates(
734    graph: &mut CommitGraph<'_>,
735    oid: ObjectId,
736    candidates: &HashSet<ObjectId>,
737) -> Result<usize> {
738    let mut stack = vec![oid];
739    let mut seen = HashSet::new();
740    while let Some(current) = stack.pop() {
741        if !seen.insert(current) {
742            continue;
743        }
744        for parent in graph.parents_of(current)? {
745            if candidates.contains(&parent) {
746                stack.push(parent);
747            }
748        }
749    }
750    Ok(seen.len())
751}
752
753fn retain_maximal_commits(
754    graph: &mut CommitGraph<'_>,
755    candidates: &mut HashSet<ObjectId>,
756) -> Result<()> {
757    if candidates.len() < 2 {
758        return Ok(());
759    }
760
761    let candidate_list: Vec<ObjectId> = candidates.iter().copied().collect();
762    let candidate_set: HashSet<ObjectId> = candidate_list.iter().copied().collect();
763    let mut reachable_from_another = HashSet::new();
764
765    for tip in candidate_list {
766        for reachable in walk_closure(graph, &[tip])? {
767            if reachable != tip && candidate_set.contains(&reachable) {
768                reachable_from_another.insert(reachable);
769            }
770        }
771    }
772
773    candidates.retain(|oid| !reachable_from_another.contains(oid));
774    Ok(())
775}
776
777/// Resolve and walk revisions for the requested options.
778///
779/// # Parameters
780///
781/// - `repo` - repository used for ref/object lookup.
782/// - `positive_specs` - positive revision tokens (e.g. `HEAD`, `A..B` rhs).
783/// - `negative_specs` - negative revision tokens (`^A`, `A..B` lhs).
784/// - `options` - traversal and output selection options.
785///
786/// # Errors
787///
788/// Returns [`Error::ObjectNotFound`] / [`Error::InvalidRef`] for bad revision
789/// specs and [`Error::CorruptObject`] for non-commit or malformed commit data.
790pub fn rev_list(
791    repo: &Repository,
792    positive_specs: &[String],
793    negative_specs: &[String],
794    options: &RevListOptions,
795) -> Result<RevListResult> {
796    let mut graph = CommitGraph::new(repo, options.first_parent);
797
798    let (mut include, mut object_roots, tip_annotated_tag_by_commit) = if options.objects {
799        resolve_specs_for_objects_with_options(
800            repo,
801            positive_specs,
802            options.ignore_missing,
803            options.missing_action,
804        )?
805    } else {
806        (
807            resolve_specs_with_options(repo, positive_specs, options.ignore_missing)?,
808            Vec::new(),
809            HashMap::new(),
810        )
811    };
812    let (exclude, negative_object_roots) = if options.objects {
813        let (commits, roots, _) = resolve_specs_for_objects_with_options(
814            repo,
815            negative_specs,
816            options.ignore_missing,
817            options.missing_action,
818        )?;
819        (commits, roots)
820    } else {
821        (
822            resolve_specs_with_options(repo, negative_specs, options.ignore_missing)?,
823            Vec::new(),
824        )
825    };
826
827    if options.all_refs {
828        include.extend(all_ref_tips(repo, &RefExclusions::default())?);
829        // In `--objects` mode a ref may point at (or peel to) a blob or tree. Those refs do not
830        // appear among the commit tips above (`peel_ref_oids_to_unique_commits` drops them), but
831        // `git rev-list --all --objects` still lists the named object. Add them as object roots so
832        // that e.g. a lightweight tag pointing at a blob keeps that blob reachable (t5310).
833        if options.objects {
834            object_roots.extend(all_ref_non_commit_object_roots(repo)?);
835        }
836    }
837
838    if options.objects && options.include_reflog_entries {
839        include.extend(reflog_commit_tips(repo)?);
840    }
841
842    let index_object_roots = if options.objects
843        && (options.include_indexed_objects || options.exclude_indexed_objects)
844    {
845        indexed_object_roots(repo)?
846    } else {
847        Vec::new()
848    };
849
850    let object_roots = if !options.include_indexed_objects || index_object_roots.is_empty() {
851        object_roots
852    } else {
853        let mut merged = object_roots;
854        merged.extend(index_object_roots.iter().cloned());
855        merged
856    };
857    let negative_object_roots = if options.exclude_indexed_objects && !index_object_roots.is_empty()
858    {
859        let mut merged = negative_object_roots;
860        merged.extend(index_object_roots);
861        merged
862    } else {
863        negative_object_roots
864    };
865
866    if include.is_empty() && object_roots.is_empty() {
867        if options.ignore_missing {
868            return Ok(RevListResult {
869                commits: Vec::new(),
870                objects: Vec::new(),
871                omitted_objects: Vec::new(),
872                missing_objects: Vec::new(),
873                boundary_commits: Vec::new(),
874                left_right_map: HashMap::new(),
875                cherry_equivalent: HashSet::new(),
876                per_commit_object_counts: Vec::new(),
877                object_walk_tips: Vec::new(),
878                objects_print_commit: Vec::new(),
879                object_segments: Vec::new(),
880                bitmap_object_format: false,
881                tip_annotated_tag_by_commit: HashMap::new(),
882            });
883        }
884        return Err(Error::InvalidRef("no revisions specified".to_owned()));
885    }
886
887    let object_walk_tip_commits: Vec<ObjectId> = if options.objects {
888        include.clone()
889    } else {
890        Vec::new()
891    };
892
893    let excluded_promisor = if options.exclude_promisor_objects {
894        crate::promisor::promisor_expanded_object_ids(repo)?
895    } else {
896        HashSet::new()
897    };
898
899    let mut traversal_missing = Vec::new();
900    let mut traversal_missing_seen = HashSet::new();
901    let (mut included, discovery_order) = if include.is_empty() {
902        (HashSet::new(), Vec::new())
903    } else if options.exclude_promisor_objects {
904        walk_closure_ordered_excluding(&mut graph, &include, &excluded_promisor)?
905    } else if options.missing_action != MissingAction::Error {
906        walk_closure_ordered_with_missing(
907            &mut graph,
908            &include,
909            &HashSet::new(),
910            options.missing_action,
911            &mut traversal_missing,
912            &mut traversal_missing_seen,
913        )?
914    } else {
915        walk_closure_ordered(&mut graph, &include)?
916    };
917    let excluded = if exclude.is_empty() {
918        HashSet::new()
919    } else if options.exclude_promisor_objects {
920        walk_closure_ordered_excluding(&mut graph, &exclude, &excluded_promisor)?.0
921    } else if options.exclude_first_parent_only {
922        walk_closure_first_parent_only(&mut graph, &exclude)?
923    } else if options.missing_action != MissingAction::Error {
924        walk_closure_ordered_with_missing(
925            &mut graph,
926            &exclude,
927            &HashSet::new(),
928            options.missing_action,
929            &mut traversal_missing,
930            &mut traversal_missing_seen,
931        )?
932        .0
933    } else {
934        walk_closure(&mut graph, &exclude)?
935    };
936    included.retain(|oid| !excluded.contains(oid));
937
938    // Default dense path limiting is not just a post-walk filter: when a merge is TREESAME to a
939    // parent for the pathspec, Git follows only that parent and never walks the other sides.
940    // `--full-history`, `--ancestry-path`, and `--simplify-merges` intentionally keep the full
941    // parent walk and simplify later.
942    //
943    // The dense closure narrows `included` to the path-matching commits, but the user tips
944    // themselves may be dropped (they did not touch the path). Keep a copy of the full reachable
945    // set so date-order emission can still traverse *through* those dropped tips to reach matching
946    // ancestors — otherwise a `log -- <path>` whose only matching commit is an ancestor of HEAD
947    // would emit nothing (Git's `limit_list` walks the full graph and only filters emission).
948    // Compute the changed-path Bloom-filter configuration once up front so that both the dense
949    // path-limited walk (which performs the real TREESAME comparison) and any later re-check use
950    // the same chain. Git records Bloom statistics during its single `limit_list` walk, so the
951    // dense walk is the natural place to consult the filters and emit the `GIT_TRACE2_PERF`
952    // counters; doing it later (after the set is already reduced) would miss TREESAME-dropped
953    // commits like the latest layer's empty filters.
954    let (bloom_chain, bloom_read_changed, bloom_version, bloom_cwd) = if !options.paths.is_empty() {
955        let cfg = ConfigSet::load(Some(&repo.git_dir), true).unwrap_or_default();
956        let mut core_cg = match cfg.get_bool("core.commitgraph") {
957            Some(Ok(b)) => b,
958            _ => true,
959        };
960        if std::env::var("GIT_TEST_COMMIT_GRAPH").ok().as_deref() == Some("0") {
961            core_cg = false;
962        }
963        let read_paths = cfg
964            .get("commitgraph.readchangedpaths")
965            .and_then(|v| crate::config::parse_bool(&v).ok())
966            .unwrap_or(true);
967        let version = cfg
968            .get("commitgraph.changedpathsversion")
969            .and_then(|s| s.parse::<i32>().ok())
970            .unwrap_or(-1);
971        let use_bloom = core_cg
972            && options.use_commit_graph_bloom
973            && crate::pathspec::pathspecs_allow_bloom(&options.paths);
974        let read_changed = read_paths && options.commit_graph_read_changed_paths;
975        let chain = if use_bloom {
976            CommitGraphChain::load(&repo.git_dir.join("objects"))
977        } else {
978            None
979        };
980        let version = if options.commit_graph_changed_paths_version != -1 {
981            options.commit_graph_changed_paths_version
982        } else {
983            version
984        };
985        (chain, read_changed, version, repo.bloom_pathspec_cwd())
986    } else {
987        (None, false, -1, None)
988    };
989
990    let mut dense_path_limited = false;
991    let dense_traversable: HashSet<ObjectId> = if !options.paths.is_empty()
992        && !options.full_history
993        && !options.ancestry_path
994        && !options.simplify_merges
995    {
996        let traversable = included.clone();
997        // Consult the Bloom filter over the full reachable set *before* dense limiting prunes the
998        // traversal. Git loads a first-parent Bloom filter for every commit its `limit_list` walk
999        // reaches — including those simplified away by TREESAME merges — which both feeds the
1000        // `GIT_TRACE2_PERF` statistics and surfaces the reader's corrupt-graph integrity warnings.
1001        // Consulting only the pruned/selected set would undercount the statistics (e.g. side-branch
1002        // commits hidden by a TREESAME merge) and miss warnings for commits that don't touch the
1003        // path. This runs whenever a Bloom chain is present, independent of statistics collection.
1004        if bloom_chain.is_some() {
1005            let bloom_ctx = DenseBloomCtx {
1006                chain: bloom_chain.as_ref(),
1007                read_changed_paths: bloom_read_changed,
1008                changed_paths_version: bloom_version,
1009                stats: options.bloom_stats.as_ref(),
1010                cwd: bloom_cwd.as_deref(),
1011            };
1012            consult_dense_bloom_filters(
1013                repo,
1014                &mut graph,
1015                &traversable,
1016                &options.paths,
1017                &bloom_ctx,
1018            )?;
1019        }
1020        included = walk_dense_path_limited_closure(
1021            repo,
1022            &mut graph,
1023            &include,
1024            &excluded,
1025            &options.paths,
1026            options.sparse,
1027            options.show_pulls,
1028        )?;
1029        dense_path_limited = true;
1030        traversable
1031    } else {
1032        HashSet::new()
1033    };
1034
1035    if options.simplify_by_decoration && options.simplify_by_decoration_oids.is_empty() {
1036        // Legacy fallback when the caller did not supply the decorated set (e.g. plain
1037        // `rev-list --simplify-by-decoration` with no CLI-computed decoration filter): keep
1038        // commits pointed at by a ref tip. Proper parent-rewriting simplification (below,
1039        // after ordering) runs when `simplify_by_decoration_oids` is populated.
1040        let decorated = all_ref_tips(repo, &RefExclusions::default())?;
1041        included.retain(|oid| decorated.contains(oid));
1042    }
1043
1044    let mut effective_ancestry_path_bottoms = Vec::new();
1045    if options.ancestry_path {
1046        let mut bottoms = options.ancestry_path_bottoms.clone();
1047        if bottoms.is_empty() {
1048            bottoms.extend(exclude.iter().copied());
1049        }
1050        if bottoms.is_empty() {
1051            return Err(Error::InvalidRef(
1052                "--ancestry-path requires a range with excluded tips".to_owned(),
1053            ));
1054        }
1055        limit_to_ancestry(&mut graph, &mut included, &bottoms)?;
1056        effective_ancestry_path_bottoms = bottoms;
1057    }
1058
1059    // Git: `--ancestry-path` implies `--full-history` for path-limited walks.
1060    // `--simplify-merges` with pathspecs uses a full parent walk first, then simplifies merges.
1061    let path_effective_full = options.full_history
1062        || options.ancestry_path
1063        || (options.simplify_merges && !options.paths.is_empty());
1064
1065    // Filter by parent count (--merges, --no-merges, --min-parents, --max-parents). The dropped
1066    // commits must still be *traversable* (Git only suppresses them from output, never severs the
1067    // graph), so remember the full reachable set before narrowing `included` to the survivors.
1068    let parent_count_filter_active = options.min_parents.is_some() || options.max_parents.is_some();
1069    let traversable = if parent_count_filter_active {
1070        // The walk must be able to traverse *through* commits that path limiting already dropped
1071        // from `included` (e.g. a tip that does not touch the pathspec): Git never severs the graph
1072        // for path limiting, it only suppresses emission. When the dense path-limited closure ran it
1073        // pruned `included`, so seed the walk from the full reachable set (`dense_traversable`) it
1074        // saved beforehand; otherwise the no-merges walk would start at a dropped tip and emit nothing
1075        // (`range-diff <range> -- <path>` whose matching commits are all behind a non-matching tip).
1076        if dense_path_limited {
1077            dense_traversable.clone()
1078        } else {
1079            included.clone()
1080        }
1081    } else {
1082        HashSet::new()
1083    };
1084    if parent_count_filter_active {
1085        let min_p = options.min_parents.unwrap_or(0);
1086        let max_p = options.max_parents.unwrap_or(usize::MAX);
1087        included.retain(|oid| {
1088            let count = graph.parents_of(*oid).map(|p| p.len()).unwrap_or(0);
1089            count >= min_p && count <= max_p
1090        });
1091    }
1092
1093    if options.maximal_only {
1094        retain_maximal_commits(&mut graph, &mut included)?;
1095    }
1096
1097    let mut ordered = match options.ordering {
1098        OrderingMode::Default | OrderingMode::DateOrderWalk | OrderingMode::AuthorDateWalk => {
1099            let author_dates = options.ordering == OrderingMode::AuthorDateWalk;
1100            if parent_count_filter_active {
1101                // When parent-count filters (`--max-parents=1` / `--no-merges`, …) drop a commit
1102                // (e.g. a merge under `--no-merges`), Git still walks *through* it: the dropped
1103                // commit is simply not emitted, but its parents stay reachable. Seed from every
1104                // user-given tip and traverse the full reachable set, skipping dropped commits in
1105                // the output while continuing the walk past them (`format-patch -3` across a merge,
1106                // `rev-list --no-merges -N`).
1107                date_order_walk_through_dropped(
1108                    &mut graph,
1109                    &include,
1110                    &traversable,
1111                    &included,
1112                    author_dates,
1113                )?
1114            } else if dense_path_limited && !include.is_empty() {
1115                // Path-limiting may have dropped the user tips from `included`; traverse the full
1116                // reachable set (`dense_traversable`) so matching ancestors are still emitted in
1117                // date order, seeding from the user tips for Git's insertion-order tiebreak.
1118                date_order_walk_through_dropped(
1119                    &mut graph,
1120                    &include,
1121                    &dense_traversable,
1122                    &included,
1123                    author_dates,
1124                )?
1125            } else {
1126                date_order_walk(&mut graph, &included, &include, author_dates)?
1127            }
1128        }
1129        OrderingMode::Topo => graph_order_topo_sort(&mut graph, &included, &discovery_order)?,
1130        OrderingMode::AuthorDateTopo => topo_sort(&mut graph, &included, true)?,
1131    };
1132
1133    // Path filtering: keep only commits that modify given paths.
1134    if !options.paths.is_empty() {
1135        let paths = &options.paths;
1136        // The dense path-limited walk above already performed the first-parent Bloom precheck and
1137        // recorded its `GIT_TRACE2_PERF` statistics for every visited commit (matching Git's single
1138        // `limit_list` pass). Re-running the precheck here would double-count those counters, so
1139        // suppress stats in the dense case while still consulting the chain to filter the set.
1140        // For `--full-history`/`--simplify-merges`/`--ancestry-path`, the dense walk was skipped, so
1141        // this re-check is the only place the precheck runs and must record the statistics.
1142        let retain_stats = if dense_path_limited {
1143            None
1144        } else {
1145            options.bloom_stats.as_ref()
1146        };
1147        ordered.retain(|oid| {
1148            commit_touches_paths(
1149                repo,
1150                &mut graph,
1151                *oid,
1152                paths,
1153                path_effective_full,
1154                options.sparse,
1155                options.simplify_merges,
1156                options.preserve_simplify_merges_graph_merges,
1157                options.show_pulls,
1158                options.parent_rewrite,
1159                options.ancestry_path,
1160                &effective_ancestry_path_bottoms,
1161                &excluded,
1162                bloom_chain.as_ref(),
1163                bloom_read_changed,
1164                bloom_version,
1165                retain_stats,
1166                bloom_cwd.as_deref(),
1167            )
1168            .unwrap_or(false)
1169        });
1170    }
1171
1172    if !options.paths.is_empty() && options.simplify_merges && !ordered.is_empty() {
1173        ordered = simplify_merges_commit_list(
1174            repo,
1175            &ordered,
1176            &options.paths,
1177            &excluded,
1178            &effective_ancestry_path_bottoms,
1179            options.ordering,
1180            options.show_pulls,
1181            options.preserve_simplify_merges_graph_merges,
1182        )?;
1183    }
1184
1185    // Git-style path-limited parent reordering (dense history and `--sparse` only). Pure
1186    // `--full-history` walks keep rev-list order (`t6012` full-history path expectations).
1187    let path_needs_graph_reorder = !options.paths.is_empty()
1188        && options.path_graph_reorder
1189        && !options.simplify_merges
1190        && (!options.full_history || options.sparse);
1191    if path_needs_graph_reorder && !ordered.is_empty() {
1192        if options.sparse {
1193            let mut dense_opts = options.clone();
1194            dense_opts.sparse = false;
1195            dense_opts.path_graph_reorder = false;
1196            let dense_result = rev_list(repo, positive_specs, negative_specs, &dense_opts)?;
1197            let dense_ordered = reorder_path_limited_graph_commits(
1198                repo,
1199                &dense_result.commits,
1200                options.first_parent,
1201            )?;
1202            ordered = expand_sparse_path_limited_graph_history(repo, &dense_ordered)?;
1203        } else {
1204            ordered = reorder_path_limited_graph_commits(repo, &ordered, options.first_parent)?;
1205        }
1206    }
1207
1208    if matches!(
1209        options.ordering,
1210        OrderingMode::Topo | OrderingMode::AuthorDateTopo
1211    ) && !options.left_right
1212        && !options.left_only
1213        && !options.right_only
1214        && !options.cherry_mark
1215        && !options.cherry_pick
1216    {
1217        if let (Some(left_oid), Some(right_oid)) = (options.symmetric_left, options.symmetric_right)
1218        {
1219            reorder_symmetric_topo_right_first(&mut graph, &mut ordered, left_oid, right_oid)?;
1220        }
1221    }
1222
1223    // Left-right classification for symmetric diffs
1224    let mut left_right_map = HashMap::new();
1225    if options.left_right
1226        || options.left_only
1227        || options.right_only
1228        || options.cherry_mark
1229        || options.cherry_pick
1230    {
1231        if let (Some(left_oid), Some(right_oid)) = (options.symmetric_left, options.symmetric_right)
1232        {
1233            // Match Git's `SYMMETRIC_LEFT` / right-only classification (`revision.c`): a commit is
1234            // "left" iff it is reachable from the left tip but not from the right tip, and vice
1235            // versa.  Using plain set intersection incorrectly labels the shared spine as "right"
1236            // only, which breaks `--cherry-pick` on `A...B` (t3419-rebase-patch-id).
1237            let left_reach = walk_closure(&mut graph, &[left_oid])?;
1238            let right_reach = walk_closure(&mut graph, &[right_oid])?;
1239            for &oid in &ordered {
1240                let from_left = left_reach.contains(&oid);
1241                let from_right = right_reach.contains(&oid);
1242                left_right_map.insert(oid, from_left && !from_right);
1243            }
1244        }
1245    }
1246
1247    // Cherry-pick / cherry-mark: match commits by Git-compatible patch-id (see `git revision.c`
1248    // `cherry_pick_list`, used by `git rebase` todo generation).
1249    let mut cherry_equivalent = HashSet::new();
1250    if options.cherry_pick || options.cherry_mark {
1251        let left_commits: Vec<_> = ordered
1252            .iter()
1253            .filter(|o| left_right_map.get(o) == Some(&true))
1254            .copied()
1255            .collect();
1256        let right_commits: Vec<_> = ordered
1257            .iter()
1258            .filter(|o| left_right_map.get(o) == Some(&false))
1259            .copied()
1260            .collect();
1261        let left_first = !left_commits.is_empty()
1262            && !right_commits.is_empty()
1263            && left_commits.len() < right_commits.len();
1264
1265        let mut by_patch: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
1266        if left_first {
1267            for oid in &left_commits {
1268                if let Ok(Some(pid)) = compute_cherry_patch_id(repo, oid, &options.paths) {
1269                    by_patch.entry(pid).or_default().push(*oid);
1270                }
1271            }
1272            for oid in &right_commits {
1273                if let Ok(Some(pid)) = compute_cherry_patch_id(repo, oid, &options.paths) {
1274                    if let Some(others) = by_patch.get(&pid) {
1275                        cherry_equivalent.insert(*oid);
1276                        cherry_equivalent.extend(others.iter().copied());
1277                    }
1278                }
1279            }
1280        } else {
1281            for oid in &right_commits {
1282                if let Ok(Some(pid)) = compute_cherry_patch_id(repo, oid, &options.paths) {
1283                    by_patch.entry(pid).or_default().push(*oid);
1284                }
1285            }
1286            for oid in &left_commits {
1287                if let Ok(Some(pid)) = compute_cherry_patch_id(repo, oid, &options.paths) {
1288                    if let Some(others) = by_patch.get(&pid) {
1289                        cherry_equivalent.insert(*oid);
1290                        cherry_equivalent.extend(others.iter().copied());
1291                    }
1292                }
1293            }
1294        }
1295    }
1296
1297    // Filter left-only / right-only
1298    if options.left_only {
1299        ordered.retain(|oid| left_right_map.get(oid) == Some(&true));
1300    }
1301    if options.right_only {
1302        ordered.retain(|oid| left_right_map.get(oid) == Some(&false));
1303    }
1304
1305    // Cherry-pick: remove equivalent commits
1306    if options.cherry_pick {
1307        ordered.retain(|oid| !cherry_equivalent.contains(oid));
1308    }
1309
1310    if options.until_cutoff.is_some() || options.since_cutoff.is_some() {
1311        let until = options.until_cutoff;
1312        let since = options.since_cutoff;
1313        ordered.retain(|oid| {
1314            let ts = graph.committer_time(*oid);
1315            if let Some(u) = until {
1316                if ts > u {
1317                    return false;
1318                }
1319            }
1320            if let Some(s) = since {
1321                if ts < s {
1322                    return false;
1323                }
1324            }
1325            true
1326        });
1327    }
1328
1329    // `--simplify-by-decoration` with a caller-supplied decorated set: run proper
1330    // history simplification with parent rewriting (Git's `simplify_commit`). A commit is
1331    // kept iff it is decorated ("tree different" per `rev_compare_tree`), a root, or a
1332    // relevant merge (>= 2 distinct rewritten relevant parents). `ordered` is already in
1333    // the walk order (topo for default ordering), so we just retain the kept subset.
1334    if options.simplify_by_decoration && !options.simplify_by_decoration_oids.is_empty() {
1335        let keep = compute_simplify_by_decoration_keep_set(
1336            &mut graph,
1337            &ordered,
1338            &options.simplify_by_decoration_oids,
1339        )?;
1340        ordered.retain(|oid| keep.contains(oid));
1341    }
1342
1343    if options.skip > 0 {
1344        ordered = ordered.into_iter().skip(options.skip).collect();
1345    }
1346    if let Some(max_count) = options.max_count {
1347        ordered.truncate(max_count);
1348    }
1349    if options.reverse {
1350        ordered.reverse();
1351    }
1352
1353    // Collect boundary commits: parents of included commits that are in the excluded set
1354    let boundary_commits = if options.boundary {
1355        let included_set: HashSet<ObjectId> = ordered.iter().copied().collect();
1356        let mut boundary = Vec::new();
1357        let mut boundary_seen = HashSet::new();
1358        for &oid in &ordered {
1359            if let Ok(parents) = graph.parents_of(oid).map(|p| p.to_vec()) {
1360                for parent in parents {
1361                    if !included_set.contains(&parent) && boundary_seen.insert(parent) {
1362                        boundary.push(parent);
1363                    }
1364                }
1365            }
1366        }
1367        boundary
1368    } else {
1369        Vec::new()
1370    };
1371
1372    // Filter kept objects when --no-kept-objects is set
1373    let kept_set = if options.no_kept_objects {
1374        kept_object_ids(repo).unwrap_or_default()
1375    } else {
1376        HashSet::new()
1377    };
1378
1379    if options.no_kept_objects {
1380        ordered.retain(|oid| !kept_set.contains(oid));
1381    }
1382
1383    if options.unpacked_only {
1384        let packed = packed_object_set(repo);
1385        ordered.retain(|oid| !packed.contains(oid));
1386    }
1387
1388    let commit_tips_set: HashSet<ObjectId> = object_walk_tip_commits.iter().copied().collect();
1389    let objects_print_commit: Vec<bool> = if options.objects {
1390        ordered
1391            .iter()
1392            .map(|&c| {
1393                let user_given = !options.filter_provided_objects && commit_tips_set.contains(&c);
1394                object_walk_print_commit_line(
1395                    options.filter_provided_objects,
1396                    options.filter.as_ref(),
1397                    user_given,
1398                )
1399            })
1400            .collect()
1401    } else {
1402        Vec::new()
1403    };
1404
1405    let sparse_lines = sparse_oid_lines_from_filter(repo, options.filter.as_ref())?;
1406    let skip_trees = skip_tree_descent_for_object_type_filter(options.filter.as_ref());
1407    let walk_cfg = ConfigSet::load(Some(&repo.git_dir), true).unwrap_or_default();
1408    let promisor_repo = crate::promisor::repo_treats_promisor_packs(&repo.git_dir, &walk_cfg);
1409    let object_walk_missing_action = if options.objects
1410        && options.missing_action == MissingAction::Error
1411        && (promisor_repo || options.exclude_promisor_objects)
1412    {
1413        MissingAction::Allow
1414    } else {
1415        options.missing_action
1416    };
1417    // Git's bitmap object traversal emits OID-only object lines (no trailing pathname) and groups
1418    // objects by type, which is observably different from the normal name-walk output. When the
1419    // caller asks for `--use-bitmap-index --objects` and a pack/MIDX bitmap actually exists, mirror
1420    // that format so `test_bitmap_traversal` (t5310) sees the two outputs differ yet normalize equal.
1421    let bitmap_object_format = options.objects
1422        && options.use_bitmap_index
1423        && (options.bitmap_oid_only_objects
1424            || !object_roots.is_empty()
1425            || options.unpacked_only
1426            || (pack_bitmap_present(repo)
1427                && !filter_forces_bitmap_fallback(options.filter.as_ref())));
1428    let omit_object_paths = bitmap_object_format;
1429    // `--unpacked` selects unpacked commits for the revision walk. Git's object walk still emits
1430    // the full object closure for those commits, including tree/blob objects that already live in a
1431    // pack (t6000, t6113). Do not pass the packed set into tree traversal here.
1432    let packed_set = None;
1433
1434    // Git only enables provisional omit recursion (`omits` non-NULL) with `--filter-print-omitted`.
1435    let collect_tree_omits = options.filter_print_omitted;
1436
1437    // Collect reachable objects if --objects
1438    let (objects, omitted_objects, missing_objects, per_commit_object_counts, object_segments) =
1439        if options.objects {
1440            let excluded_object_ids = excluded_object_root_ids(
1441                repo,
1442                &negative_object_roots,
1443                object_walk_missing_action,
1444                sparse_lines.as_deref(),
1445                skip_trees,
1446                omit_object_paths,
1447                collect_tree_omits,
1448            )?;
1449            let filter_provided = options.filter_provided_objects;
1450            let (mut objs, mut omit, mut miss, mut counts, mut segments) =
1451                if options.in_commit_order {
1452                    let (o, om, mi, c) = collect_reachable_objects_in_commit_order(
1453                        repo,
1454                        &mut graph,
1455                        &ordered,
1456                        &object_roots,
1457                        &tip_annotated_tag_by_commit,
1458                        options.filter.as_ref(),
1459                        filter_provided,
1460                        object_walk_missing_action,
1461                        sparse_lines.as_deref(),
1462                        skip_trees,
1463                        omit_object_paths,
1464                        packed_set.as_ref(),
1465                        collect_tree_omits,
1466                    )?;
1467                    (o, om, mi, c, Vec::new())
1468                } else {
1469                    let (o, om, mi, seg) = collect_reachable_objects_segmented(
1470                        repo,
1471                        &mut graph,
1472                        &ordered,
1473                        &object_roots,
1474                        &tip_annotated_tag_by_commit,
1475                        options.filter.as_ref(),
1476                        filter_provided,
1477                        object_walk_missing_action,
1478                        sparse_lines.as_deref(),
1479                        skip_trees,
1480                        omit_object_paths,
1481                        packed_set.as_ref(),
1482                        collect_tree_omits,
1483                    )?;
1484                    (o, om, mi, Vec::new(), seg)
1485                };
1486            if !excluded_object_ids.is_empty() {
1487                if !counts.is_empty() {
1488                    let mut offset = 0usize;
1489                    for count in &mut counts {
1490                        let end = offset.saturating_add(*count);
1491                        *count = objs[offset..end]
1492                            .iter()
1493                            .filter(|(oid, _)| !excluded_object_ids.contains(oid))
1494                            .count();
1495                        offset = end;
1496                    }
1497                }
1498                objs.retain(|(oid, _)| !excluded_object_ids.contains(oid));
1499                omit.retain(|oid| !excluded_object_ids.contains(oid));
1500                miss.retain(|oid| !excluded_object_ids.contains(oid));
1501                for segment in &mut segments {
1502                    segment.retain(|(oid, _)| !excluded_object_ids.contains(oid));
1503                }
1504            }
1505            if options.no_kept_objects {
1506                objs.retain(|(oid, _)| !kept_set.contains(oid));
1507            }
1508            if options.exclude_promisor_objects {
1509                objs.retain(|(oid, _)| !excluded_promisor.contains(oid));
1510                for segment in &mut segments {
1511                    segment.retain(|(oid, _)| !excluded_promisor.contains(oid));
1512                }
1513            }
1514            if !options.paths.is_empty() && !omit_object_paths {
1515                retain_objects_matching_pathspecs(&mut objs, &options.paths);
1516                let mut seen_oids: HashSet<ObjectId> = objs.iter().map(|(oid, _)| *oid).collect();
1517                for (oid, path) in
1518                    collect_pathspec_matching_tree_objects(repo, &ordered, &options.paths)?
1519                {
1520                    if seen_oids.insert(oid) {
1521                        objs.push((oid, path));
1522                    }
1523                }
1524                for segment in &mut segments {
1525                    retain_objects_matching_pathspecs(segment, &options.paths);
1526                }
1527                if !counts.is_empty() {
1528                    counts = segments.iter().map(Vec::len).collect();
1529                }
1530            }
1531            (objs, omit, miss, counts, segments)
1532        } else {
1533            (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new())
1534        };
1535
1536    let omitted_objects = if omitted_objects.is_empty() {
1537        omitted_objects
1538    } else {
1539        let emitted: HashSet<ObjectId> = objects.iter().map(|(o, _)| *o).collect();
1540        omitted_objects
1541            .into_iter()
1542            .filter(|o| !emitted.contains(o))
1543            .collect::<BTreeSet<_>>()
1544            .into_iter()
1545            .collect()
1546    };
1547
1548    let missing_objects = if traversal_missing.is_empty() {
1549        missing_objects
1550    } else {
1551        let mut seen = HashSet::new();
1552        traversal_missing
1553            .into_iter()
1554            .chain(missing_objects)
1555            .filter(|oid| seen.insert(*oid))
1556            .collect()
1557    };
1558
1559    Ok(RevListResult {
1560        commits: ordered,
1561        objects,
1562        omitted_objects,
1563        missing_objects,
1564        boundary_commits,
1565        left_right_map,
1566        cherry_equivalent,
1567        per_commit_object_counts,
1568        object_walk_tips: object_walk_tip_commits,
1569        objects_print_commit,
1570        object_segments,
1571        bitmap_object_format,
1572        tip_annotated_tag_by_commit,
1573    })
1574}
1575
1576/// Parse a raw revision token into positive and negative specs.
1577///
1578/// Supports:
1579/// - `<a>..<b>` => negative `<a>`, positive `<b>`
1580/// - `^<rev>` => negative `<rev>`
1581/// - `<rev>` => positive `<rev>`
1582#[must_use]
1583pub fn split_revision_token(token: &str) -> (Vec<String>, Vec<String>) {
1584    if let Some((lhs, rhs)) = crate::rev_parse::split_double_dot_range(token) {
1585        let positive = if rhs.is_empty() {
1586            "HEAD".to_owned()
1587        } else {
1588            rhs.to_owned()
1589        };
1590        let negative = if lhs.is_empty() {
1591            "HEAD".to_owned()
1592        } else {
1593            lhs.to_owned()
1594        };
1595        return (vec![positive], vec![negative]);
1596    }
1597    if let Some(rest) = token.strip_prefix('^') {
1598        return (Vec::new(), vec![rest.to_owned()]);
1599    }
1600    (vec![token.to_owned()], Vec::new())
1601}
1602
1603fn ansi_color_from_name(name: &str) -> String {
1604    match name {
1605        "red" => "\x1b[31m".to_owned(),
1606        "green" => "\x1b[32m".to_owned(),
1607        "yellow" => "\x1b[33m".to_owned(),
1608        "blue" => "\x1b[34m".to_owned(),
1609        "magenta" => "\x1b[35m".to_owned(),
1610        "cyan" => "\x1b[36m".to_owned(),
1611        "white" => "\x1b[37m".to_owned(),
1612        "bold" => "\x1b[1m".to_owned(),
1613        "dim" => "\x1b[2m".to_owned(),
1614        "ul" | "underline" => "\x1b[4m".to_owned(),
1615        "blink" => "\x1b[5m".to_owned(),
1616        "reverse" => "\x1b[7m".to_owned(),
1617        "reset" => "\x1b[m".to_owned(),
1618        _ => String::new(),
1619    }
1620}
1621
1622fn color_name_to_code(name: &str) -> Option<u8> {
1623    match name {
1624        "black" => Some(0),
1625        "red" => Some(1),
1626        "green" => Some(2),
1627        "yellow" => Some(3),
1628        "blue" => Some(4),
1629        "magenta" => Some(5),
1630        "cyan" => Some(6),
1631        "white" => Some(7),
1632        "default" => Some(9),
1633        _ => None,
1634    }
1635}
1636
1637fn ansi_color_from_spec(spec: &str) -> String {
1638    if spec == "reset" {
1639        return "\x1b[m".to_owned();
1640    }
1641    let mut attrs = Vec::new();
1642    let mut fg = None;
1643    let mut bg = None;
1644    for part in spec.split_whitespace() {
1645        match part {
1646            "bold" => attrs.push("1".to_owned()),
1647            "dim" => attrs.push("2".to_owned()),
1648            "italic" => attrs.push("3".to_owned()),
1649            "ul" | "underline" => attrs.push("4".to_owned()),
1650            "blink" => attrs.push("5".to_owned()),
1651            "reverse" => attrs.push("7".to_owned()),
1652            "strike" => attrs.push("9".to_owned()),
1653            "nobold" | "nodim" => attrs.push("22".to_owned()),
1654            "noitalic" => attrs.push("23".to_owned()),
1655            "noul" | "nounderline" => attrs.push("24".to_owned()),
1656            "noblink" => attrs.push("25".to_owned()),
1657            "noreverse" => attrs.push("27".to_owned()),
1658            "nostrike" => attrs.push("29".to_owned()),
1659            _ => {
1660                if let Some(code) = color_name_to_code(part) {
1661                    if fg.is_none() {
1662                        fg = Some(format!("{}", 30 + code));
1663                    } else {
1664                        bg = Some(format!("{}", 40 + code));
1665                    }
1666                }
1667            }
1668        }
1669    }
1670    let mut codes = attrs;
1671    if let Some(code) = fg {
1672        codes.push(code);
1673    }
1674    if let Some(code) = bg {
1675        codes.push(code);
1676    }
1677    if codes.is_empty() {
1678        String::new()
1679    } else {
1680        format!("\x1b[{}m", codes.join(";"))
1681    }
1682}
1683
1684fn format_relative_date(diff: i64) -> String {
1685    if diff < 0 {
1686        "in the future".to_owned()
1687    } else if diff < 60 {
1688        format!("{} seconds ago", diff)
1689    } else if diff < 3600 {
1690        let m = diff / 60;
1691        if m == 1 {
1692            "1 minute ago".to_owned()
1693        } else {
1694            format!("{m} minutes ago")
1695        }
1696    } else if diff < 86400 {
1697        let h = diff / 3600;
1698        if h == 1 {
1699            "1 hour ago".to_owned()
1700        } else {
1701            format!("{h} hours ago")
1702        }
1703    } else if diff < 86400 * 30 {
1704        let d = diff / 86400;
1705        if d == 1 {
1706            "1 day ago".to_owned()
1707        } else {
1708            format!("{d} days ago")
1709        }
1710    } else if diff < 86400 * 365 {
1711        let months = diff / (86400 * 30);
1712        if months == 1 {
1713            "1 month ago".to_owned()
1714        } else {
1715            format!("{months} months ago")
1716        }
1717    } else {
1718        let years = diff / (86400 * 365);
1719        if years == 1 {
1720            "1 year ago".to_owned()
1721        } else {
1722            format!("{years} years ago")
1723        }
1724    }
1725}
1726
1727/// Render one commit according to the selected output mode.
1728///
1729/// # Errors
1730///
1731/// Returns object decode errors when commit metadata is required.
1732pub fn render_commit(
1733    repo: &Repository,
1734    oid: ObjectId,
1735    mode: &OutputMode,
1736    abbrev_len: usize,
1737) -> Result<String> {
1738    render_commit_with_color(repo, oid, mode, abbrev_len, false)
1739}
1740
1741/// Render one commit, optionally with ANSI color for `%C` placeholders.
1742pub fn render_commit_with_color(
1743    repo: &Repository,
1744    oid: ObjectId,
1745    mode: &OutputMode,
1746    abbrev_len: usize,
1747    use_color: bool,
1748) -> Result<String> {
1749    match mode {
1750        OutputMode::OidOnly => Ok(format!("{oid}")),
1751        OutputMode::Parents => {
1752            let mut out = format!("{oid}");
1753            let commit = load_commit(repo, oid)?;
1754            for parent in commit.parents {
1755                out.push(' ');
1756                out.push_str(&parent.to_hex());
1757            }
1758            Ok(out)
1759        }
1760        OutputMode::Format(fmt) => {
1761            let commit = load_commit(repo, oid)?;
1762            let subject = commit.message.lines().next().unwrap_or_default();
1763            let hex = oid.to_hex();
1764
1765            // Handle named pretty formats
1766            match fmt.as_str() {
1767                "oneline" => {
1768                    return Ok(format!("{} {}", hex, subject));
1769                }
1770                "short" => {
1771                    fn fmt_ident(ident: &str) -> String {
1772                        let name = if let Some(bracket) = ident.find('<') {
1773                            ident[..bracket].trim()
1774                        } else {
1775                            ident.trim()
1776                        };
1777                        let email = if let Some(start) = ident.find('<') {
1778                            if let Some(end) = ident.find('>') {
1779                                &ident[start..=end]
1780                            } else {
1781                                ""
1782                            }
1783                        } else {
1784                            ""
1785                        };
1786                        format!("{} {}", name, email)
1787                    }
1788                    let mut out = String::new();
1789                    out.push_str(&format!("Author: {}\n", fmt_ident(&commit.author)));
1790                    out.push('\n');
1791                    out.push_str(&format!("    {}\n", subject));
1792                    out.push('\n');
1793                    return Ok(out);
1794                }
1795                "medium" => {
1796                    fn extract_ident_display(ident: &str) -> String {
1797                        let name = if let Some(bracket) = ident.find('<') {
1798                            ident[..bracket].trim()
1799                        } else {
1800                            ident.trim()
1801                        };
1802                        let email = if let Some(start) = ident.find('<') {
1803                            if let Some(end) = ident.find('>') {
1804                                &ident[start..=end]
1805                            } else {
1806                                ""
1807                            }
1808                        } else {
1809                            ""
1810                        };
1811                        format!("{} {}", name, email)
1812                    }
1813                    fn format_default_date(ident: &str) -> String {
1814                        let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
1815                        if parts.len() < 2 {
1816                            return String::new();
1817                        }
1818                        let ts_str = parts[1];
1819                        let offset_str = parts[0];
1820                        let ts: i64 = match ts_str.parse() {
1821                            Ok(v) => v,
1822                            Err(_) => return format!("{ts_str} {offset_str}"),
1823                        };
1824                        let tz_bytes = offset_str.as_bytes();
1825                        let tz_secs: i64 = if tz_bytes.len() >= 5 {
1826                            let sign = if tz_bytes[0] == b'-' { -1i64 } else { 1i64 };
1827                            let h: i64 = offset_str[1..3].parse().unwrap_or(0);
1828                            let m: i64 = offset_str[3..5].parse().unwrap_or(0);
1829                            sign * (h * 3600 + m * 60)
1830                        } else {
1831                            0
1832                        };
1833                        let adjusted = ts + tz_secs;
1834                        let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1835                            .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1836                        let weekday = match dt.weekday() {
1837                            time::Weekday::Monday => "Mon",
1838                            time::Weekday::Tuesday => "Tue",
1839                            time::Weekday::Wednesday => "Wed",
1840                            time::Weekday::Thursday => "Thu",
1841                            time::Weekday::Friday => "Fri",
1842                            time::Weekday::Saturday => "Sat",
1843                            time::Weekday::Sunday => "Sun",
1844                        };
1845                        let month = match dt.month() {
1846                            time::Month::January => "Jan",
1847                            time::Month::February => "Feb",
1848                            time::Month::March => "Mar",
1849                            time::Month::April => "Apr",
1850                            time::Month::May => "May",
1851                            time::Month::June => "Jun",
1852                            time::Month::July => "Jul",
1853                            time::Month::August => "Aug",
1854                            time::Month::September => "Sep",
1855                            time::Month::October => "Oct",
1856                            time::Month::November => "Nov",
1857                            time::Month::December => "Dec",
1858                        };
1859                        format!(
1860                            "{} {} {} {:02}:{:02}:{:02} {} {}",
1861                            weekday,
1862                            month,
1863                            dt.day(),
1864                            dt.hour(),
1865                            dt.minute(),
1866                            dt.second(),
1867                            dt.year(),
1868                            offset_str
1869                        )
1870                    }
1871                    let mut out = String::new();
1872                    out.push_str(&format!(
1873                        "Author: {}\n",
1874                        extract_ident_display(&commit.author)
1875                    ));
1876                    out.push_str(&format!(
1877                        "Date:   {}\n",
1878                        format_default_date(&commit.author)
1879                    ));
1880                    out.push('\n');
1881                    for line in commit.message.lines() {
1882                        out.push_str(&format!("    {}\n", line));
1883                    }
1884                    return Ok(out);
1885                }
1886                _ => {}
1887            }
1888
1889            let raw_fmt = if let Some(t) = fmt.strip_prefix("format:") {
1890                t
1891            } else if let Some(t) = fmt.strip_prefix("tformat:") {
1892                t
1893            } else {
1894                fmt.as_str()
1895            };
1896
1897            // Verify the commit signature only when a `%G` placeholder is present
1898            // in the format (matches git's lazy `%G?`/`%GS` evaluation).
1899            let signature: Option<crate::signing::SignatureCheck> = if raw_fmt.contains("%G") {
1900                repo.odb.read(&oid).ok().map(|obj| {
1901                    let config = ConfigSet::load(Some(&repo.git_dir), true).unwrap_or_default();
1902                    match crate::signing::GpgConfig::from_config(&config) {
1903                        Ok(cfg) => crate::signing::verify_commit(&cfg, &obj.data)
1904                            .unwrap_or_else(|_| crate::signing::SignatureCheck::default_none()),
1905                        Err(_) => crate::signing::SignatureCheck::default_none(),
1906                    }
1907                })
1908            } else {
1909                None
1910            };
1911            // Body: everything after the first line (skip blank separator line)
1912            let body = {
1913                let mut lines = commit.message.lines();
1914                lines.next(); // skip subject
1915                              // Skip optional blank line after subject
1916                if let Some(blank) = lines.next() {
1917                    if blank.is_empty() {
1918                        lines.collect::<Vec<_>>().join("\n")
1919                    } else {
1920                        std::iter::once(blank)
1921                            .chain(lines)
1922                            .collect::<Vec<_>>()
1923                            .join("\n")
1924                    }
1925                } else {
1926                    String::new()
1927                }
1928            };
1929            let tree_hex = commit.tree.to_hex();
1930            let parent_hexes: Vec<String> = commit.parents.iter().map(|p| p.to_hex()).collect();
1931            let parent_abbrevs: Vec<String> = commit
1932                .parents
1933                .iter()
1934                .map(|p| {
1935                    let hex = p.to_hex();
1936                    let n = abbrev_len.clamp(4, 40).min(hex.len());
1937                    hex[..n].to_string()
1938                })
1939                .collect();
1940
1941            // Extract name/email components from ident strings
1942            fn extract_name(ident: &str) -> &str {
1943                if let Some(bracket) = ident.find('<') {
1944                    ident[..bracket].trim()
1945                } else {
1946                    ident.trim()
1947                }
1948            }
1949            fn extract_email(ident: &str) -> &str {
1950                if let Some(start) = ident.find('<') {
1951                    if let Some(end) = ident.find('>') {
1952                        return &ident[start + 1..end];
1953                    }
1954                }
1955                ""
1956            }
1957            fn extract_timestamp(ident: &str) -> String {
1958                match parse_signature_times(ident) {
1959                    Some(p) => p.unix_seconds.to_string(),
1960                    None => String::new(),
1961                }
1962            }
1963            fn weekday_str(dt: &time::OffsetDateTime) -> &'static str {
1964                match dt.weekday() {
1965                    time::Weekday::Monday => "Mon",
1966                    time::Weekday::Tuesday => "Tue",
1967                    time::Weekday::Wednesday => "Wed",
1968                    time::Weekday::Thursday => "Thu",
1969                    time::Weekday::Friday => "Fri",
1970                    time::Weekday::Saturday => "Sat",
1971                    time::Weekday::Sunday => "Sun",
1972                }
1973            }
1974            fn month_str(dt: &time::OffsetDateTime) -> &'static str {
1975                match dt.month() {
1976                    time::Month::January => "Jan",
1977                    time::Month::February => "Feb",
1978                    time::Month::March => "Mar",
1979                    time::Month::April => "Apr",
1980                    time::Month::May => "May",
1981                    time::Month::June => "Jun",
1982                    time::Month::July => "Jul",
1983                    time::Month::August => "Aug",
1984                    time::Month::September => "Sep",
1985                    time::Month::October => "Oct",
1986                    time::Month::November => "Nov",
1987                    time::Month::December => "Dec",
1988                }
1989            }
1990            fn extract_email_local(ident: &str) -> &str {
1991                let email = extract_email(ident);
1992                if let Some(at) = email.find('@') {
1993                    &email[..at]
1994                } else {
1995                    email
1996                }
1997            }
1998            fn extract_date_default(ident: &str) -> String {
1999                let Some(p) = parse_signature_times(ident) else {
2000                    return String::new();
2001                };
2002                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
2003                let adjusted = p.unix_seconds + p.tz_offset_secs;
2004                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
2005                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
2006                format!(
2007                    "{} {} {} {:02}:{:02}:{:02} {} {}",
2008                    weekday_str(&dt),
2009                    month_str(&dt),
2010                    dt.day(),
2011                    dt.hour(),
2012                    dt.minute(),
2013                    dt.second(),
2014                    dt.year(),
2015                    offset_str
2016                )
2017            }
2018            fn extract_date_rfc2822(ident: &str) -> String {
2019                let Some(p) = parse_signature_times(ident) else {
2020                    return String::new();
2021                };
2022                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
2023                let adjusted = p.unix_seconds + p.tz_offset_secs;
2024                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
2025                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
2026                format!(
2027                    "{}, {} {} {} {:02}:{:02}:{:02} {}",
2028                    weekday_str(&dt),
2029                    dt.day(),
2030                    month_str(&dt),
2031                    dt.year(),
2032                    dt.hour(),
2033                    dt.minute(),
2034                    dt.second(),
2035                    offset_str
2036                )
2037            }
2038            fn extract_date_short(ident: &str) -> String {
2039                let Some(p) = parse_signature_times(ident) else {
2040                    return String::new();
2041                };
2042                let adjusted = p.unix_seconds + p.tz_offset_secs;
2043                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
2044                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
2045                format!("{:04}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day())
2046            }
2047            fn extract_date_iso(ident: &str) -> String {
2048                let Some(p) = parse_signature_times(ident) else {
2049                    return String::new();
2050                };
2051                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
2052                let adjusted = p.unix_seconds + p.tz_offset_secs;
2053                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
2054                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
2055                format!(
2056                    "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {}",
2057                    dt.year(),
2058                    dt.month() as u8,
2059                    dt.day(),
2060                    dt.hour(),
2061                    dt.minute(),
2062                    dt.second(),
2063                    offset_str
2064                )
2065            }
2066
2067            // Alignment/truncation state for %<(N), %>(N), %><(N) directives
2068            #[derive(Clone, Copy)]
2069            enum Align {
2070                Left,
2071                Right,
2072                Center,
2073            }
2074            #[derive(Clone, Copy)]
2075            enum Trunc {
2076                None,
2077                Trunc,
2078                LTrunc,
2079                MTrunc,
2080            }
2081            struct ColSpec {
2082                width: usize,
2083                align: Align,
2084                trunc: Trunc,
2085            }
2086            fn apply_col(spec: &ColSpec, s: &str) -> String {
2087                let char_len = s.chars().count();
2088                if char_len > spec.width {
2089                    match spec.trunc {
2090                        Trunc::None => s.to_owned(),
2091                        Trunc::Trunc => {
2092                            let mut out: String =
2093                                s.chars().take(spec.width.saturating_sub(2)).collect();
2094                            out.push_str("..");
2095                            out
2096                        }
2097                        Trunc::LTrunc => {
2098                            let skip = char_len - spec.width + 2;
2099                            let mut out = String::from("..");
2100                            out.extend(s.chars().skip(skip));
2101                            out
2102                        }
2103                        Trunc::MTrunc => {
2104                            let keep = spec.width.saturating_sub(2);
2105                            let left_half = keep / 2;
2106                            let right_half = keep - left_half;
2107                            let mut out: String = s.chars().take(left_half).collect();
2108                            out.push_str("..");
2109                            out.extend(s.chars().skip(char_len - right_half));
2110                            out
2111                        }
2112                    }
2113                } else {
2114                    let pad = spec.width - char_len;
2115                    match spec.align {
2116                        Align::Left => {
2117                            let mut out = s.to_owned();
2118                            for _ in 0..pad {
2119                                out.push(' ');
2120                            }
2121                            out
2122                        }
2123                        Align::Right => {
2124                            let mut out = String::new();
2125                            for _ in 0..pad {
2126                                out.push(' ');
2127                            }
2128                            out.push_str(s);
2129                            out
2130                        }
2131                        Align::Center => {
2132                            let left = pad / 2;
2133                            let right = pad - left;
2134                            let mut out = String::new();
2135                            for _ in 0..left {
2136                                out.push(' ');
2137                            }
2138                            out.push_str(s);
2139                            for _ in 0..right {
2140                                out.push(' ');
2141                            }
2142                            out
2143                        }
2144                    }
2145                }
2146            }
2147            fn parse_col_spec(
2148                chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
2149                align: Align,
2150            ) -> Option<ColSpec> {
2151                // Consume '('
2152                if chars.peek() != Some(&'(') {
2153                    return None;
2154                }
2155                chars.next();
2156                let mut num_str = String::new();
2157                while let Some(&c) = chars.peek() {
2158                    if c.is_ascii_digit() {
2159                        num_str.push(c);
2160                        chars.next();
2161                    } else {
2162                        break;
2163                    }
2164                }
2165                let width: usize = num_str.parse().ok()?;
2166                let trunc = if chars.peek() == Some(&',') {
2167                    chars.next(); // consume comma
2168                    let mut mode = String::new();
2169                    while let Some(&c) = chars.peek() {
2170                        if c == ')' {
2171                            break;
2172                        }
2173                        mode.push(c);
2174                        chars.next();
2175                    }
2176                    match mode.as_str() {
2177                        "trunc" => Trunc::Trunc,
2178                        "ltrunc" => Trunc::LTrunc,
2179                        "mtrunc" => Trunc::MTrunc,
2180                        _ => Trunc::None,
2181                    }
2182                } else {
2183                    Trunc::None
2184                };
2185                // Consume ')'
2186                if chars.peek() == Some(&')') {
2187                    chars.next();
2188                }
2189                Some(ColSpec {
2190                    width,
2191                    align,
2192                    trunc,
2193                })
2194            }
2195
2196            let mut pending_col: Option<ColSpec> = None;
2197            let mut rendered = String::new();
2198            let mut chars = raw_fmt.chars().peekable();
2199            while let Some(ch) = chars.next() {
2200                if ch != '%' {
2201                    rendered.push(ch);
2202                    continue;
2203                }
2204                // Check for alignment directives: %<(...), %>(...), %><(...)
2205                if chars.peek() == Some(&'<') {
2206                    chars.next();
2207                    if let Some(spec) = parse_col_spec(&mut chars, Align::Left) {
2208                        pending_col = Some(spec);
2209                    }
2210                    continue;
2211                }
2212                if chars.peek() == Some(&'>') {
2213                    chars.next();
2214                    if chars.peek() == Some(&'<') {
2215                        chars.next(); // %><(...)
2216                        if let Some(spec) = parse_col_spec(&mut chars, Align::Center) {
2217                            pending_col = Some(spec);
2218                        }
2219                    } else if chars.peek() == Some(&'>') {
2220                        chars.next(); // %>>(...)
2221                        if let Some(spec) = parse_col_spec(&mut chars, Align::Right) {
2222                            pending_col = Some(spec);
2223                        }
2224                    } else if let Some(spec) = parse_col_spec(&mut chars, Align::Right) {
2225                        pending_col = Some(spec);
2226                    }
2227                    continue;
2228                }
2229
2230                // Helper macro-like: expand the placeholder, then apply pending_col
2231                let mut expanded = String::new();
2232                let target = if pending_col.is_some() {
2233                    &mut expanded
2234                } else {
2235                    &mut rendered
2236                };
2237                match chars.peek() {
2238                    Some('%') => {
2239                        chars.next();
2240                        target.push('%');
2241                    }
2242                    Some('H') => {
2243                        chars.next();
2244                        target.push_str(&oid.to_hex());
2245                    }
2246                    Some('h') => {
2247                        chars.next();
2248                        let hex = oid.to_hex();
2249                        let n = abbrev_len.clamp(4, 40).min(hex.len());
2250                        target.push_str(&hex[..n]);
2251                    }
2252                    Some('T') => {
2253                        chars.next();
2254                        target.push_str(&tree_hex);
2255                    }
2256                    Some('t') => {
2257                        chars.next();
2258                        let n = abbrev_len.clamp(4, 40).min(tree_hex.len());
2259                        target.push_str(&tree_hex[..n]);
2260                    }
2261                    Some('P') => {
2262                        chars.next();
2263                        target.push_str(&parent_hexes.join(" "));
2264                    }
2265                    Some('p') => {
2266                        chars.next();
2267                        target.push_str(&parent_abbrevs.join(" "));
2268                    }
2269                    Some('n') => {
2270                        chars.next();
2271                        target.push('\n');
2272                    }
2273                    Some('s') => {
2274                        chars.next();
2275                        target.push_str(subject);
2276                    }
2277                    Some('b') => {
2278                        chars.next();
2279                        target.push_str(&body);
2280                        if !body.is_empty() {
2281                            target.push('\n');
2282                        }
2283                    }
2284                    Some('B') => {
2285                        chars.next();
2286                        target.push_str(&commit.message);
2287                    }
2288                    Some('a') => {
2289                        chars.next();
2290                        match chars.next() {
2291                            Some('n') => target.push_str(extract_name(&commit.author)),
2292                            Some('N') => target.push_str(extract_name(&commit.author)),
2293                            Some('e') => target.push_str(extract_email(&commit.author)),
2294                            Some('E') => target.push_str(extract_email(&commit.author)),
2295                            Some('l') => target.push_str(extract_email_local(&commit.author)),
2296                            Some('d') => target.push_str(&extract_date_default(&commit.author)),
2297                            Some('D') => target.push_str(&extract_date_rfc2822(&commit.author)),
2298                            Some('t') => target.push_str(&extract_timestamp(&commit.author)),
2299                            Some('s') => target.push_str(&extract_date_short(&commit.author)),
2300                            Some('i') => target.push_str(&extract_date_iso(&commit.author)),
2301                            Some('I') => {
2302                                let Some(p) = parse_signature_times(&commit.author) else {
2303                                    break;
2304                                };
2305                                let adjusted = p.unix_seconds + p.tz_offset_secs;
2306                                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
2307                                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
2308                                let sign_ch = if p.tz_offset_secs >= 0 { '+' } else { '-' };
2309                                let abs_off = p.tz_offset_secs.unsigned_abs();
2310                                target.push_str(&format!(
2311                                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
2312                                    dt.year(),
2313                                    dt.month() as u8,
2314                                    dt.day(),
2315                                    dt.hour(),
2316                                    dt.minute(),
2317                                    dt.second(),
2318                                    sign_ch,
2319                                    abs_off / 3600,
2320                                    (abs_off % 3600) / 60
2321                                ));
2322                            }
2323                            Some('r') => {
2324                                let Some(p) = parse_signature_times(&commit.author) else {
2325                                    break;
2326                                };
2327                                let now = std::time::SystemTime::now()
2328                                    .duration_since(std::time::UNIX_EPOCH)
2329                                    .unwrap_or_default()
2330                                    .as_secs() as i64;
2331                                target.push_str(&format_relative_date(now - p.unix_seconds));
2332                            }
2333                            Some(other) => {
2334                                target.push('%');
2335                                target.push('a');
2336                                target.push(other);
2337                            }
2338                            None => {
2339                                target.push('%');
2340                                target.push('a');
2341                            }
2342                        }
2343                    }
2344                    Some('c') => {
2345                        chars.next();
2346                        match chars.next() {
2347                            Some('n') => target.push_str(extract_name(&commit.committer)),
2348                            Some('N') => target.push_str(extract_name(&commit.committer)),
2349                            Some('e') => target.push_str(extract_email(&commit.committer)),
2350                            Some('E') => target.push_str(extract_email(&commit.committer)),
2351                            Some('l') => target.push_str(extract_email_local(&commit.committer)),
2352                            Some('d') => target.push_str(&extract_date_default(&commit.committer)),
2353                            Some('D') => target.push_str(&extract_date_rfc2822(&commit.committer)),
2354                            Some('t') => target.push_str(&extract_timestamp(&commit.committer)),
2355                            Some('s') => target.push_str(&extract_date_short(&commit.committer)),
2356                            Some('i') => target.push_str(&extract_date_iso(&commit.committer)),
2357                            Some('I') => {
2358                                let Some(p) = parse_signature_times(&commit.committer) else {
2359                                    break;
2360                                };
2361                                let adjusted = p.unix_seconds + p.tz_offset_secs;
2362                                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
2363                                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
2364                                let sign_ch = if p.tz_offset_secs >= 0 { '+' } else { '-' };
2365                                let abs_off = p.tz_offset_secs.unsigned_abs();
2366                                target.push_str(&format!(
2367                                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
2368                                    dt.year(),
2369                                    dt.month() as u8,
2370                                    dt.day(),
2371                                    dt.hour(),
2372                                    dt.minute(),
2373                                    dt.second(),
2374                                    sign_ch,
2375                                    abs_off / 3600,
2376                                    (abs_off % 3600) / 60
2377                                ));
2378                            }
2379                            Some('r') => {
2380                                let Some(p) = parse_signature_times(&commit.committer) else {
2381                                    break;
2382                                };
2383                                let now = std::time::SystemTime::now()
2384                                    .duration_since(std::time::UNIX_EPOCH)
2385                                    .unwrap_or_default()
2386                                    .as_secs() as i64;
2387                                target.push_str(&format_relative_date(now - p.unix_seconds));
2388                            }
2389                            Some(other) => {
2390                                target.push('%');
2391                                target.push('c');
2392                                target.push(other);
2393                            }
2394                            None => {
2395                                target.push('%');
2396                                target.push('c');
2397                            }
2398                        }
2399                    }
2400                    Some('x') => {
2401                        // Hex escape: %xNN
2402                        chars.next();
2403                        let mut hex_str = String::new();
2404                        if let Some(&c1) = chars.peek() {
2405                            if c1.is_ascii_hexdigit() {
2406                                hex_str.push(c1);
2407                                chars.next();
2408                            }
2409                        }
2410                        if let Some(&c2) = chars.peek() {
2411                            if c2.is_ascii_hexdigit() {
2412                                hex_str.push(c2);
2413                                chars.next();
2414                            }
2415                        }
2416                        if let Ok(byte) = u8::from_str_radix(&hex_str, 16) {
2417                            target.push(byte as char);
2418                        }
2419                    }
2420                    Some('C') => {
2421                        chars.next();
2422                        if chars.peek() == Some(&'(') {
2423                            chars.next();
2424                            let mut spec = String::new();
2425                            for c in chars.by_ref() {
2426                                if c == ')' {
2427                                    break;
2428                                }
2429                                spec.push(c);
2430                            }
2431                            let (force, color_spec) =
2432                                if let Some(rest) = spec.strip_prefix("always,") {
2433                                    (true, rest)
2434                                } else if let Some(rest) = spec.strip_prefix("auto,") {
2435                                    (false, rest)
2436                                } else if spec == "auto" {
2437                                    if use_color {
2438                                        target.push_str("\x1b[m");
2439                                    }
2440                                    continue;
2441                                } else {
2442                                    (false, spec.as_str())
2443                                };
2444                            if use_color || force {
2445                                target.push_str(&ansi_color_from_spec(color_spec));
2446                            }
2447                        } else {
2448                            // Named colors: %Cred, %Cgreen, %Cblue, %Creset, %Cbold
2449                            // Must match known names only, not consume trailing text
2450                            let remaining: String = chars.clone().collect();
2451                            let known = [
2452                                "reset", "red", "green", "blue", "yellow", "magenta", "cyan",
2453                                "white", "bold", "dim", "ul",
2454                            ];
2455                            let mut matched = false;
2456                            for name in &known {
2457                                if remaining.starts_with(name) {
2458                                    for _ in 0..name.len() {
2459                                        chars.next();
2460                                    }
2461                                    if use_color {
2462                                        target.push_str(&ansi_color_from_name(name));
2463                                    }
2464                                    matched = true;
2465                                    break;
2466                                }
2467                            }
2468                            if !matched {
2469                                // Unknown color name — consume alphanumerics
2470                                while let Some(&c) = chars.peek() {
2471                                    if c.is_alphanumeric() {
2472                                        chars.next();
2473                                    } else {
2474                                        break;
2475                                    }
2476                                }
2477                            }
2478                        }
2479                    }
2480                    Some('w') => {
2481                        // %w(...) — wrapping directive, consume and ignore for now
2482                        chars.next();
2483                        if chars.peek() == Some(&'(') {
2484                            chars.next();
2485                            for c in chars.by_ref() {
2486                                if c == ')' {
2487                                    break;
2488                                }
2489                            }
2490                        }
2491                    }
2492                    Some('+') => {
2493                        // %+x — conditional newline: if next placeholder is non-empty, prepend newline
2494                        chars.next();
2495                        // Expand the following placeholder
2496                        if chars.peek() == Some(&'%') {
2497                            // The %+ applies to the NEXT expanded value
2498                            // For simplicity, treat %+x as: if %x is non-empty, emit '\n' + value
2499                            // This needs the *next* placeholder's value
2500                        }
2501                        // Simple: consume the next char as a format code; prepend \n if non-empty
2502                        let mut sub = String::new();
2503                        if let Some(&nc) = chars.peek() {
2504                            match nc {
2505                                'b' => {
2506                                    chars.next();
2507                                    sub.push_str(&body);
2508                                    if !body.is_empty() {
2509                                        sub.push('\n');
2510                                    }
2511                                }
2512                                's' => {
2513                                    chars.next();
2514                                    sub.push_str(subject);
2515                                }
2516                                _ => {
2517                                    chars.next();
2518                                    sub.push('%');
2519                                    sub.push('+');
2520                                    sub.push(nc);
2521                                }
2522                            }
2523                        }
2524                        if !sub.is_empty() {
2525                            target.push('\n');
2526                            target.push_str(&sub);
2527                        }
2528                    }
2529                    Some('-') => {
2530                        // %-x — conditional: suppress newline before placeholder if empty
2531                        chars.next();
2532                        // Consume the next format code
2533                        if let Some(&nc) = chars.peek() {
2534                            match nc {
2535                                'b' => {
2536                                    chars.next();
2537                                    if !body.is_empty() {
2538                                        target.push_str(&body);
2539                                        target.push('\n');
2540                                    }
2541                                }
2542                                's' => {
2543                                    chars.next();
2544                                    target.push_str(subject);
2545                                }
2546                                _ => {
2547                                    chars.next();
2548                                    target.push('%');
2549                                    target.push('-');
2550                                    target.push(nc);
2551                                }
2552                            }
2553                        }
2554                    }
2555                    Some('d') => {
2556                        // Decorations — output empty for now
2557                        chars.next();
2558                    }
2559                    Some('D') => {
2560                        // Decorations without parens — output empty for now
2561                        chars.next();
2562                    }
2563                    Some('e') => {
2564                        // Encoding
2565                        chars.next();
2566                        if let Some(encoding) = &commit.encoding {
2567                            target.push_str(encoding);
2568                        }
2569                    }
2570                    Some('g') => {
2571                        // Reflog placeholders: %gD, %gd, %gs, %gn, %ge, etc.
2572                        chars.next();
2573                        if let Some(&_nc) = chars.peek() {
2574                            chars.next(); // consume the sub-specifier
2575                                          // For non-reflog commits, these expand to empty
2576                        }
2577                    }
2578                    Some('G') => {
2579                        // Signature placeholders (%G?, %GS, %GK, %GF, %GP, %GT, %GG).
2580                        // Unknown `%G<x>` (and a bare trailing `%G`) pass through
2581                        // literally, mirroring git's pretty.c `return 0`.
2582                        use crate::signing::{SignatureCheck, TrustLevel};
2583                        let default_sig;
2584                        let sig: &SignatureCheck = match &signature {
2585                            Some(s) => s,
2586                            None => {
2587                                default_sig = SignatureCheck::default_none();
2588                                &default_sig
2589                            }
2590                        };
2591                        let mut lookahead = chars.clone();
2592                        lookahead.next(); // consume 'G'
2593                        let sub = lookahead.peek().copied();
2594                        let handled = match sub {
2595                            Some('?') => {
2596                                let ch = if sig.result == 'G'
2597                                    && matches!(
2598                                        sig.trust_level,
2599                                        TrustLevel::Undefined | TrustLevel::Never
2600                                    ) {
2601                                    'U'
2602                                } else {
2603                                    sig.result
2604                                };
2605                                target.push(ch);
2606                                true
2607                            }
2608                            Some('S') => {
2609                                target.push_str(sig.signer.as_deref().unwrap_or(""));
2610                                true
2611                            }
2612                            Some('K') => {
2613                                target.push_str(sig.key.as_deref().unwrap_or(""));
2614                                true
2615                            }
2616                            Some('F') => {
2617                                target.push_str(sig.fingerprint.as_deref().unwrap_or(""));
2618                                true
2619                            }
2620                            Some('P') => {
2621                                target
2622                                    .push_str(sig.primary_key_fingerprint.as_deref().unwrap_or(""));
2623                                true
2624                            }
2625                            Some('T') => {
2626                                target.push_str(sig.trust_level.display_key());
2627                                true
2628                            }
2629                            Some('G') => {
2630                                target.push_str(&sig.output);
2631                                true
2632                            }
2633                            _ => false,
2634                        };
2635                        if handled {
2636                            chars.next(); // consume 'G'
2637                            chars.next(); // consume sub-char
2638                        } else {
2639                            target.push('%');
2640                        }
2641                    }
2642                    Some(&other) => {
2643                        chars.next();
2644                        target.push('%');
2645                        target.push(other);
2646                    }
2647                    None => target.push('%'),
2648                }
2649                // Apply pending column formatting
2650                if let Some(spec) = pending_col.take() {
2651                    let formatted = apply_col(&spec, &expanded);
2652                    rendered.push_str(&formatted);
2653                }
2654            }
2655            Ok(rendered)
2656        }
2657    }
2658}
2659
2660#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2661enum ExpectedObjectKind {
2662    Commit,
2663    Tree,
2664    Blob,
2665    Tag,
2666}
2667
2668impl ExpectedObjectKind {
2669    fn from_object_kind(kind: ObjectKind) -> Option<Self> {
2670        match kind {
2671            ObjectKind::Commit => Some(Self::Commit),
2672            ObjectKind::Tree => Some(Self::Tree),
2673            ObjectKind::Blob => Some(Self::Blob),
2674            ObjectKind::Tag => Some(Self::Tag),
2675        }
2676    }
2677
2678    fn from_tag_type(kind: &str) -> Option<Self> {
2679        match kind {
2680            "commit" => Some(Self::Commit),
2681            "tree" => Some(Self::Tree),
2682            "blob" => Some(Self::Blob),
2683            "tag" => Some(Self::Tag),
2684            _ => None,
2685        }
2686    }
2687
2688    fn matches(self, kind: ObjectKind) -> bool {
2689        matches!(
2690            (self, kind),
2691            (Self::Commit, ObjectKind::Commit)
2692                | (Self::Tree, ObjectKind::Tree)
2693                | (Self::Blob, ObjectKind::Blob)
2694                | (Self::Tag, ObjectKind::Tag)
2695        )
2696    }
2697
2698    fn as_str(self) -> &'static str {
2699        match self {
2700            Self::Commit => "commit",
2701            Self::Tree => "tree",
2702            Self::Blob => "blob",
2703            Self::Tag => "tag",
2704        }
2705    }
2706}
2707
2708/// Non-commit root from revision arguments (`tag`, `rev:path`, raw tree/blob OID), for object walks.
2709#[derive(Clone, Debug)]
2710pub struct ObjectWalkRoot {
2711    /// Object id of the peeled non-commit (tree or blob) or tag target.
2712    pub oid: ObjectId,
2713    pub input: String,
2714    /// Path within the tree for `rev:path` blob roots.
2715    pub root_path: Option<String>,
2716}
2717
2718#[derive(Clone, Debug)]
2719struct RootObject {
2720    oid: ObjectId,
2721    input: String,
2722    expected_kind: Option<ExpectedObjectKind>,
2723    root_path: Option<String>,
2724    /// When the user named an annotated tag, the tag object to emit before walking [`Self::oid`].
2725    wrap_with_tag: Option<ObjectId>,
2726}
2727
2728fn indexed_object_roots(repo: &Repository) -> Result<Vec<RootObject>> {
2729    let Some(_) = &repo.work_tree else {
2730        return Ok(Vec::new());
2731    };
2732    let index_path = repo.git_dir.join("index");
2733    if !index_path.is_file() {
2734        return Ok(Vec::new());
2735    }
2736
2737    let idx = Index::load(&index_path)?;
2738    let mut roots = Vec::new();
2739    for e in &idx.entries {
2740        if e.stage() != 0 || e.mode == MODE_GITLINK || e.mode == MODE_TREE {
2741            continue;
2742        }
2743        let path_str = String::from_utf8_lossy(&e.path).into_owned();
2744        roots.push(RootObject {
2745            oid: e.oid,
2746            input: format!(":{path_str}"),
2747            expected_kind: Some(ExpectedObjectKind::Blob),
2748            root_path: Some(path_str),
2749            wrap_with_tag: None,
2750        });
2751    }
2752
2753    if let Some(cache_tree) = &idx.cache_tree {
2754        push_index_cache_tree_child_roots(&mut roots, cache_tree, "");
2755    }
2756
2757    Ok(roots)
2758}
2759
2760fn push_index_cache_tree_child_roots(
2761    roots: &mut Vec<RootObject>,
2762    node: &CacheTreeNode,
2763    parent_path: &str,
2764) {
2765    for child in &node.children {
2766        let name = String::from_utf8_lossy(&child.name);
2767        let path = if parent_path.is_empty() {
2768            name.into_owned()
2769        } else {
2770            format!("{parent_path}/{name}")
2771        };
2772
2773        if child.entry_count >= 0 {
2774            if let Some(oid) = child.oid {
2775                roots.push(RootObject {
2776                    oid,
2777                    input: format!(":{path}"),
2778                    expected_kind: Some(ExpectedObjectKind::Tree),
2779                    root_path: Some(path.clone()),
2780                    wrap_with_tag: None,
2781                });
2782            }
2783        }
2784
2785        push_index_cache_tree_child_roots(roots, child, &path);
2786    }
2787}
2788
2789fn object_walk_print_commit_line(
2790    filter_provided_objects: bool,
2791    filter: Option<&ObjectFilter>,
2792    user_given_tip: bool,
2793) -> bool {
2794    if !filter_provided_objects {
2795        return user_given_tip || filter_shows_commit_line_when_not_user_given(filter);
2796    }
2797    match filter {
2798        Some(ObjectFilter::ObjectType(FilterObjectKind::Commit)) => {
2799            user_given_tip || filter_shows_commit_line_when_not_user_given(filter)
2800        }
2801        Some(ObjectFilter::ObjectType(_)) => false,
2802        Some(ObjectFilter::Combine(parts)) => {
2803            if parts
2804                .iter()
2805                .all(|p| matches!(p, ObjectFilter::ObjectType(FilterObjectKind::Commit)))
2806            {
2807                user_given_tip || filter_shows_commit_line_when_not_user_given(filter)
2808            } else {
2809                false
2810            }
2811        }
2812        _ => user_given_tip || filter_shows_commit_line_when_not_user_given(filter),
2813    }
2814}
2815
2816/// Whether `LOFS_COMMIT` would receive `LOFR_DO_SHOW` when the commit is `NOT_USER_GIVEN` (Git list-objects-filter).
2817fn filter_shows_commit_line_when_not_user_given(filter: Option<&ObjectFilter>) -> bool {
2818    match filter {
2819        None => true,
2820        Some(ObjectFilter::BlobNone)
2821        | Some(ObjectFilter::BlobLimit(_))
2822        | Some(ObjectFilter::TreeDepth(_))
2823        | Some(ObjectFilter::SparseOid(_)) => true,
2824        Some(ObjectFilter::ObjectType(FilterObjectKind::Commit)) => true,
2825        Some(ObjectFilter::ObjectType(
2826            FilterObjectKind::Blob | FilterObjectKind::Tree | FilterObjectKind::Tag,
2827        )) => false,
2828        Some(ObjectFilter::Combine(parts)) => parts
2829            .iter()
2830            .all(|p| filter_shows_commit_line_when_not_user_given(Some(p))),
2831    }
2832}
2833
2834fn skip_tree_descent_for_object_type_filter(filter: Option<&ObjectFilter>) -> bool {
2835    match filter {
2836        Some(ObjectFilter::ObjectType(FilterObjectKind::Commit | FilterObjectKind::Tag)) => true,
2837        Some(ObjectFilter::Combine(parts)) => parts
2838            .iter()
2839            .any(|p| skip_tree_descent_for_object_type_filter(Some(p))),
2840        _ => false,
2841    }
2842}
2843
2844fn sparse_filter_includes_path(
2845    repo: &Repository,
2846    path: &str,
2847    sparse_lines: Option<&[String]>,
2848) -> bool {
2849    sparse_lines
2850        .map(|lines| path_in_sparse_checkout(path, lines, repo.work_tree.as_deref()))
2851        .unwrap_or(true)
2852}
2853
2854fn sparse_oid_lines_from_filter(
2855    repo: &Repository,
2856    filter: Option<&ObjectFilter>,
2857) -> Result<Option<Vec<String>>> {
2858    let Some(f) = filter else {
2859        return Ok(None);
2860    };
2861    match f {
2862        ObjectFilter::SparseOid(spec) => {
2863            // Resolve `<rev>:<path>` (or a raw blob OID) to a blob, matching Git's
2864            // `repo_get_oid_with_flags(..., GET_OID_BLOB)`. A name that does not resolve to an
2865            // accessible blob is a hard error (`unable to access sparse blob in '<name>'`).
2866            let blob_oid = if let Ok(oid) = spec.parse::<ObjectId>() {
2867                oid
2868            } else if let Some((treeish, path)) = split_treeish_spec(spec) {
2869                let treeish_oid = resolve_revision_for_range_end(repo, treeish)
2870                    .map_err(|_| sparse_blob_access_error(spec))?;
2871                resolve_treeish_path(repo, treeish_oid, path)
2872                    .map_err(|_| sparse_blob_access_error(spec))?
2873            } else {
2874                // A bare name with no `:<path>` (e.g. `main`): Git resolves it to whatever object
2875                // the revision names (commit/tree/tag/blob), then tries to parse that object as a
2876                // sparse blob. A non-blob therefore fails parsing, not access (t5616 expects
2877                // "unable to parse sparse filter data in <oid>" for `sparse:oid=main`).
2878                resolve_revision_for_range_end(repo, spec)
2879                    .map_err(|_| sparse_blob_access_error(spec))?
2880            };
2881            let obj = repo
2882                .odb
2883                .read(&blob_oid)
2884                .map_err(|_| sparse_blob_access_error(spec))?;
2885            // A resolved object that is not a parseable sparse blob (e.g. a tree) fails parsing:
2886            // `unable to parse sparse filter data in <oid>`.
2887            if obj.kind != ObjectKind::Blob {
2888                return Err(Error::Message(format!(
2889                    "fatal: unable to parse sparse filter data in {}",
2890                    blob_oid.to_hex()
2891                )));
2892            }
2893            let text = std::str::from_utf8(&obj.data).map_err(|_| {
2894                Error::Message(format!(
2895                    "fatal: unable to parse sparse filter data in {}",
2896                    blob_oid.to_hex()
2897                ))
2898            })?;
2899            Ok(Some(parse_sparse_patterns_from_blob(text)))
2900        }
2901        ObjectFilter::Combine(parts) => {
2902            for p in parts {
2903                if let Some(lines) = sparse_oid_lines_from_filter(repo, Some(p))? {
2904                    return Ok(Some(lines));
2905                }
2906            }
2907            Ok(None)
2908        }
2909        _ => Ok(None),
2910    }
2911}
2912
2913/// Git's `unable to access sparse blob in '<name>'` error for an unresolvable `sparse:oid` spec.
2914fn sparse_blob_access_error(spec: &str) -> Error {
2915    Error::Message(format!("fatal: unable to access sparse blob in '{spec}'"))
2916}
2917
2918fn packed_object_set(repo: &Repository) -> HashSet<ObjectId> {
2919    let mut out = HashSet::new();
2920    let objects_dir = repo.odb.objects_dir();
2921    if let Ok(indexes) = pack::read_local_pack_indexes(objects_dir) {
2922        for idx in indexes {
2923            for e in idx.entries {
2924                if let Ok(oid) = ObjectId::from_bytes(&e.oid) {
2925                    out.insert(oid);
2926                }
2927            }
2928        }
2929    }
2930    out
2931}
2932
2933fn resolve_specs(repo: &Repository, specs: &[String]) -> Result<Vec<ObjectId>> {
2934    resolve_specs_with_options(repo, specs, false)
2935}
2936
2937fn resolve_specs_with_options(
2938    repo: &Repository,
2939    specs: &[String],
2940    ignore_missing: bool,
2941) -> Result<Vec<ObjectId>> {
2942    let mut out = Vec::with_capacity(specs.len());
2943    for spec in specs {
2944        let oid = match resolve_revision_for_range_end(repo, spec) {
2945            Ok(oid) => oid,
2946            Err(Error::ObjectNotFound(_) | Error::InvalidRef(_)) if ignore_missing => continue,
2947            Err(err) => return Err(err),
2948        };
2949        match peel_to_commit(repo, oid) {
2950            Ok(commit_oid) => out.push(commit_oid),
2951            // `git rev-list <obj>` accepts a positive tip that is (or peels to) a non-commit
2952            // object such as a blob or a tag-of-blob: it simply contributes no commits and exits
2953            // 0 (verified against git: `git rev-list tag-of-blob` prints nothing, exit 0). Mirror
2954            // that here so callers that feed all freshly-fetched ref tips into a commit walk
2955            // (e.g. submodule-change detection over a remote carrying a tag-of-blob, t5516
2956            // "refuse fetch to current branch of worktree") do not abort.
2957            Err(Error::CorruptObject(_)) if object_peels_to_non_commit(repo, oid) => {}
2958            Err(Error::ObjectNotFound(_) | Error::InvalidRef(_)) if ignore_missing => {}
2959            Err(err) => return Err(err),
2960        }
2961    }
2962    Ok(out)
2963}
2964
2965/// Resolve revision strings to commit tips and non-commit roots (tags, trees, blobs, `rev:path`).
2966///
2967/// Used by `test-tool path-walk` and similar object walks that mirror `git` revision parsing.
2968/// Resolve revision strings to commit OIDs (for negative specs / exclusions).
2969pub fn resolve_revision_commits(repo: &Repository, specs: &[String]) -> Result<Vec<ObjectId>> {
2970    resolve_specs(repo, specs)
2971}
2972
2973/// Resolve revision argument strings to commit OIDs for history walks (`log`, `rev-list`).
2974///
2975/// Each spec is interpreted like Git's `handle_revision_arg` for a plain commit tip: ranges
2976/// (`A..B`), exclusions (`^rev`), and revision expressions are supported via
2977/// [`split_revision_token`] and [`resolve_revision_for_range_end`].
2978pub fn resolve_revision_specs_to_commits(
2979    repo: &Repository,
2980    specs: &[String],
2981) -> Result<Vec<ObjectId>> {
2982    resolve_specs(repo, specs)
2983}
2984
2985pub fn resolve_object_walk_roots(
2986    repo: &Repository,
2987    specs: &[String],
2988) -> Result<(Vec<ObjectId>, Vec<ObjectWalkRoot>)> {
2989    let (commits, roots, _tip_annotated_tag_by_commit) = resolve_specs_for_objects(repo, specs)?;
2990    Ok((
2991        commits,
2992        roots
2993            .into_iter()
2994            .map(|r| ObjectWalkRoot {
2995                oid: r.oid,
2996                input: r.input,
2997                root_path: r.root_path,
2998            })
2999            .collect(),
3000    ))
3001}
3002
3003fn resolve_specs_for_objects(
3004    repo: &Repository,
3005    specs: &[String],
3006) -> Result<(Vec<ObjectId>, Vec<RootObject>, HashMap<ObjectId, ObjectId>)> {
3007    resolve_specs_for_objects_with_options(repo, specs, false, MissingAction::Error)
3008}
3009
3010fn resolve_specs_for_objects_with_options(
3011    repo: &Repository,
3012    specs: &[String],
3013    ignore_missing: bool,
3014    missing_action: MissingAction,
3015) -> Result<(Vec<ObjectId>, Vec<RootObject>, HashMap<ObjectId, ObjectId>)> {
3016    let mut commits = Vec::new();
3017    let mut roots = Vec::new();
3018    let mut tip_annotated_tag_by_commit: HashMap<ObjectId, ObjectId> = HashMap::new();
3019
3020    for spec in specs {
3021        if let Ok(raw_oid) = spec.parse::<ObjectId>() {
3022            let raw_object = match repo.odb.read(&raw_oid) {
3023                Ok(obj) => obj,
3024                Err(Error::ObjectNotFound(_)) if ignore_missing => continue,
3025                Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
3026                    roots.push(RootObject {
3027                        oid: raw_oid,
3028                        input: spec.clone(),
3029                        expected_kind: None,
3030                        root_path: None,
3031                        wrap_with_tag: None,
3032                    });
3033                    continue;
3034                }
3035                Err(err) => return Err(err),
3036            };
3037            match raw_object.kind {
3038                ObjectKind::Commit => {
3039                    commits.push(raw_oid);
3040                }
3041                ObjectKind::Tag => {
3042                    let tag = parse_tag(&raw_object.data)?;
3043                    let expected_kind = ExpectedObjectKind::from_tag_type(&tag.object_type)
3044                        .ok_or_else(|| {
3045                            Error::CorruptObject(format!(
3046                                "object {spec} has unsupported tag type '{}'",
3047                                tag.object_type
3048                            ))
3049                        })?;
3050                    if expected_kind == ExpectedObjectKind::Commit {
3051                        // A tag whose target peels to a commit becomes a normal commit walk tip
3052                        // (printed as a bare OID commit line) with the tag emitted alongside it via
3053                        // `tip_annotated_tag_by_commit`, matching how a tag named by ref is handled.
3054                        // Routing it through a commit `RootObject` instead would print the commit with
3055                        // an empty object name (`<oid> \0`) rather than as a commit line (t5305).
3056                        tip_annotated_tag_by_commit.insert(tag.object, raw_oid);
3057                        commits.push(tag.object);
3058                    } else {
3059                        roots.push(RootObject {
3060                            oid: tag.object,
3061                            input: spec.clone(),
3062                            expected_kind: Some(expected_kind),
3063                            root_path: None,
3064                            wrap_with_tag: Some(raw_oid),
3065                        });
3066                    }
3067                }
3068                ObjectKind::Tree | ObjectKind::Blob => roots.push(RootObject {
3069                    oid: raw_oid,
3070                    input: spec.clone(),
3071                    expected_kind: None,
3072                    root_path: None,
3073                    wrap_with_tag: None,
3074                }),
3075            }
3076            continue;
3077        }
3078
3079        if let Some((treeish, path)) = split_treeish_spec(spec) {
3080            if !path.is_empty() {
3081                let treeish_oid = match resolve_revision_for_range_end(repo, treeish) {
3082                    Ok(oid) => oid,
3083                    Err(Error::ObjectNotFound(_) | Error::InvalidRef(_)) if ignore_missing => {
3084                        continue;
3085                    }
3086                    Err(err) => return Err(err),
3087                };
3088                let object_oid = match resolve_treeish_path(repo, treeish_oid, path) {
3089                    Ok(oid) => oid,
3090                    Err(Error::ObjectNotFound(_) | Error::InvalidRef(_)) if ignore_missing => {
3091                        continue;
3092                    }
3093                    Err(err) => return Err(err),
3094                };
3095                let expected_kind = match repo.odb.read(&object_oid) {
3096                    Ok(object) => ExpectedObjectKind::from_object_kind(object.kind),
3097                    Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => None,
3098                    Err(Error::ObjectNotFound(_)) if ignore_missing => continue,
3099                    Err(err) => return Err(err),
3100                };
3101                roots.push(RootObject {
3102                    oid: object_oid,
3103                    input: spec.clone(),
3104                    expected_kind,
3105                    root_path: Some(path.to_owned()),
3106                    wrap_with_tag: None,
3107                });
3108                continue;
3109            }
3110        }
3111
3112        let oid = match resolve_revision_for_range_end(repo, spec) {
3113            Ok(oid) => oid,
3114            Err(Error::ObjectNotFound(_) | Error::InvalidRef(_)) if ignore_missing => continue,
3115            Err(err) => return Err(err),
3116        };
3117        if let Ok(obj) = repo.odb.read(&oid) {
3118            if obj.kind == ObjectKind::Tag {
3119                if let Ok(commit_oid) = peel_to_commit(repo, oid) {
3120                    tip_annotated_tag_by_commit.insert(commit_oid, oid);
3121                }
3122            }
3123        }
3124        match peel_to_commit(repo, oid) {
3125            Ok(commit_oid) => commits.push(commit_oid),
3126            Err(Error::CorruptObject(_)) if ignore_missing => {}
3127            Err(Error::ObjectNotFound(_)) if ignore_missing => {}
3128            Err(Error::CorruptObject(_)) | Err(Error::ObjectNotFound(_)) => {
3129                roots.push(RootObject {
3130                    oid,
3131                    input: spec.clone(),
3132                    expected_kind: None,
3133                    root_path: None,
3134                    wrap_with_tag: None,
3135                })
3136            }
3137            Err(err) => return Err(err),
3138        }
3139    }
3140
3141    Ok((commits, roots, tip_annotated_tag_by_commit))
3142}
3143
3144/// Returns `true` when `oid` is a readable object that peels (through any tag chain) to a blob or
3145/// tree rather than a commit. Used to mirror `git rev-list`'s tolerance of a non-commit positive
3146/// tip: such a tip is silently ignored, whereas a genuinely missing/corrupt object is an error.
3147fn object_peels_to_non_commit(repo: &Repository, mut oid: ObjectId) -> bool {
3148    loop {
3149        let Ok(object) = repo.odb.read(&oid) else {
3150            return false;
3151        };
3152        match object.kind {
3153            ObjectKind::Commit => return false,
3154            ObjectKind::Blob | ObjectKind::Tree => return true,
3155            ObjectKind::Tag => match parse_tag(&object.data) {
3156                Ok(tag) => oid = tag.object,
3157                Err(_) => return false,
3158            },
3159        }
3160    }
3161}
3162
3163/// Peel an object (possibly a tag) to the underlying commit.
3164fn peel_to_commit(repo: &Repository, mut oid: ObjectId) -> Result<ObjectId> {
3165    loop {
3166        let object = repo.odb.read(&oid)?;
3167        match object.kind {
3168            ObjectKind::Commit => return Ok(oid),
3169            ObjectKind::Tag => {
3170                let tag = parse_tag(&object.data)?;
3171                oid = tag.object;
3172            }
3173            other => {
3174                return Err(Error::CorruptObject(format!(
3175                    "object {oid} is a {other:?}, not a commit"
3176                )));
3177            }
3178        }
3179    }
3180}
3181
3182fn reflog_commit_tips(repo: &Repository) -> Result<Vec<ObjectId>> {
3183    let z = zero_oid();
3184    let mut out = Vec::new();
3185    let mut seen = HashSet::new();
3186    for refname in list_reflog_refs(&repo.git_dir)? {
3187        let entries = read_reflog(&repo.git_dir, &refname)?;
3188        for e in entries {
3189            for oid in [e.old_oid, e.new_oid] {
3190                if oid == z {
3191                    continue;
3192                }
3193                match peel_to_commit(repo, oid) {
3194                    Ok(c) if seen.insert(c) => out.push(c),
3195                    Err(_) => {}
3196                    _ => {}
3197                }
3198            }
3199        }
3200    }
3201    Ok(out)
3202}
3203
3204pub(crate) fn walk_closure(
3205    graph: &mut CommitGraph<'_>,
3206    starts: &[ObjectId],
3207) -> Result<HashSet<ObjectId>> {
3208    let (seen, _) = walk_closure_ordered(graph, starts)?;
3209    Ok(seen)
3210}
3211
3212/// Like [`walk_closure`] but follows only the first parent from each start (Git
3213/// `--exclude-first-parent-only` for excluded tips).
3214fn walk_closure_first_parent_only(
3215    graph: &mut CommitGraph<'_>,
3216    starts: &[ObjectId],
3217) -> Result<HashSet<ObjectId>> {
3218    let mut seen = HashSet::new();
3219    let mut queue = VecDeque::new();
3220    for &start in starts {
3221        queue.push_back(start);
3222    }
3223    while let Some(oid) = queue.pop_front() {
3224        if !seen.insert(oid) {
3225            continue;
3226        }
3227        let parents = graph.parents_of(oid)?;
3228        if let Some(&p) = parents.first() {
3229            queue.push_back(p);
3230        }
3231    }
3232    Ok(seen)
3233}
3234
3235/// BFS walk that returns both the set and the discovery order.
3236pub(crate) fn walk_closure_ordered(
3237    graph: &mut CommitGraph<'_>,
3238    starts: &[ObjectId],
3239) -> Result<(HashSet<ObjectId>, Vec<ObjectId>)> {
3240    walk_closure_ordered_excluding(graph, starts, &HashSet::new())
3241}
3242
3243fn walk_closure_ordered_excluding(
3244    graph: &mut CommitGraph<'_>,
3245    starts: &[ObjectId],
3246    excluded: &HashSet<ObjectId>,
3247) -> Result<(HashSet<ObjectId>, Vec<ObjectId>)> {
3248    let mut seen = HashSet::new();
3249    let mut order = Vec::new();
3250    let mut queue = VecDeque::new();
3251    for &start in starts {
3252        queue.push_back(start);
3253    }
3254    while let Some(oid) = queue.pop_front() {
3255        if excluded.contains(&oid) {
3256            continue;
3257        }
3258        if !seen.insert(oid) {
3259            continue;
3260        }
3261        order.push(oid);
3262        for parent in graph.parents_of(oid)? {
3263            queue.push_back(parent);
3264        }
3265    }
3266    Ok((seen, order))
3267}
3268
3269fn walk_closure_ordered_with_missing(
3270    graph: &mut CommitGraph<'_>,
3271    starts: &[ObjectId],
3272    excluded: &HashSet<ObjectId>,
3273    missing_action: MissingAction,
3274    missing: &mut Vec<ObjectId>,
3275    missing_seen: &mut HashSet<ObjectId>,
3276) -> Result<(HashSet<ObjectId>, Vec<ObjectId>)> {
3277    let mut seen = HashSet::new();
3278    let mut order = Vec::new();
3279    let mut queue = VecDeque::new();
3280    for &start in starts {
3281        queue.push_back(start);
3282    }
3283    while let Some(oid) = queue.pop_front() {
3284        if excluded.contains(&oid) || seen.contains(&oid) {
3285            continue;
3286        }
3287        let parents = match graph.parents_of(oid) {
3288            Ok(parents) => parents,
3289            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
3290                if missing_action.reports_missing() && missing_seen.insert(oid) {
3291                    missing.push(oid);
3292                }
3293                continue;
3294            }
3295            Err(err) => return Err(err),
3296        };
3297        seen.insert(oid);
3298        order.push(oid);
3299        for parent in parents {
3300            queue.push_back(parent);
3301        }
3302    }
3303    Ok((seen, order))
3304}
3305
3306/// Date-priority walk used when a parent-count filter (`--no-merges`, `--max-parents`, …) is
3307/// active. Mirrors Git's `limit_list`: a pure committer-date priority queue seeded from the user
3308/// tips that traverses the *full* reachable set (`traversable`), stepping through dropped commits
3309/// (e.g. merges) without emitting them, and emits only commits in `emit`. Unlike [`date_order_walk`]
3310/// this imposes no topological gating, so a tip that is also an ancestor (and has a later date than
3311/// its descendants) is emitted in date order — matching `git rev-list --all --no-merges` (t6009).
3312fn date_order_walk_through_dropped(
3313    graph: &mut CommitGraph<'_>,
3314    tips: &[ObjectId],
3315    traversable: &HashSet<ObjectId>,
3316    emit: &HashSet<ObjectId>,
3317    author_dates: bool,
3318) -> Result<Vec<ObjectId>> {
3319    let mut heap = BinaryHeap::new();
3320    let mut queued: HashSet<ObjectId> = HashSet::new();
3321    let mut next_seq: u64 = 0;
3322    // Seed every user tip that is part of the reachable set, in their given order: an earlier tip
3323    // gets a smaller seq so equal-date tips are emitted in tip order (Git's insertion-order tiebreak).
3324    for &tip in tips {
3325        if traversable.contains(&tip) && queued.insert(tip) {
3326            heap.push(CommitDateKey {
3327                oid: tip,
3328                date: graph.sort_key(tip, author_dates),
3329                seq: next_seq,
3330            });
3331            next_seq += 1;
3332        }
3333    }
3334
3335    let mut emitted = HashSet::new();
3336    let mut out = Vec::with_capacity(emit.len());
3337    while let Some(item) = heap.pop() {
3338        if emit.contains(&item.oid) && emitted.insert(item.oid) {
3339            out.push(item.oid);
3340        }
3341        for parent in graph.parents_of(item.oid)? {
3342            if !traversable.contains(&parent) {
3343                continue;
3344            }
3345            if queued.insert(parent) {
3346                heap.push(CommitDateKey {
3347                    oid: parent,
3348                    date: graph.sort_key(parent, author_dates),
3349                    seq: next_seq,
3350                });
3351                next_seq += 1;
3352            }
3353        }
3354    }
3355
3356    Ok(out)
3357}
3358
3359/// Git-style default ordering: among commits ready to print, pick the one with the
3360/// greatest committer timestamp; a parent becomes ready only after all of its
3361/// children that remain in the walk have been emitted.
3362///
3363/// This matches `git rev-list` behavior (and differs from sorting the whole set by
3364/// date, which can surface ancestors before descendants when dates are skewed).
3365pub(crate) fn date_order_walk(
3366    graph: &mut CommitGraph<'_>,
3367    selected: &HashSet<ObjectId>,
3368    tip_order: &[ObjectId],
3369    author_dates: bool,
3370) -> Result<Vec<ObjectId>> {
3371    let mut unfinished_children: HashMap<ObjectId, usize> =
3372        selected.iter().map(|&oid| (oid, 0usize)).collect();
3373    for &child in selected {
3374        for parent in graph.parents_of(child)? {
3375            if selected.contains(&parent) {
3376                if let Some(count) = unfinished_children.get_mut(&parent) {
3377                    *count += 1;
3378                }
3379            }
3380        }
3381    }
3382
3383    let mut heap = BinaryHeap::new();
3384    // Equal-date ties break by *insertion order* to match Git's `prio_queue` FIFO tiebreak
3385    // (`prio-queue.c`): the tip the user (or pseudo-ref expansion like `--reflog`) listed first pops
3386    // first. `tip_order` carries that user/insertion order; each seeded tip below takes its index as
3387    // its `seq`, and parents discovered during the walk take a strictly larger `seq`.
3388    let ordered_tip_count = tip_order.len() as u64;
3389    // Git `limit_list` seeds `revs->commits` — every *explicitly given* tip — into the priority
3390    // queue up front, then pops by date and inserts unseen parents. It does NOT defer a parent until
3391    // its child is shown, so when the user lists an ancestor and its descendant as equal-date tips,
3392    // the one listed first is emitted first (`rev-list A B` with A an ancestor of B, same date → A,
3393    // then B). Replicate that: seed explicit tips immediately. When there are no explicit tips
3394    // (path-walk, where `selected` is the whole matched set and seeding everything would surface
3395    // ancestors before descendants), fall back to seeding only in-degree-zero commits so descendants
3396    // still precede ancestors. Parents discovered during the walk get a strictly larger seq.
3397    let mut seeded: HashSet<ObjectId> = HashSet::new();
3398    if tip_order.is_empty() {
3399        for &oid in selected {
3400            if unfinished_children.get(&oid).copied().unwrap_or(0) == 0 {
3401                heap.push(CommitDateKey {
3402                    oid,
3403                    date: graph.sort_key(oid, author_dates),
3404                    seq: 0,
3405                });
3406                seeded.insert(oid);
3407            }
3408        }
3409    } else {
3410        for (i, &oid) in tip_order.iter().enumerate() {
3411            if selected.contains(&oid) && seeded.insert(oid) {
3412                heap.push(CommitDateKey {
3413                    oid,
3414                    date: graph.sort_key(oid, author_dates),
3415                    seq: i as u64,
3416                });
3417            }
3418        }
3419    }
3420
3421    let mut next_seq: u64 = ordered_tip_count + 1;
3422    let mut emitted = HashSet::new();
3423    let mut out = Vec::with_capacity(selected.len());
3424    while let Some(item) = heap.pop() {
3425        if !emitted.insert(item.oid) {
3426            continue;
3427        }
3428        out.push(item.oid);
3429        for parent in graph.parents_of(item.oid)? {
3430            if !selected.contains(&parent) {
3431                continue;
3432            }
3433            let Some(count) = unfinished_children.get_mut(&parent) else {
3434                continue;
3435            };
3436            *count = count.saturating_sub(1);
3437            // Already-seeded commits (explicit tips) are in the heap; the pop-time `emitted` guard
3438            // dedups them. Only enqueue a parent once its last selected child has been processed and
3439            // it has not already been seeded as a tip.
3440            if *count == 0 && !seeded.contains(&parent) {
3441                heap.push(CommitDateKey {
3442                    oid: parent,
3443                    date: graph.sort_key(parent, author_dates),
3444                    seq: next_seq,
3445                });
3446                next_seq += 1;
3447            }
3448        }
3449    }
3450
3451    Ok(out)
3452}
3453
3454fn topo_sort(
3455    graph: &mut CommitGraph<'_>,
3456    selected: &HashSet<ObjectId>,
3457    author_dates: bool,
3458) -> Result<Vec<ObjectId>> {
3459    let mut child_count: HashMap<ObjectId, usize> = selected.iter().map(|&oid| (oid, 0)).collect();
3460
3461    for &oid in selected {
3462        for parent in graph.parents_of(oid)? {
3463            if !selected.contains(&parent) {
3464                continue;
3465            }
3466            if let Some(count) = child_count.get_mut(&parent) {
3467                *count += 1;
3468            }
3469        }
3470    }
3471
3472    // Git's `--topo-order`: among commits whose children have all been emitted, take the one
3473    // with the smallest committer date first (Kahn + min-heap). A max-heap on `CommitDateKey`
3474    // inverts this and breaks `rev-list --reverse --topo-order` vs upstream (t3425).
3475    let mut ready: BinaryHeap<Reverse<CommitDateKey>> = BinaryHeap::new();
3476    for (&oid, &count) in &child_count {
3477        if count == 0 {
3478            ready.push(Reverse(CommitDateKey {
3479                oid,
3480                date: graph.sort_key(oid, author_dates),
3481                seq: 0,
3482            }));
3483        }
3484    }
3485
3486    let mut out = Vec::with_capacity(selected.len());
3487    while let Some(Reverse(item)) = ready.pop() {
3488        let oid = item.oid;
3489        out.push(oid);
3490        for parent in graph.parents_of(oid)? {
3491            if !selected.contains(&parent) {
3492                continue;
3493            }
3494            if let Some(count) = child_count.get_mut(&parent) {
3495                *count = count.saturating_sub(1);
3496                if *count == 0 {
3497                    ready.push(Reverse(CommitDateKey {
3498                        oid: parent,
3499                        date: graph.sort_key(parent, author_dates),
3500                        seq: 0,
3501                    }));
3502                }
3503            }
3504        }
3505    }
3506
3507    reorder_adjacent_merge_parent_blocks(graph, &mut out)?;
3508    Ok(out)
3509}
3510
3511fn graph_order_topo_sort(
3512    graph: &mut CommitGraph<'_>,
3513    selected: &HashSet<ObjectId>,
3514    discovery_order: &[ObjectId],
3515) -> Result<Vec<ObjectId>> {
3516    let mut child_count: HashMap<ObjectId, usize> = selected.iter().map(|&oid| (oid, 0)).collect();
3517
3518    for &oid in selected {
3519        for parent in graph.parents_of(oid)? {
3520            if !selected.contains(&parent) {
3521                continue;
3522            }
3523            if let Some(count) = child_count.get_mut(&parent) {
3524                *count += 1;
3525            }
3526        }
3527    }
3528
3529    let discovery_rank: HashMap<ObjectId, usize> = discovery_order
3530        .iter()
3531        .copied()
3532        .enumerate()
3533        .map(|(rank, oid)| (oid, rank))
3534        .collect();
3535    let mut ready: Vec<ObjectId> = child_count
3536        .iter()
3537        .filter_map(|(&oid, &count)| (count == 0).then_some(oid))
3538        .collect();
3539
3540    ready.sort_by(|&a, &b| {
3541        graph
3542            .committer_time(a)
3543            .cmp(&graph.committer_time(b))
3544            .then_with(|| {
3545                discovery_rank
3546                    .get(&b)
3547                    .copied()
3548                    .unwrap_or(usize::MAX)
3549                    .cmp(&discovery_rank.get(&a).copied().unwrap_or(usize::MAX))
3550            })
3551            .then_with(|| a.cmp(&b))
3552    });
3553
3554    let mut out = Vec::with_capacity(selected.len());
3555    while let Some(oid) = ready.pop() {
3556        out.push(oid);
3557        for parent in graph.parents_of(oid)? {
3558            if !selected.contains(&parent) {
3559                continue;
3560            }
3561            if let Some(count) = child_count.get_mut(&parent) {
3562                *count = count.saturating_sub(1);
3563                if *count == 0 {
3564                    ready.push(parent);
3565                }
3566            }
3567        }
3568    }
3569
3570    reorder_adjacent_merge_parent_blocks(graph, &mut out)?;
3571    Ok(out)
3572}
3573
3574fn reorder_adjacent_merge_parent_blocks(
3575    graph: &mut CommitGraph<'_>,
3576    ordered: &mut [ObjectId],
3577) -> Result<()> {
3578    for index in 0..ordered.len() {
3579        let parents = graph.parents_of(ordered[index])?;
3580        if parents.len() <= 1 || index + parents.len() >= ordered.len() {
3581            continue;
3582        }
3583        let parent_set: HashSet<ObjectId> = parents.iter().copied().collect();
3584        let end = index + 1 + parents.len();
3585        if ordered[index + 1..end]
3586            .iter()
3587            .all(|oid| parent_set.contains(oid))
3588        {
3589            let rank: HashMap<ObjectId, usize> = parents
3590                .iter()
3591                .rev()
3592                .copied()
3593                .enumerate()
3594                .map(|(rank, oid)| (oid, rank))
3595                .collect();
3596            ordered[index + 1..end].sort_by_key(|oid| rank.get(oid).copied().unwrap_or(usize::MAX));
3597        }
3598    }
3599    Ok(())
3600}
3601
3602fn reorder_symmetric_topo_right_first(
3603    graph: &mut CommitGraph<'_>,
3604    ordered: &mut Vec<ObjectId>,
3605    left_oid: ObjectId,
3606    right_oid: ObjectId,
3607) -> Result<()> {
3608    if ordered.len() < 2 {
3609        return Ok(());
3610    }
3611
3612    let left_reach = walk_closure(graph, &[left_oid])?;
3613    let right_reach = walk_closure(graph, &[right_oid])?;
3614    let mut right_only = Vec::new();
3615    let mut rest = Vec::new();
3616
3617    for oid in ordered.drain(..) {
3618        if right_reach.contains(&oid) && !left_reach.contains(&oid) {
3619            right_only.push(oid);
3620        } else {
3621            rest.push(oid);
3622        }
3623    }
3624
3625    right_only.extend(rest);
3626    *ordered = right_only;
3627    Ok(())
3628}
3629
3630fn simplify_merges_commit_list(
3631    repo: &Repository,
3632    commits: &[ObjectId],
3633    paths: &[String],
3634    excluded: &HashSet<ObjectId>,
3635    ancestry_path_bottoms: &[ObjectId],
3636    ordering: OrderingMode,
3637    show_pulls: bool,
3638    preserve_graph_merges: bool,
3639) -> Result<Vec<ObjectId>> {
3640    let selected: HashSet<ObjectId> = commits.iter().copied().collect();
3641    let mut out = Vec::new();
3642    let mut simplified_parents: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
3643    for oid in commits {
3644        let raw_parents = load_commit(repo, *oid)?.parents;
3645        let rewritten = visible_parents_for_graph_lib(repo, *oid, &selected, false)?;
3646        let path_survives =
3647            path_merge_survives_simplify(repo, *oid, paths, excluded, ancestry_path_bottoms)?;
3648        let pull_merge = show_pulls
3649            && raw_parents.len() > 1
3650            && path_merge_survives_simplify(repo, *oid, paths, excluded, &[])?;
3651        if raw_parents.len() > 1 && rewritten.len() <= 1 {
3652            if rewritten.is_empty() || pull_merge || path_survives {
3653                simplified_parents.insert(*oid, rewritten);
3654                out.push(*oid);
3655            }
3656            continue;
3657        }
3658        if rewritten.len() <= 1 {
3659            simplified_parents.insert(*oid, rewritten);
3660            out.push(*oid);
3661            continue;
3662        }
3663        let mut simplified = if preserve_graph_merges {
3664            let treesame_rewritten = path_treesame_parents(repo, *oid, paths, &rewritten)?;
3665            let all_rewritten_treesame =
3666                !rewritten.is_empty() && treesame_rewritten.len() == rewritten.len();
3667            if all_rewritten_treesame {
3668                vec![rewritten[0]]
3669            } else {
3670                graph_simplify_parent_list_lib(repo, &selected, &rewritten, &treesame_rewritten)?
3671            }
3672        } else {
3673            graph_simplify_parent_list_lib(repo, &selected, &rewritten, &HashSet::new())?
3674        };
3675        simplified.sort_unstable();
3676        simplified.dedup();
3677        if simplified.len() > 1 || pull_merge {
3678            simplified_parents.insert(*oid, simplified);
3679            out.push(*oid);
3680        }
3681    }
3682    if ordering == OrderingMode::AuthorDateTopo {
3683        return simplify_merges_author_date_order(repo, &out, &simplified_parents);
3684    }
3685    Ok(out)
3686}
3687
3688fn simplify_merges_author_date_order(
3689    repo: &Repository,
3690    commits: &[ObjectId],
3691    simplified_parents: &HashMap<ObjectId, Vec<ObjectId>>,
3692) -> Result<Vec<ObjectId>> {
3693    let selected: HashSet<ObjectId> = commits.iter().copied().collect();
3694    let mut child_count: HashMap<ObjectId, usize> =
3695        commits.iter().copied().map(|oid| (oid, 0)).collect();
3696    for parents in simplified_parents.values() {
3697        for parent in parents {
3698            if selected.contains(parent) {
3699                if let Some(count) = child_count.get_mut(parent) {
3700                    *count += 1;
3701                }
3702            }
3703        }
3704    }
3705
3706    let mut graph = CommitGraph::new(repo, false);
3707    let mut ready = BinaryHeap::new();
3708    for oid in commits {
3709        if child_count.get(oid).copied().unwrap_or_default() == 0 {
3710            ready.push(CommitDateKey {
3711                oid: *oid,
3712                date: graph.sort_key(*oid, true),
3713                seq: 0,
3714            });
3715        }
3716    }
3717
3718    let mut out = Vec::with_capacity(commits.len());
3719    let mut emitted = HashSet::new();
3720    while let Some(item) = ready.pop() {
3721        if !emitted.insert(item.oid) {
3722            continue;
3723        }
3724        out.push(item.oid);
3725        if let Some(parents) = simplified_parents.get(&item.oid) {
3726            for parent in parents {
3727                if !selected.contains(parent) {
3728                    continue;
3729                }
3730                let Some(count) = child_count.get_mut(parent) else {
3731                    continue;
3732                };
3733                *count = count.saturating_sub(1);
3734                if *count == 0 {
3735                    ready.push(CommitDateKey {
3736                        oid: *parent,
3737                        date: graph.sort_key(*parent, true),
3738                        seq: 0,
3739                    });
3740                }
3741            }
3742        }
3743    }
3744    Ok(out)
3745}
3746
3747fn path_merge_survives_simplify(
3748    repo: &Repository,
3749    oid: ObjectId,
3750    paths: &[String],
3751    excluded: &HashSet<ObjectId>,
3752    ancestry_path_bottoms: &[ObjectId],
3753) -> Result<bool> {
3754    if paths.is_empty() {
3755        return Ok(false);
3756    }
3757    let commit = load_commit(repo, oid)?;
3758    if commit.parents.len() <= 1 {
3759        return Ok(false);
3760    }
3761    let commit_map: HashMap<String, (ObjectId, u32)> =
3762        flatten_tree_with_mode(repo, commit.tree, "")?
3763            .into_iter()
3764            .collect();
3765    let mut treesame_parents = 0usize;
3766    let mut first_parent_differs = false;
3767    let mut differing_parent_is_excluded = false;
3768    let mut differing_parent_oids = Vec::new();
3769    for (nth, parent_oid) in commit.parents.iter().enumerate() {
3770        let parent = load_commit(repo, *parent_oid)?;
3771        let parent_map: HashMap<String, (ObjectId, u32)> =
3772            flatten_tree_with_mode(repo, parent.tree, "")?
3773                .into_iter()
3774                .collect();
3775        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
3776        if differs {
3777            differing_parent_oids.push(*parent_oid);
3778            if nth == 0 {
3779                first_parent_differs = true;
3780            }
3781            if excluded.contains(parent_oid) {
3782                differing_parent_is_excluded = true;
3783            }
3784        } else {
3785            treesame_parents += 1;
3786        }
3787    }
3788    if treesame_parents != 1 {
3789        return Ok(false);
3790    }
3791    if first_parent_differs || differing_parent_is_excluded {
3792        return Ok(true);
3793    }
3794    if !ancestry_path_bottoms.is_empty() {
3795        let mut graph = CommitGraph::new(repo, false);
3796        return treesame_parent_is_on_ancestry_bottom_side(
3797            &mut graph,
3798            &differing_parent_oids,
3799            ancestry_path_bottoms,
3800        );
3801    }
3802    Ok(false)
3803}
3804
3805fn graph_simplify_parent_list_lib(
3806    repo: &Repository,
3807    selected: &HashSet<ObjectId>,
3808    parents: &[ObjectId],
3809    protected_treesame: &HashSet<ObjectId>,
3810) -> Result<Vec<ObjectId>> {
3811    let mut out = Vec::new();
3812    let protected_count = parents
3813        .iter()
3814        .filter(|parent| protected_treesame.contains(parent))
3815        .count();
3816    for parent in parents {
3817        if parent_reachable_via_others_lib(repo, selected, *parent, parents)? {
3818            if protected_count == 1 && protected_treesame.contains(parent) {
3819                out.push(*parent);
3820            }
3821            continue;
3822        }
3823        out.push(*parent);
3824    }
3825    Ok(out)
3826}
3827
3828fn path_treesame_parents(
3829    repo: &Repository,
3830    oid: ObjectId,
3831    paths: &[String],
3832    parents: &[ObjectId],
3833) -> Result<HashSet<ObjectId>> {
3834    let mut treesame = HashSet::new();
3835    if paths.is_empty() || parents.is_empty() {
3836        return Ok(treesame);
3837    }
3838
3839    let commit = load_commit(repo, oid)?;
3840    let commit_map: HashMap<String, (ObjectId, u32)> =
3841        flatten_tree_with_mode(repo, commit.tree, "")?
3842            .into_iter()
3843            .collect();
3844    for parent_oid in parents {
3845        let parent = load_commit(repo, *parent_oid)?;
3846        let parent_map: HashMap<String, (ObjectId, u32)> =
3847            flatten_tree_with_mode(repo, parent.tree, "")?
3848                .into_iter()
3849                .collect();
3850        if !path_differs_for_specs(&commit_map, &parent_map, paths) {
3851            treesame.insert(*parent_oid);
3852        }
3853    }
3854    Ok(treesame)
3855}
3856
3857fn parent_reachable_via_others_lib(
3858    repo: &Repository,
3859    selected: &HashSet<ObjectId>,
3860    target: ObjectId,
3861    parents: &[ObjectId],
3862) -> Result<bool> {
3863    for parent in parents {
3864        if *parent == target {
3865            continue;
3866        }
3867        if graph_reaches_lib(repo, selected, *parent, target)? {
3868            return Ok(true);
3869        }
3870    }
3871    Ok(false)
3872}
3873
3874fn graph_reaches_lib(
3875    repo: &Repository,
3876    selected: &HashSet<ObjectId>,
3877    start: ObjectId,
3878    target: ObjectId,
3879) -> Result<bool> {
3880    let mut stack = vec![start];
3881    let mut seen = HashSet::new();
3882    while let Some(oid) = stack.pop() {
3883        if !seen.insert(oid) {
3884            continue;
3885        }
3886        if oid == target {
3887            return Ok(true);
3888        }
3889        let mut parents = load_commit(repo, oid)?.parents;
3890        parents.retain(|p| selected.contains(p));
3891        stack.extend(parents);
3892    }
3893    Ok(false)
3894}
3895
3896fn load_raw_parents_lib(repo: &Repository, oid: ObjectId) -> Result<Vec<ObjectId>> {
3897    Ok(load_commit(repo, oid)?.parents)
3898}
3899
3900fn load_navigation_parents_lib(
3901    repo: &Repository,
3902    oid: ObjectId,
3903    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
3904) -> Result<Vec<ObjectId>> {
3905    if let Some(grafted) = grafts.get(&oid) {
3906        return Ok(grafted.clone());
3907    }
3908    load_raw_parents_lib(repo, oid)
3909}
3910
3911fn first_parent_of_commit_lib(
3912    repo: &Repository,
3913    oid: ObjectId,
3914    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
3915) -> Result<Option<ObjectId>> {
3916    let parents = load_navigation_parents_lib(repo, oid, grafts)?;
3917    Ok(parents.first().copied())
3918}
3919
3920fn first_parent_anchor_in_set_lib(
3921    repo: &Repository,
3922    start: ObjectId,
3923    anchors: &HashSet<ObjectId>,
3924    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
3925) -> Result<Option<ObjectId>> {
3926    let mut seen = HashSet::new();
3927    let mut cursor = Some(start);
3928    while let Some(oid) = cursor {
3929        if !seen.insert(oid) {
3930            break;
3931        }
3932        if anchors.contains(&oid) {
3933            return Ok(Some(oid));
3934        }
3935        cursor = first_parent_of_commit_lib(repo, oid, grafts)?;
3936    }
3937    Ok(None)
3938}
3939
3940fn collect_visible_parent_for_graph_lib(
3941    repo: &Repository,
3942    candidate: ObjectId,
3943    included: &HashSet<ObjectId>,
3944    first_parent_only: bool,
3945    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
3946    seen: &mut HashSet<ObjectId>,
3947    out: &mut Vec<ObjectId>,
3948) -> Result<()> {
3949    if !seen.insert(candidate) {
3950        return Ok(());
3951    }
3952    if included.contains(&candidate) {
3953        out.push(candidate);
3954        return Ok(());
3955    }
3956    let mut parents = load_navigation_parents_lib(repo, candidate, grafts)?;
3957    if parents.is_empty() {
3958        return Ok(());
3959    }
3960    if parents.len() > 1 {
3961        parents.truncate(1);
3962    }
3963    for parent in parents {
3964        collect_visible_parent_for_graph_lib(
3965            repo,
3966            parent,
3967            included,
3968            first_parent_only,
3969            grafts,
3970            seen,
3971            out,
3972        )?;
3973    }
3974    Ok(())
3975}
3976
3977fn visible_parents_for_graph_lib(
3978    repo: &Repository,
3979    oid: ObjectId,
3980    included: &HashSet<ObjectId>,
3981    first_parent_only: bool,
3982) -> Result<Vec<ObjectId>> {
3983    let grafts = crate::rev_parse::load_graft_parents(&repo.git_dir);
3984    let mut direct = load_navigation_parents_lib(repo, oid, &grafts)?;
3985    if first_parent_only && direct.len() > 1 {
3986        direct.truncate(1);
3987    }
3988    let mut seen = HashSet::new();
3989    let mut out = Vec::new();
3990    for parent in direct {
3991        collect_visible_parent_for_graph_lib(
3992            repo,
3993            parent,
3994            included,
3995            first_parent_only,
3996            &grafts,
3997            &mut seen,
3998            &mut out,
3999        )?;
4000    }
4001    let mut dedup = HashSet::new();
4002    out.retain(|parent| dedup.insert(*parent));
4003    Ok(out)
4004}
4005
4006fn reorder_path_limited_graph_commits(
4007    repo: &Repository,
4008    commits: &[ObjectId],
4009    first_parent_only: bool,
4010) -> Result<Vec<ObjectId>> {
4011    if commits.is_empty() {
4012        return Ok(Vec::new());
4013    }
4014
4015    // For a default (non `--first-parent`) dense path-limited walk Git emits commits in
4016    // commit-date priority order: when it pops a merge it inserts *all* of its parents into
4017    // the date-sorted pending list, so a newer side branch is shown before an older
4018    // first-parent ancestor. The caller already produced this order via `date_order_walk`,
4019    // so reshuffling by first-parent topology here would be wrong (it would sink a newer
4020    // side commit below the older first-parent spine — t4013 `log --patch-with-stat main --
4021    // dir/`). Only the `--first-parent` walk wants the first-parent spine grouping below.
4022    if !first_parent_only {
4023        return Ok(commits.to_vec());
4024    }
4025
4026    let included: HashSet<ObjectId> = commits.iter().copied().collect();
4027    let grafts = crate::rev_parse::load_graft_parents(&repo.git_dir);
4028    let mut chain = Vec::new();
4029    let mut chain_seen = HashSet::new();
4030    let mut cursor = Some(commits[0]);
4031    while let Some(oid) = cursor {
4032        if !included.contains(&oid) || !chain_seen.insert(oid) {
4033            break;
4034        }
4035        chain.push(oid);
4036        let visible = visible_parents_for_graph_lib(repo, oid, &included, first_parent_only)?;
4037        cursor = visible.first().copied();
4038    }
4039
4040    let chain_set: HashSet<ObjectId> = chain.iter().copied().collect();
4041    let mut grouped: HashMap<Option<ObjectId>, Vec<ObjectId>> = HashMap::new();
4042    for oid in commits {
4043        if chain_set.contains(oid) {
4044            continue;
4045        }
4046        let anchor = first_parent_anchor_in_set_lib(repo, *oid, &chain_set, &grafts)?;
4047        grouped.entry(anchor).or_default().push(*oid);
4048    }
4049
4050    let mut ordered = Vec::new();
4051    for chain_oid in chain {
4052        if let Some(group) = grouped.remove(&Some(chain_oid)) {
4053            ordered.extend(group);
4054        }
4055        ordered.push(chain_oid);
4056    }
4057    if let Some(group) = grouped.remove(&None) {
4058        ordered.extend(group);
4059    }
4060    for (_anchor, group) in grouped {
4061        ordered.extend(group);
4062    }
4063    Ok(ordered)
4064}
4065
4066fn expand_sparse_path_limited_graph_history(
4067    repo: &Repository,
4068    commits: &[ObjectId],
4069) -> Result<Vec<ObjectId>> {
4070    if commits.is_empty() {
4071        return Ok(Vec::new());
4072    }
4073
4074    let mut expanded = Vec::new();
4075    let mut seen = HashSet::new();
4076    let grafts = crate::rev_parse::load_graft_parents(&repo.git_dir);
4077    let mut push_unique = |oid: ObjectId, out: &mut Vec<ObjectId>| {
4078        if seen.insert(oid) {
4079            out.push(oid);
4080        }
4081    };
4082
4083    for window in commits.windows(2) {
4084        let from = window[0];
4085        let to = window[1];
4086        push_unique(from, &mut expanded);
4087
4088        let mut cursor = first_parent_of_commit_lib(repo, from, &grafts)?;
4089        let mut chain = Vec::new();
4090        let mut found_target = false;
4091        let mut local_seen = HashSet::new();
4092        while let Some(oid) = cursor {
4093            if !local_seen.insert(oid) {
4094                break;
4095            }
4096            if oid == to {
4097                found_target = true;
4098                break;
4099            }
4100            chain.push(oid);
4101            cursor = first_parent_of_commit_lib(repo, oid, &grafts)?;
4102        }
4103        if found_target {
4104            for oid in chain {
4105                push_unique(oid, &mut expanded);
4106            }
4107        }
4108    }
4109
4110    if let Some(&last) = commits.last() {
4111        push_unique(last, &mut expanded);
4112        let mut cursor = first_parent_of_commit_lib(repo, last, &grafts)?;
4113        let mut tail_seen = HashSet::new();
4114        while let Some(oid) = cursor {
4115            if !tail_seen.insert(oid) {
4116                break;
4117            }
4118            push_unique(oid, &mut expanded);
4119            cursor = first_parent_of_commit_lib(repo, oid, &grafts)?;
4120        }
4121    }
4122
4123    Ok(expanded)
4124}
4125
4126fn limit_to_ancestry(
4127    graph: &mut CommitGraph<'_>,
4128    selected: &mut HashSet<ObjectId>,
4129    bottoms: &[ObjectId],
4130) -> Result<()> {
4131    let mut keep = HashSet::new();
4132
4133    for &bottom in bottoms {
4134        let ancestors = walk_closure(graph, &[bottom])?;
4135        keep.extend(
4136            ancestors
4137                .iter()
4138                .copied()
4139                .filter(|oid| selected.contains(oid)),
4140        );
4141    }
4142
4143    let mut descendants: HashSet<ObjectId> = bottoms.iter().copied().collect();
4144    loop {
4145        let mut made_progress = false;
4146        for &candidate in selected.iter() {
4147            if descendants.contains(&candidate) {
4148                continue;
4149            }
4150
4151            let parents = graph.parents_of(candidate)?;
4152            if parents.iter().any(|parent| descendants.contains(parent)) {
4153                descendants.insert(candidate);
4154                made_progress = true;
4155            }
4156        }
4157
4158        if !made_progress {
4159            break;
4160        }
4161    }
4162
4163    keep.extend(
4164        descendants
4165            .iter()
4166            .copied()
4167            .filter(|oid| selected.contains(oid)),
4168    );
4169    selected.retain(|oid| keep.contains(oid));
4170    Ok(())
4171}
4172
4173/// Check if a commit modifies any of the given paths compared to its parents.
4174///
4175/// `simplify_merges` / `show_pulls` mirror Git's path-limited `try_to_simplify_commit` behavior
4176/// for merge commits when `--full-history` is active (including the implicit full history from
4177/// `--simplify-merges` or `--ancestry-path`).
4178#[allow(clippy::too_many_arguments)]
4179fn commit_touches_paths(
4180    repo: &Repository,
4181    graph: &mut CommitGraph<'_>,
4182    oid: ObjectId,
4183    paths: &[String],
4184    full_history: bool,
4185    sparse: bool,
4186    simplify_merges: bool,
4187    preserve_simplify_merges_graph_merges: bool,
4188    show_pulls: bool,
4189    parent_rewrite: bool,
4190    ancestry_path: bool,
4191    ancestry_path_bottoms: &[ObjectId],
4192    excluded: &HashSet<ObjectId>,
4193    bloom_chain: Option<&CommitGraphChain>,
4194    read_changed_paths: bool,
4195    changed_paths_version: i32,
4196    bloom_stats: Option<&BloomWalkStatsHandle>,
4197    bloom_cwd: Option<&str>,
4198) -> Result<bool> {
4199    let commit = load_commit(repo, oid)?;
4200    let parents = graph.parents_of(oid)?;
4201    let commit_entries = flatten_tree_with_mode(repo, commit.tree, "")?;
4202    let commit_map: HashMap<String, (ObjectId, u32)> = commit_entries.into_iter().collect();
4203
4204    // Root commit: include only when any requested pathspec exists.
4205    if parents.is_empty() {
4206        if sparse {
4207            return Ok(true);
4208        }
4209        let ctx = crate::pathspec::PathspecMatchContext {
4210            is_directory: false,
4211            is_git_submodule: false,
4212        };
4213        return Ok(commit_map
4214            .keys()
4215            .any(|path| crate::pathspec::matches_pathspec_list_with_context(path, paths, ctx)));
4216    }
4217
4218    // Single-parent commit: include only when requested paths changed.
4219    if parents.len() == 1 {
4220        let mut bloom_ret = BloomPrecheck::Inapplicable;
4221        if let Some(chain) = bloom_chain {
4222            bloom_ret = chain.bloom_precheck_for_paths(
4223                &repo.odb,
4224                oid,
4225                paths,
4226                bloom_cwd,
4227                changed_paths_version,
4228                read_changed_paths,
4229            )?;
4230            if let Some(stats) = bloom_stats {
4231                if let Ok(mut g) = stats.lock() {
4232                    g.record_precheck(bloom_ret);
4233                }
4234            }
4235            if bloom_ret == BloomPrecheck::DefinitelyNot {
4236                return Ok(false);
4237            }
4238        }
4239
4240        let parent = load_commit(repo, parents[0])?;
4241        let parent_map: HashMap<String, (ObjectId, u32)> =
4242            flatten_tree_with_mode(repo, parent.tree, "")?
4243                .into_iter()
4244                .collect();
4245        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
4246        if bloom_ret == BloomPrecheck::Maybe && !differs {
4247            if let Some(stats) = bloom_stats {
4248                if let Ok(mut g) = stats.lock() {
4249                    g.record_false_positive();
4250                }
4251            }
4252        }
4253        if differs {
4254            return Ok(true);
4255        }
4256        if sparse {
4257            return Ok(true);
4258        }
4259        return Ok(false);
4260    }
4261
4262    // Merge commit: dense history omits the merge when exactly one parent is TREESAME.
4263    let mut treesame_parents = 0usize;
4264    let mut treesame_parent_oids = Vec::new();
4265    let mut differing_parent_oids = Vec::new();
4266    let mut differs_any = false;
4267    let mut first_parent_differs = false;
4268    for (nth, parent_oid) in parents.iter().enumerate() {
4269        // Git consults the changed-path Bloom filter only when comparing against the
4270        // first parent (`nth_parent == 0` in `rev_compare_tree`). This applies to merge
4271        // commits too: a `DefinitelyNot` result short-circuits the first-parent tree diff
4272        // and is recorded in the Bloom statistics counters just like for single-parent
4273        // commits.
4274        let mut bloom_ret = BloomPrecheck::Inapplicable;
4275        if nth == 0 {
4276            if let Some(chain) = bloom_chain {
4277                bloom_ret = chain.bloom_precheck_for_paths(
4278                    &repo.odb,
4279                    oid,
4280                    paths,
4281                    bloom_cwd,
4282                    changed_paths_version,
4283                    read_changed_paths,
4284                )?;
4285                if let Some(stats) = bloom_stats {
4286                    if let Ok(mut g) = stats.lock() {
4287                        g.record_precheck(bloom_ret);
4288                    }
4289                }
4290            }
4291        }
4292
4293        let differs = if bloom_ret == BloomPrecheck::DefinitelyNot {
4294            false
4295        } else {
4296            let parent = load_commit(repo, *parent_oid)?;
4297            let parent_map: HashMap<String, (ObjectId, u32)> =
4298                flatten_tree_with_mode(repo, parent.tree, "")?
4299                    .into_iter()
4300                    .collect();
4301            path_differs_for_specs(&commit_map, &parent_map, paths)
4302        };
4303        if nth == 0 {
4304            first_parent_differs = differs;
4305            if bloom_ret == BloomPrecheck::Maybe && !differs {
4306                if let Some(stats) = bloom_stats {
4307                    if let Ok(mut g) = stats.lock() {
4308                        g.record_false_positive();
4309                    }
4310                }
4311            }
4312        }
4313        if differs {
4314            differs_any = true;
4315            differing_parent_oids.push(*parent_oid);
4316        } else {
4317            treesame_parents += 1;
4318            treesame_parent_oids.push(*parent_oid);
4319        }
4320    }
4321
4322    if ancestry_path
4323        && !ancestry_path_bottoms.is_empty()
4324        && if parent_rewrite {
4325            treesame_parents != parents.len()
4326                && treesame_parent_is_ancestry_bottom_or_ancestor(
4327                    graph,
4328                    &treesame_parent_oids,
4329                    ancestry_path_bottoms,
4330                )?
4331        } else {
4332            treesame_parent_is_on_ancestry_bottom_side(
4333                graph,
4334                &treesame_parent_oids,
4335                ancestry_path_bottoms,
4336            )?
4337        }
4338    {
4339        return Ok(sparse);
4340    }
4341
4342    // `--full-history` without parent rewriting still omits merges that are TREESAME to every
4343    // parent. With parent rewriting (`--parents`, `--children`, or `%P`/`%p`) Git keeps those
4344    // merges so their rewritten edges can explain the selected history.
4345    if full_history && !simplify_merges && parents.len() > 1 && treesame_parents == parents.len() {
4346        return Ok(parent_rewrite || sparse);
4347    }
4348
4349    if show_pulls && first_parent_differs && treesame_parents > 0 {
4350        return Ok(true);
4351    }
4352
4353    if !full_history && treesame_parents == 1 {
4354        return Ok(false);
4355    }
4356
4357    if full_history && simplify_merges {
4358        if preserve_simplify_merges_graph_merges {
4359            return Ok(true);
4360        }
4361        if treesame_parents == parents.len() {
4362            return Ok(true);
4363        }
4364        if treesame_parents > 0 && !differs_any {
4365            return Ok(true);
4366        }
4367        if show_pulls && first_parent_differs && treesame_parents > 0 {
4368            return Ok(true);
4369        }
4370        if treesame_parents == 1 {
4371            if ancestry_path
4372                && !ancestry_path_bottoms.is_empty()
4373                && treesame_parent_is_on_ancestry_bottom_side(
4374                    graph,
4375                    &differing_parent_oids,
4376                    ancestry_path_bottoms,
4377                )?
4378            {
4379                return Ok(true);
4380            }
4381            return Ok(first_parent_differs
4382                || differing_parent_oids
4383                    .iter()
4384                    .any(|parent| excluded.contains(parent)));
4385        }
4386        return Ok(differs_any || sparse);
4387    }
4388
4389    if differs_any {
4390        return Ok(true);
4391    }
4392
4393    Ok(sparse)
4394}
4395
4396fn treesame_parent_is_on_ancestry_bottom_side(
4397    graph: &mut CommitGraph<'_>,
4398    treesame_parents: &[ObjectId],
4399    bottoms: &[ObjectId],
4400) -> Result<bool> {
4401    if treesame_parents.is_empty() {
4402        return Ok(false);
4403    }
4404    let treesame: HashSet<ObjectId> = treesame_parents.iter().copied().collect();
4405    for bottom in bottoms {
4406        if treesame.contains(bottom) {
4407            return Ok(true);
4408        }
4409        for parent in treesame_parents {
4410            let parent_closure = walk_closure(graph, &[*parent])?;
4411            if parent_closure.contains(bottom) {
4412                return Ok(true);
4413            }
4414        }
4415    }
4416    Ok(false)
4417}
4418
4419fn treesame_parent_is_ancestry_bottom_or_ancestor(
4420    graph: &mut CommitGraph<'_>,
4421    treesame_parents: &[ObjectId],
4422    bottoms: &[ObjectId],
4423) -> Result<bool> {
4424    if treesame_parents.is_empty() {
4425        return Ok(false);
4426    }
4427    let treesame: HashSet<ObjectId> = treesame_parents.iter().copied().collect();
4428    for bottom in bottoms {
4429        if treesame.contains(bottom) {
4430            return Ok(true);
4431        }
4432        let bottom_closure = walk_closure(graph, &[*bottom])?;
4433        if bottom_closure.iter().any(|oid| treesame.contains(oid)) {
4434            return Ok(true);
4435        }
4436    }
4437    Ok(false)
4438}
4439
4440/// Bloom-filter context threaded through the dense path-limited walk so that the
4441/// changed-path Bloom precheck (Git's `rev_compare_tree` first-parent shortcut) and its
4442/// `GIT_TRACE2_PERF` statistics are recorded during the single real walk, exactly as Git
4443/// does — rather than during a later re-check of an already-reduced set.
4444#[derive(Clone, Copy)]
4445struct DenseBloomCtx<'a> {
4446    chain: Option<&'a CommitGraphChain>,
4447    read_changed_paths: bool,
4448    changed_paths_version: i32,
4449    stats: Option<&'a BloomWalkStatsHandle>,
4450    cwd: Option<&'a str>,
4451}
4452
4453#[allow(clippy::too_many_arguments)]
4454fn walk_dense_path_limited_closure(
4455    repo: &Repository,
4456    graph: &mut CommitGraph<'_>,
4457    tips: &[ObjectId],
4458    excluded: &HashSet<ObjectId>,
4459    paths: &[String],
4460    sparse: bool,
4461    show_pulls: bool,
4462) -> Result<HashSet<ObjectId>> {
4463    let mut walked = HashSet::new();
4464    let mut selected = HashSet::new();
4465    let mut queue: VecDeque<ObjectId> = tips.iter().copied().collect();
4466
4467    while let Some(oid) = queue.pop_front() {
4468        if excluded.contains(&oid) || !walked.insert(oid) {
4469            continue;
4470        }
4471
4472        let action = dense_path_limited_action(repo, graph, oid, paths, sparse, show_pulls)?;
4473        if action.visible {
4474            selected.insert(oid);
4475        }
4476
4477        for parent in action.parents_to_walk {
4478            if !excluded.contains(&parent) && !walked.contains(&parent) {
4479                queue.push_back(parent);
4480            }
4481        }
4482    }
4483
4484    Ok(selected)
4485}
4486
4487/// Consult the changed-path Bloom filter over the full reachable set, matching Git's walk.
4488///
4489/// Git consults the Bloom filter for the first-parent comparison of *every* commit it walks in
4490/// `limit_list` via `rev_compare_tree`/`check_maybe_different_in_bloom_filter` (even commits later
4491/// pruned by TREESAME merge simplification). This happens unconditionally — not just when
4492/// `GIT_TRACE2_PERF` statistics are being collected — so loading the filter for each commit is also
4493/// what surfaces the reader's integrity warnings (out-of-range / decreasing index offsets) for a
4494/// corrupt commit-graph. grit's dense path-limited selection walk prunes the traversal (it follows
4495/// only the TREESAME parent of a merge) and then filters an already-reduced set, so on its own it
4496/// would consult far fewer filters than Git and miss both the statistics and these warnings.
4497///
4498/// This pass therefore runs over `reachable` — the full reachable set before dense limiting — and
4499/// looks up each commit's first-parent Bloom filter exactly once, mirroring Git's single
4500/// `limit_list` pass. When a `BloomWalkStats` handle is present it records the
4501/// `filter_not_present` / `maybe` / `definitely_not` / `false_positive` counters; the pass runs even
4502/// without one so corrupt-graph warnings still fire. It never changes which commits are selected for
4503/// output (a `DefinitelyNot` only confirms what the tree diff would also conclude).
4504fn consult_dense_bloom_filters(
4505    repo: &Repository,
4506    graph: &mut CommitGraph<'_>,
4507    reachable: &HashSet<ObjectId>,
4508    paths: &[String],
4509    bloom: &DenseBloomCtx<'_>,
4510) -> Result<()> {
4511    let Some(chain) = bloom.chain else {
4512        return Ok(());
4513    };
4514    for oid in reachable {
4515        let parents = graph.parents_of(*oid)?;
4516        // Bloom is consulted only against the first parent (Git `rev_compare_tree`,
4517        // `nth_parent == 0`); root commits have no first-parent comparison.
4518        let Some(first_parent) = parents.first().copied() else {
4519            continue;
4520        };
4521        let pre = chain.bloom_precheck_for_paths(
4522            &repo.odb,
4523            *oid,
4524            paths,
4525            bloom.cwd,
4526            bloom.changed_paths_version,
4527            bloom.read_changed_paths,
4528        )?;
4529        if let Some(stats) = bloom.stats {
4530            if let Ok(mut g) = stats.lock() {
4531                g.record_precheck(pre);
4532            }
4533        }
4534        // A `Maybe` result that turns out not to touch the path (tree-same against the first
4535        // parent) is a Bloom false positive in Git's accounting. Only computed when stats are
4536        // being collected, since it requires an extra tree diff that is otherwise unnecessary.
4537        if pre == BloomPrecheck::Maybe {
4538            if let Some(stats) = bloom.stats {
4539                let commit = load_commit(repo, *oid)?;
4540                let commit_map: HashMap<String, (ObjectId, u32)> =
4541                    flatten_tree_with_mode(repo, commit.tree, "")?
4542                        .into_iter()
4543                        .collect();
4544                let parent = load_commit(repo, first_parent)?;
4545                let parent_map: HashMap<String, (ObjectId, u32)> =
4546                    flatten_tree_with_mode(repo, parent.tree, "")?
4547                        .into_iter()
4548                        .collect();
4549                if !path_differs_for_specs(&commit_map, &parent_map, paths) {
4550                    if let Ok(mut g) = stats.lock() {
4551                        g.record_false_positive();
4552                    }
4553                }
4554            }
4555        }
4556    }
4557    Ok(())
4558}
4559
4560struct DensePathAction {
4561    visible: bool,
4562    parents_to_walk: Vec<ObjectId>,
4563}
4564
4565fn dense_path_limited_action(
4566    repo: &Repository,
4567    graph: &mut CommitGraph<'_>,
4568    oid: ObjectId,
4569    paths: &[String],
4570    sparse: bool,
4571    show_pulls: bool,
4572) -> Result<DensePathAction> {
4573    let commit = load_commit(repo, oid)?;
4574    let parents = graph.parents_of(oid)?;
4575    let commit_map: HashMap<String, (ObjectId, u32)> =
4576        flatten_tree_with_mode(repo, commit.tree, "")?
4577            .into_iter()
4578            .collect();
4579
4580    if parents.is_empty() {
4581        let ctx = crate::pathspec::PathspecMatchContext {
4582            is_directory: false,
4583            is_git_submodule: false,
4584        };
4585        let visible = sparse
4586            || commit_map
4587                .keys()
4588                .any(|path| crate::pathspec::matches_pathspec_list_with_context(path, paths, ctx));
4589        return Ok(DensePathAction {
4590            visible,
4591            parents_to_walk: Vec::new(),
4592        });
4593    }
4594
4595    let mut treesame = Vec::new();
4596    let mut differs_any = false;
4597    let mut first_parent_differs = false;
4598    for (nth, parent_oid) in parents.iter().enumerate() {
4599        let parent = load_commit(repo, *parent_oid)?;
4600        let parent_map: HashMap<String, (ObjectId, u32)> =
4601            flatten_tree_with_mode(repo, parent.tree, "")?
4602                .into_iter()
4603                .collect();
4604        if path_differs_for_specs(&commit_map, &parent_map, paths) {
4605            if nth == 0 {
4606                first_parent_differs = true;
4607            }
4608            differs_any = true;
4609        } else {
4610            treesame.push(*parent_oid);
4611        }
4612    }
4613
4614    let parents_to_walk = if parents.len() > 1 {
4615        match treesame.first().copied() {
4616            Some(parent) => vec![parent],
4617            None => parents.clone(),
4618        }
4619    } else {
4620        parents.clone()
4621    };
4622
4623    let visible = if sparse {
4624        true
4625    } else if parents.len() > 1 {
4626        treesame.is_empty() || (show_pulls && first_parent_differs && !treesame.is_empty())
4627    } else {
4628        differs_any
4629    };
4630
4631    Ok(DensePathAction {
4632        visible,
4633        parents_to_walk,
4634    })
4635}
4636
4637/// Whether `oid` would be included in a dense path-limited history walk for `paths`.
4638///
4639/// Matches the non-Bloom parts of [`commit_touches_paths`] with `full_history = false` and
4640/// `sparse = false`: single-parent commits require a tree change on `paths` vs their parent;
4641/// merge commits are omitted when exactly one parent is tree-same on `paths` (Git `TREESAME`
4642/// simplification). Used by `log -g -- <path>` to align with Git's reflog path filtering.
4643pub fn commit_visible_for_dense_pathspecs(
4644    repo: &Repository,
4645    oid: ObjectId,
4646    paths: &[String],
4647) -> Result<bool> {
4648    if paths.is_empty() {
4649        return Ok(true);
4650    }
4651    let commit = load_commit(repo, oid)?;
4652    let parents = commit.parents.clone();
4653    let commit_entries = flatten_tree_with_mode(repo, commit.tree, "")?;
4654    let commit_map: HashMap<String, (ObjectId, u32)> = commit_entries.into_iter().collect();
4655
4656    if parents.is_empty() {
4657        return Ok(commit_map.keys().any(|path| {
4658            paths.iter().any(|spec| {
4659                crate::pathspec::matches_pathspec_with_context(
4660                    spec,
4661                    path,
4662                    crate::pathspec::PathspecMatchContext {
4663                        is_directory: false,
4664                        is_git_submodule: false,
4665                    },
4666                )
4667            })
4668        }));
4669    }
4670
4671    if parents.len() == 1 {
4672        let parent = load_commit(repo, parents[0])?;
4673        let parent_map: HashMap<String, (ObjectId, u32)> =
4674            flatten_tree_with_mode(repo, parent.tree, "")?
4675                .into_iter()
4676                .collect();
4677        return Ok(path_differs_for_specs(&commit_map, &parent_map, paths));
4678    }
4679
4680    let mut treesame_parents = 0usize;
4681    let mut differs_any = false;
4682    for parent_oid in &parents {
4683        let parent = load_commit(repo, *parent_oid)?;
4684        let parent_map: HashMap<String, (ObjectId, u32)> =
4685            flatten_tree_with_mode(repo, parent.tree, "")?
4686                .into_iter()
4687                .collect();
4688        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
4689        if differs {
4690            differs_any = true;
4691        } else {
4692            treesame_parents += 1;
4693        }
4694    }
4695
4696    if treesame_parents == 1 {
4697        return Ok(false);
4698    }
4699    if differs_any {
4700        return Ok(true);
4701    }
4702    Ok(false)
4703}
4704
4705fn compute_cherry_patch_id(
4706    repo: &Repository,
4707    oid: &ObjectId,
4708    paths: &[String],
4709) -> Result<Option<ObjectId>> {
4710    if paths.is_empty() {
4711        compute_patch_id(&repo.odb, oid)
4712    } else {
4713        compute_patch_id_for_paths(&repo.odb, oid, paths)
4714    }
4715}
4716
4717fn path_differs_for_specs(
4718    current: &HashMap<String, (ObjectId, u32)>,
4719    parent: &HashMap<String, (ObjectId, u32)>,
4720    specs: &[String],
4721) -> bool {
4722    let mut paths = std::collections::BTreeSet::new();
4723    paths.extend(current.keys().cloned());
4724    paths.extend(parent.keys().cloned());
4725
4726    for path in &paths {
4727        if !crate::pathspec::matches_pathspec_list(path, specs) {
4728            continue;
4729        }
4730        if current.get(path) != parent.get(path) {
4731            return true;
4732        }
4733    }
4734    false
4735}
4736
4737fn load_commit(repo: &Repository, oid: ObjectId) -> Result<crate::objects::CommitData> {
4738    let object = repo.odb.read(&oid)?;
4739    if object.kind != ObjectKind::Commit {
4740        return Err(Error::CorruptObject(format!(
4741            "object {oid} is not a commit"
4742        )));
4743    }
4744    parse_commit(&object.data)
4745}
4746
4747fn extend_split_token(
4748    token: &str,
4749    not_mode: bool,
4750    positive: &mut Vec<String>,
4751    negative: &mut Vec<String>,
4752) {
4753    let (pos, neg) = split_revision_token(token);
4754    if not_mode {
4755        positive.extend(neg);
4756        negative.extend(pos);
4757    } else {
4758        positive.extend(pos);
4759        negative.extend(neg);
4760    }
4761}
4762
4763/// Git `parse_long_opt`-style parsing for a single argv vector (used for `--stdin` lines).
4764///
4765/// Returns `Some((consumed_arg_count, value))` when `line` is `--<opt>` or `--<opt>=...`.
4766/// Consumed count is 1 for stuck form, 2 for detached form (caller must supply next line as value).
4767fn parse_long_opt_value(opt: &str, argv0: &str, argv1: Option<&str>) -> Option<(usize, String)> {
4768    let rest = argv0.strip_prefix("--")?;
4769    let rest = rest.strip_prefix(opt)?;
4770    if let Some(stripped) = rest.strip_prefix('=') {
4771        return Some((1, stripped.to_owned()));
4772    }
4773    if !rest.is_empty() {
4774        return None;
4775    }
4776    let Some(next) = argv1 else {
4777        return None;
4778    };
4779    Some((2, next.to_owned()))
4780}
4781
4782fn stdin_die_requires_value(opt: &str) -> Error {
4783    Error::Message(format!("fatal: Option '{opt}' requires a value"))
4784}
4785
4786fn apply_stdin_pseudo_opt(
4787    git_dir: &Path,
4788    line: &str,
4789    next_line: Option<&str>,
4790    not_mode: bool,
4791    positive: &mut Vec<String>,
4792    negative: &mut Vec<String>,
4793    stdin_all_refs: &mut bool,
4794) -> Result<Option<usize>> {
4795    if line == "--end-of-options" {
4796        return Ok(Some(1));
4797    }
4798    if line == "--all" {
4799        if not_mode {
4800            for (_, oid) in refs::list_refs(git_dir, "refs/")? {
4801                let s = oid.to_hex();
4802                negative.push(s);
4803            }
4804            if let Ok(head_oid) = refs::resolve_ref(git_dir, "HEAD") {
4805                negative.push(head_oid.to_hex());
4806            }
4807        } else {
4808            *stdin_all_refs = true;
4809        }
4810        return Ok(Some(1));
4811    }
4812    if line == "--not" {
4813        return Ok(Some(1));
4814    }
4815    if line == "--branches" {
4816        for (_, oid) in refs::list_refs(git_dir, "refs/heads/")? {
4817            let s = oid.to_hex();
4818            if not_mode {
4819                negative.push(s);
4820            } else {
4821                positive.push(s);
4822            }
4823        }
4824        return Ok(Some(1));
4825    }
4826    if line == "--tags" {
4827        for (_, oid) in refs::list_refs(git_dir, "refs/tags/")? {
4828            let s = oid.to_hex();
4829            if not_mode {
4830                negative.push(s);
4831            } else {
4832                positive.push(s);
4833            }
4834        }
4835        return Ok(Some(1));
4836    }
4837    if line == "--remotes" {
4838        for (_, oid) in refs::list_refs(git_dir, "refs/remotes/")? {
4839            let s = oid.to_hex();
4840            if not_mode {
4841                negative.push(s);
4842            } else {
4843                positive.push(s);
4844            }
4845        }
4846        return Ok(Some(1));
4847    }
4848    if let Some((consumed, pattern)) = parse_long_opt_value("branches", line, next_line) {
4849        let full_pattern = format!("refs/heads/{pattern}");
4850        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
4851            let s = oid.to_hex();
4852            if not_mode {
4853                negative.push(s);
4854            } else {
4855                positive.push(s);
4856            }
4857        }
4858        return Ok(Some(consumed));
4859    }
4860    if let Some((1, pattern)) = parse_long_opt_value("tags", line, None) {
4861        let full_pattern = format!("refs/tags/{pattern}");
4862        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
4863            let s = oid.to_hex();
4864            if not_mode {
4865                negative.push(s);
4866            } else {
4867                positive.push(s);
4868            }
4869        }
4870        return Ok(Some(1));
4871    }
4872    if let Some((consumed, pattern)) = parse_long_opt_value("tags", line, next_line) {
4873        let full_pattern = format!("refs/tags/{pattern}");
4874        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
4875            let s = oid.to_hex();
4876            if not_mode {
4877                negative.push(s);
4878            } else {
4879                positive.push(s);
4880            }
4881        }
4882        return Ok(Some(consumed));
4883    }
4884    if let Some((1, pattern)) = parse_long_opt_value("remotes", line, None) {
4885        let full_pattern = format!("refs/remotes/{pattern}");
4886        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
4887            let s = oid.to_hex();
4888            if not_mode {
4889                negative.push(s);
4890            } else {
4891                positive.push(s);
4892            }
4893        }
4894        return Ok(Some(1));
4895    }
4896    if let Some((consumed, pattern)) = parse_long_opt_value("remotes", line, next_line) {
4897        let full_pattern = format!("refs/remotes/{pattern}");
4898        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
4899            let s = oid.to_hex();
4900            if not_mode {
4901                negative.push(s);
4902            } else {
4903                positive.push(s);
4904            }
4905        }
4906        return Ok(Some(consumed));
4907    }
4908    if line == "--glob" {
4909        return Err(stdin_die_requires_value("--glob"));
4910    }
4911    if let Some((1, pattern)) = parse_long_opt_value("glob", line, None) {
4912        for (_, oid) in refs::list_refs_glob(git_dir, &pattern)? {
4913            let s = oid.to_hex();
4914            if not_mode {
4915                negative.push(s);
4916            } else {
4917                positive.push(s);
4918            }
4919        }
4920        return Ok(Some(1));
4921    }
4922    if let Some((consumed, pattern)) = parse_long_opt_value("glob", line, next_line) {
4923        for (_, oid) in refs::list_refs_glob(git_dir, &pattern)? {
4924            let s = oid.to_hex();
4925            if not_mode {
4926                negative.push(s);
4927            } else {
4928                positive.push(s);
4929            }
4930        }
4931        return Ok(Some(consumed));
4932    }
4933    if line == "--no-walk" || line.starts_with("--no-walk=") {
4934        if let Some(rest) = line.strip_prefix("--no-walk=") {
4935            if rest == "sorted" || rest == "unsorted" {
4936                return Ok(Some(1));
4937            }
4938            eprintln!("error: invalid argument to --no-walk");
4939            return Err(Error::Message(format!(
4940                "fatal: invalid option '{line}' in --stdin mode"
4941            )));
4942        }
4943        return Ok(Some(1));
4944    }
4945    if line.starts_with("--") {
4946        return Err(Error::Message(format!(
4947            "fatal: invalid option '{line}' in --stdin mode"
4948        )));
4949    }
4950    if line.starts_with('-') {
4951        return Err(Error::Message(format!(
4952            "fatal: invalid option '{line}' in --stdin mode"
4953        )));
4954    }
4955    Ok(None)
4956}
4957
4958/// Read `--stdin` revision lines and pathspec tail (after `--`), matching Git `read_revisions_from_stdin`.
4959fn read_revisions_from_stdin_lines(
4960    git_dir: &Path,
4961) -> Result<(Vec<String>, Vec<String>, bool, Vec<String>)> {
4962    let stdin = std::io::read_to_string(std::io::stdin()).map_err(Error::Io)?;
4963    let lines: Vec<String> = stdin.lines().map(std::borrow::ToOwned::to_owned).collect();
4964
4965    let mut positive = Vec::new();
4966    let mut negative = Vec::new();
4967    let mut stdin_all_refs = false;
4968    let mut stdin_not_mode = false;
4969    let mut seen_end_of_options = false;
4970    let mut i = 0usize;
4971
4972    while i < lines.len() {
4973        let line = lines[i].as_str();
4974        if line.is_empty() {
4975            break;
4976        }
4977        if line == "--" {
4978            i += 1;
4979            let paths: Vec<String> = lines[i..].to_vec();
4980            return Ok((positive, negative, stdin_all_refs, paths));
4981        }
4982
4983        if !seen_end_of_options && line.starts_with('-') {
4984            if line == "--end-of-options" {
4985                seen_end_of_options = true;
4986                i += 1;
4987                continue;
4988            }
4989            let next = lines.get(i + 1).map(|s| s.as_str());
4990            match apply_stdin_pseudo_opt(
4991                git_dir,
4992                line,
4993                next,
4994                stdin_not_mode,
4995                &mut positive,
4996                &mut negative,
4997                &mut stdin_all_refs,
4998            )? {
4999                Some(consumed) => {
5000                    if line == "--not" && consumed == 1 {
5001                        stdin_not_mode = !stdin_not_mode;
5002                    }
5003                    i += consumed;
5004                }
5005                None => {
5006                    extend_split_token(line, stdin_not_mode, &mut positive, &mut negative);
5007                    i += 1;
5008                }
5009            }
5010            continue;
5011        }
5012
5013        extend_split_token(line, stdin_not_mode, &mut positive, &mut negative);
5014        i += 1;
5015    }
5016
5017    Ok((positive, negative, stdin_all_refs, Vec::new()))
5018}
5019
5020/// Merge command-line revision strings with `--stdin` lines (and optional stdin pathspecs).
5021///
5022/// Returns `(positive_specs, negative_specs, stdin_saw_all, stdin_pathspecs)`.
5023///
5024/// Command-line `--not` is applied while building `args_specs` (see `rev-list`); stdin uses an
5025/// independent `--not` toggle, matching Git.
5026///
5027/// # Errors
5028///
5029/// Returns [`Error::Message`] for Git-compatible `fatal:` stderr lines from stdin mode.
5030pub fn collect_revision_specs_with_stdin(
5031    git_dir: &Path,
5032    args_specs: &[String],
5033    read_stdin: bool,
5034) -> Result<(Vec<String>, Vec<String>, bool, Vec<String>)> {
5035    let mut positive = Vec::new();
5036    let mut negative = Vec::new();
5037
5038    for spec in args_specs {
5039        let (pos, neg) = split_revision_token(spec);
5040        positive.extend(pos);
5041        negative.extend(neg);
5042    }
5043
5044    if !read_stdin {
5045        return Ok((positive, negative, false, Vec::new()));
5046    }
5047
5048    let (s_pos, s_neg, stdin_all_refs, stdin_paths) = read_revisions_from_stdin_lines(git_dir)?;
5049    positive.extend(s_pos);
5050    negative.extend(s_neg);
5051
5052    Ok((positive, negative, stdin_all_refs, stdin_paths))
5053}
5054
5055/// Resolve every local tag object ID.
5056pub fn tag_targets(git_dir: &Path) -> Result<HashSet<ObjectId>> {
5057    Ok(refs::list_refs(git_dir, "refs/tags/")?
5058        .into_iter()
5059        .map(|(_, oid)| oid)
5060        .collect())
5061}
5062
5063pub(crate) struct CommitGraph<'r> {
5064    repo: &'r Repository,
5065    first_parent_only: bool,
5066    parents: HashMap<ObjectId, Vec<ObjectId>>,
5067    committer_time: HashMap<ObjectId, i64>,
5068    author_time: HashMap<ObjectId, i64>,
5069    shallow_boundaries: HashSet<ObjectId>,
5070    graft_parents: HashMap<ObjectId, Vec<ObjectId>>,
5071}
5072
5073impl<'r> CommitGraph<'r> {
5074    pub(crate) fn new(repo: &'r Repository, first_parent_only: bool) -> Self {
5075        let shallow_boundaries = load_shallow_boundaries(&repo.git_dir);
5076        let graft_parents = crate::rev_parse::load_graft_parents(&repo.git_dir);
5077        Self {
5078            repo,
5079            first_parent_only,
5080            parents: HashMap::new(),
5081            committer_time: HashMap::new(),
5082            author_time: HashMap::new(),
5083            shallow_boundaries,
5084            graft_parents,
5085        }
5086    }
5087
5088    pub(crate) fn parents_of(&mut self, oid: ObjectId) -> Result<Vec<ObjectId>> {
5089        self.populate(oid)?;
5090        Ok(self.parents.get(&oid).cloned().unwrap_or_default())
5091    }
5092
5093    fn committer_time(&mut self, oid: ObjectId) -> i64 {
5094        if self.populate(oid).is_err() {
5095            return 0;
5096        }
5097        self.committer_time.get(&oid).copied().unwrap_or(0)
5098    }
5099
5100    fn author_time(&mut self, oid: ObjectId) -> i64 {
5101        if self.populate(oid).is_err() {
5102            return 0;
5103        }
5104        self.author_time.get(&oid).copied().unwrap_or(0)
5105    }
5106
5107    fn sort_key(&mut self, oid: ObjectId, author: bool) -> i64 {
5108        if author {
5109            self.author_time(oid)
5110        } else {
5111            self.committer_time(oid)
5112        }
5113    }
5114
5115    fn populate(&mut self, oid: ObjectId) -> Result<()> {
5116        if self.parents.contains_key(&oid) {
5117            return Ok(());
5118        }
5119        let commit = load_commit(self.repo, oid)?;
5120        // Shallow boundaries: treat commit as having no parents
5121        let mut parents = if self.shallow_boundaries.contains(&oid) {
5122            Vec::new()
5123        } else {
5124            commit.parents
5125        };
5126        if let Some(graft_parents) = self.graft_parents.get(&oid) {
5127            parents = graft_parents.clone();
5128        }
5129        if self.first_parent_only && parents.len() > 1 {
5130            parents.truncate(1);
5131        }
5132        self.committer_time
5133            .insert(oid, committer_unix_seconds_for_ordering(&commit.committer));
5134        self.author_time
5135            .insert(oid, committer_unix_seconds_for_ordering(&commit.author));
5136        self.parents.insert(oid, parents);
5137        Ok(())
5138    }
5139}
5140
5141/// Load shallow boundary commit OIDs from `.git/shallow`.
5142fn load_shallow_boundaries(git_dir: &Path) -> HashSet<ObjectId> {
5143    let shallow_path = git_dir.join("shallow");
5144    let mut set = HashSet::new();
5145    if let Ok(contents) = fs::read_to_string(&shallow_path) {
5146        for line in contents.lines() {
5147            let line = line.trim();
5148            if !line.is_empty() {
5149                if let Ok(oid) = line.parse::<ObjectId>() {
5150                    set.insert(oid);
5151                }
5152            }
5153        }
5154    }
5155    set
5156}
5157
5158fn commit_tips_from_ref_pairs(
5159    repo: &Repository,
5160    pairs: &[(String, ObjectId)],
5161    exclusions: &RefExclusions,
5162) -> Result<Vec<ObjectId>> {
5163    let namespace_prefix = git_namespace_prefix();
5164    let mut raw = Vec::new();
5165    for (refname, oid) in pairs {
5166        if exclusions.ref_excluded(strip_git_namespace(refname, &namespace_prefix), refname) {
5167            continue;
5168        }
5169        raw.push(*oid);
5170    }
5171    peel_ref_oids_to_unique_commits(repo, raw)
5172}
5173
5174fn peel_ref_oids_to_unique_commits(repo: &Repository, raw: Vec<ObjectId>) -> Result<Vec<ObjectId>> {
5175    let mut out = Vec::new();
5176    let mut seen = HashSet::new();
5177    for oid in raw {
5178        match peel_to_commit(repo, oid) {
5179            Ok(commit_oid) if seen.insert(commit_oid) => out.push(commit_oid),
5180            Err(_) => {}
5181            _ => {}
5182        }
5183    }
5184    out.sort();
5185    Ok(out)
5186}
5187
5188/// Compute which commits survive `--simplify-by-decoration`, mirroring Git's history
5189/// simplification (`simplify_commit` / `rewrite_parents` in revision.c with the
5190/// decoration-based TREESAME oracle from `rev_compare_tree`).
5191///
5192/// A commit is "interesting" (TREE_DIFFERENT, never pruned) iff it is decorated. Undecorated
5193/// commits are TREESAME and are candidates for pruning; their parent links are rewritten so
5194/// that children point past them to the nearest interesting ancestors. A commit is kept iff:
5195///   * it is decorated, or
5196///   * it is a root (no parents in the reachable graph) — roots are always interesting, or
5197///   * it is a relevant merge: after rewriting and removing redundant parents (those reachable
5198///     via another parent), it still has >= 2 distinct relevant parents.
5199///
5200/// `ordered` must be in walk order with every commit listed before its parents (topo order),
5201/// so processing it in reverse computes each commit's rewritten parents bottom-up.
5202fn compute_simplify_by_decoration_keep_set(
5203    graph: &mut CommitGraph,
5204    ordered: &[ObjectId],
5205    decorated: &HashSet<ObjectId>,
5206) -> Result<HashSet<ObjectId>> {
5207    let in_walk: HashSet<ObjectId> = ordered.iter().copied().collect();
5208    // For each commit, its list of rewritten relevant parents (the representatives a child
5209    // sees when it points at this commit). For a kept commit this is itself; for a pruned
5210    // commit it is the flattened relevant parents.
5211    let mut rewritten: HashMap<ObjectId, Vec<ObjectId>> = HashMap::new();
5212    let mut keep: HashSet<ObjectId> = HashSet::new();
5213
5214    // Process parents before children: `ordered` lists children first, so iterate in reverse.
5215    for &oid in ordered.iter().rev() {
5216        let raw_parents: Vec<ObjectId> = graph
5217            .parents_of(oid)?
5218            .into_iter()
5219            .filter(|p| in_walk.contains(p))
5220            .collect();
5221
5222        // Build the relevant-parent list: replace each pruned parent with its own
5223        // rewritten representatives (already computed since parents precede children here).
5224        let mut relevant: Vec<ObjectId> = Vec::new();
5225        let mut seen = HashSet::new();
5226        for parent in &raw_parents {
5227            if keep.contains(parent) {
5228                if seen.insert(*parent) {
5229                    relevant.push(*parent);
5230                }
5231            } else if let Some(reps) = rewritten.get(parent) {
5232                for &rep in reps {
5233                    if seen.insert(rep) {
5234                        relevant.push(rep);
5235                    }
5236                }
5237            }
5238        }
5239
5240        // Remove redundant parents: a relevant parent reachable from another relevant parent
5241        // (through the kept subgraph) is dropped, matching Git's parent-list simplification.
5242        if relevant.len() > 1 {
5243            relevant = remove_redundant_relevant_parents(graph, &relevant, &keep)?;
5244        }
5245
5246        let is_root = raw_parents.is_empty();
5247        let kept = decorated.contains(&oid) || is_root || relevant.len() >= 2;
5248        if kept {
5249            keep.insert(oid);
5250            // A kept commit represents itself to its children.
5251            rewritten.insert(oid, vec![oid]);
5252        } else {
5253            // A pruned commit forwards its relevant parents to its children.
5254            rewritten.insert(oid, relevant);
5255        }
5256    }
5257
5258    Ok(keep)
5259}
5260
5261/// Drop parents from `relevant` that are reachable from another parent through the kept
5262/// subgraph (so a merge whose second parent is an ancestor of its first parent collapses).
5263fn remove_redundant_relevant_parents(
5264    graph: &mut CommitGraph,
5265    relevant: &[ObjectId],
5266    keep: &HashSet<ObjectId>,
5267) -> Result<Vec<ObjectId>> {
5268    let mut out = Vec::new();
5269    for (i, &candidate) in relevant.iter().enumerate() {
5270        let mut redundant = false;
5271        for (j, &other) in relevant.iter().enumerate() {
5272            if i == j {
5273                continue;
5274            }
5275            if relevant_parent_reaches(graph, other, candidate, keep)? {
5276                redundant = true;
5277                break;
5278            }
5279        }
5280        if !redundant {
5281            out.push(candidate);
5282        }
5283    }
5284    Ok(out)
5285}
5286
5287/// Walk ancestors of `start` over the full graph to see if `target` is a proper ancestor.
5288/// Used to drop a relevant parent that is already reachable through another parent.
5289fn relevant_parent_reaches(
5290    graph: &mut CommitGraph,
5291    start: ObjectId,
5292    target: ObjectId,
5293    _keep: &HashSet<ObjectId>,
5294) -> Result<bool> {
5295    if start == target {
5296        // A parent cannot make itself redundant; the caller already deduplicated by OID.
5297        return Ok(false);
5298    }
5299    let mut stack = vec![start];
5300    let mut seen = HashSet::new();
5301    while let Some(oid) = stack.pop() {
5302        if !seen.insert(oid) {
5303            continue;
5304        }
5305        for parent in graph.parents_of(oid)? {
5306            if parent == target {
5307                return Ok(true);
5308            }
5309            stack.push(parent);
5310        }
5311    }
5312    Ok(false)
5313}
5314
5315fn all_ref_tips(repo: &Repository, exclusions: &RefExclusions) -> Result<Vec<ObjectId>> {
5316    let mut pairs = Vec::new();
5317    if let Ok(head) = refs::resolve_ref(&repo.git_dir, "HEAD") {
5318        pairs.push(("HEAD".to_owned(), head));
5319    }
5320    pairs.extend(refs::list_refs(&repo.git_dir, "refs/")?);
5321    commit_tips_from_ref_pairs(repo, &pairs, exclusions)
5322}
5323
5324/// Whether `filter` is incompatible with bitmap traversal, forcing `--use-bitmap-index` to fall
5325/// back to a normal name-walk (and therefore the normal, path-bearing object output).
5326///
5327/// Path-based filters (`sparse:oid`) and depth-limited tree filters (`tree:<n>`) cannot be served
5328/// from reachability bitmaps; `blob:none`, `blob:limit`, and most `object:type` filters can. This
5329/// mirrors the CLI's `bitmap_use_oid_only_object_lines`.
5330fn filter_forces_bitmap_fallback(filter: Option<&ObjectFilter>) -> bool {
5331    match filter {
5332        None => false,
5333        Some(ObjectFilter::SparseOid(_)) | Some(ObjectFilter::TreeDepth(_)) => true,
5334        Some(ObjectFilter::Combine(parts)) => {
5335            parts.iter().any(|p| filter_forces_bitmap_fallback(Some(p)))
5336        }
5337        _ => false,
5338    }
5339}
5340
5341/// Whether a pack or multi-pack-index reachability bitmap (`*.bitmap`) is present in the object
5342/// store, indicating that `--use-bitmap-index` would engage a real bitmap and therefore emit the
5343/// OID-only bitmap object format.
5344fn pack_bitmap_present(repo: &Repository) -> bool {
5345    let pack_dir = repo.odb.objects_dir().join("pack");
5346    let Ok(rd) = std::fs::read_dir(&pack_dir) else {
5347        return false;
5348    };
5349    rd.filter_map(|e| e.ok()).any(|e| {
5350        e.path()
5351            .extension()
5352            .and_then(|s| s.to_str())
5353            .is_some_and(|ext| ext == "bitmap")
5354    })
5355}
5356
5357/// Collect `--all` refs whose target is (or peels to) a non-commit object — a blob or tree — as
5358/// object roots for `--objects` traversal.
5359///
5360/// Commit-pointing refs are already covered by [`all_ref_tips`]; only refs naming a blob/tree
5361/// directly, or annotated tags that peel to a blob/tree, are returned here. This mirrors
5362/// `git rev-list --all --objects`, which lists objects directly referenced by any ref.
5363fn all_ref_non_commit_object_roots(repo: &Repository) -> Result<Vec<RootObject>> {
5364    let mut roots = Vec::new();
5365    let mut seen: HashSet<ObjectId> = HashSet::new();
5366    for (refname, oid) in refs::list_refs(&repo.git_dir, "refs/")? {
5367        let Ok(object) = repo.odb.read(&oid) else {
5368            continue;
5369        };
5370        match object.kind {
5371            ObjectKind::Commit => continue,
5372            ObjectKind::Tree | ObjectKind::Blob => {
5373                if seen.insert(oid) {
5374                    roots.push(RootObject {
5375                        oid,
5376                        input: refname,
5377                        expected_kind: None,
5378                        root_path: None,
5379                        wrap_with_tag: None,
5380                    });
5381                }
5382            }
5383            ObjectKind::Tag => {
5384                let Ok(tag) = parse_tag(&object.data) else {
5385                    continue;
5386                };
5387                let Some(expected_kind) = ExpectedObjectKind::from_tag_type(&tag.object_type)
5388                else {
5389                    continue;
5390                };
5391                // Annotated tags peeling to a commit are handled by the commit-tip walk.
5392                if expected_kind == ExpectedObjectKind::Commit {
5393                    continue;
5394                }
5395                if seen.insert(tag.object) {
5396                    roots.push(RootObject {
5397                        oid: tag.object,
5398                        input: refname,
5399                        expected_kind: Some(expected_kind),
5400                        root_path: None,
5401                        wrap_with_tag: Some(oid),
5402                    });
5403                }
5404            }
5405        }
5406    }
5407    Ok(roots)
5408}
5409
5410/// Expand named refs to peeled unique commit tips, applying `--exclude` / `--exclude-hidden` rules.
5411pub fn commit_tips_from_named_refs(
5412    repo: &Repository,
5413    pairs: &[(String, ObjectId)],
5414    exclusions: &RefExclusions,
5415) -> Result<Vec<ObjectId>> {
5416    commit_tips_from_ref_pairs(repo, pairs, exclusions)
5417}
5418
5419/// Commit OIDs listed in `.git/shallow` (shallow clone boundaries).
5420///
5421/// At each boundary commit, history is cut: parents are omitted from the object store and must
5422/// not be traversed for connectivity checks (`git fsck`, `pack-objects` reachability, etc.).
5423#[must_use]
5424pub fn shallow_boundary_oids(git_dir: &Path) -> HashSet<ObjectId> {
5425    crate::shallow::load_shallow_boundaries(git_dir)
5426}
5427
5428/// Shallow boundary commits on paths from `wants` when the server repository is shallow.
5429///
5430/// Matches Git `get_shallow_commits` with infinite depth and no client-advertised shallows: walk
5431/// from wanted tips, stop parent traversal at `.git/shallow` entries, and return those boundary
5432/// commits (for protocol v2 `shallow-info` and `pack-objects --shallow`).
5433#[must_use]
5434pub fn shallow_borders_reachable_from_wants(
5435    repo: &Repository,
5436    wants: &[ObjectId],
5437) -> Vec<ObjectId> {
5438    let boundaries = shallow_boundary_oids(&repo.git_dir);
5439    if boundaries.is_empty() || wants.is_empty() {
5440        return Vec::new();
5441    }
5442    let mut out: Vec<ObjectId> = Vec::new();
5443    let mut seen_out = HashSet::new();
5444    let mut visited = HashSet::new();
5445    let mut q: VecDeque<ObjectId> = wants.iter().copied().collect();
5446    while let Some(oid) = q.pop_front() {
5447        if !visited.insert(oid) {
5448            continue;
5449        }
5450        if boundaries.contains(&oid) {
5451            if seen_out.insert(oid) {
5452                out.push(oid);
5453            }
5454            continue;
5455        }
5456        for p in commit_parent_ids(repo, oid) {
5457            q.push_back(p);
5458        }
5459    }
5460    out.sort();
5461    out
5462}
5463
5464/// Compute new shallow boundary commits for `upload-pack` when the client sends `deepen <n>`.
5465///
5466/// This approximates Git's `get_shallow_commits` / `get_shallows_or_depth` for the common case:
5467/// non-`deepen-relative` fetches where the client lists its shallow commits and requests more
5468/// history. Returns commit OIDs that should be sent as `shallow` lines and registered as grafts
5469/// for `pack-objects --shallow`.
5470///
5471/// # Parameters
5472///
5473/// - `wants` — wanted commit OIDs (usually remote `HEAD` / ref tips).
5474/// - `client_shallow` — OIDs the client advertised with `shallow <oid>` before `deepen`.
5475/// - `deepen` — positive integer from the `deepen` pkt-line (`deepen 2` → `2`).
5476#[must_use]
5477pub fn shallow_grafts_for_upload_pack_deepen(
5478    repo: &Repository,
5479    wants: &[ObjectId],
5480    client_shallow: &[ObjectId],
5481    deepen: usize,
5482) -> Vec<ObjectId> {
5483    if deepen == 0 || wants.is_empty() {
5484        return Vec::new();
5485    }
5486
5487    let server_shallow = shallow_boundary_oids(&repo.git_dir);
5488    let client_set: HashSet<ObjectId> = client_shallow.iter().copied().collect();
5489
5490    // For a fresh (non-shallow) client, an absolute `deepen N` keeps exactly N commits counting
5491    // from the wanted tips, so the boundary sits at the N-th commit and the base offset is 0.
5492    // Using 1 here over-counts by one: with `deepen 2` on a 3-commit history the border lands on
5493    // the root commit (which has no parents) and no `shallow` line is advertised at all, leaving
5494    // the client believing it received full history and failing connectivity/fsck for the cut-off
5495    // parents (t5537 "shallow fetches check connectivity before writing shallow file"). When the
5496    // client already has a shallow boundary, `min_client_shallow_distance` supplies the real base.
5497    let min_hit = min_client_shallow_distance(repo, wants, &client_set, &server_shallow);
5498    let base = min_hit.unwrap_or(0);
5499    let target_depth = base.saturating_add(deepen);
5500
5501    let included = commits_within_parent_depth(repo, wants, target_depth, &server_shallow);
5502    border_commits_not_in_client_shallow(repo, &included, &client_set)
5503}
5504
5505fn commit_parent_ids(repo: &Repository, oid: ObjectId) -> Vec<ObjectId> {
5506    let Ok(obj) = repo.odb.read(&oid) else {
5507        return Vec::new();
5508    };
5509    if obj.kind != ObjectKind::Commit {
5510        return Vec::new();
5511    }
5512    parse_commit(&obj.data)
5513        .map(|c| c.parents)
5514        .unwrap_or_default()
5515}
5516
5517fn min_client_shallow_distance(
5518    repo: &Repository,
5519    wants: &[ObjectId],
5520    client_shallow: &HashSet<ObjectId>,
5521    server_shallow: &HashSet<ObjectId>,
5522) -> Option<usize> {
5523    if client_shallow.is_empty() {
5524        return None;
5525    }
5526    let mut best: Option<usize> = None;
5527    let mut dist: HashMap<ObjectId, usize> = HashMap::new();
5528    let mut q: VecDeque<(ObjectId, usize)> = VecDeque::new();
5529    for &w in wants {
5530        dist.insert(w, 0);
5531        q.push_back((w, 0));
5532    }
5533    while let Some((oid, d)) = q.pop_front() {
5534        if client_shallow.contains(&oid) {
5535            best = Some(best.map(|b| b.min(d)).unwrap_or(d));
5536        }
5537        if server_shallow.contains(&oid) {
5538            continue;
5539        }
5540        for p in commit_parent_ids(repo, oid) {
5541            let nd = d.saturating_add(1);
5542            let prev = dist.get(&p).copied().unwrap_or(usize::MAX);
5543            if nd < prev {
5544                dist.insert(p, nd);
5545                q.push_back((p, nd));
5546            }
5547        }
5548    }
5549    best
5550}
5551
5552fn commits_within_parent_depth(
5553    repo: &Repository,
5554    wants: &[ObjectId],
5555    max_depth: usize,
5556    server_shallow: &HashSet<ObjectId>,
5557) -> HashSet<ObjectId> {
5558    let mut best_depth: HashMap<ObjectId, usize> = HashMap::new();
5559    let mut q: VecDeque<(ObjectId, usize)> = VecDeque::new();
5560    for &w in wants {
5561        best_depth.insert(w, 1);
5562        q.push_back((w, 1));
5563    }
5564    while let Some((oid, depth)) = q.pop_front() {
5565        if depth > max_depth {
5566            continue;
5567        }
5568        if best_depth.get(&oid).copied() != Some(depth) {
5569            continue;
5570        }
5571        if depth == max_depth {
5572            continue;
5573        }
5574        if server_shallow.contains(&oid) {
5575            continue;
5576        }
5577        for p in commit_parent_ids(repo, oid) {
5578            let nd = depth.saturating_add(1);
5579            if nd > max_depth {
5580                continue;
5581            }
5582            let prev = best_depth.get(&p).copied().unwrap_or(usize::MAX);
5583            if nd < prev {
5584                best_depth.insert(p, nd);
5585                q.push_back((p, nd));
5586            }
5587        }
5588    }
5589    best_depth.into_keys().collect()
5590}
5591
5592fn border_commits_not_in_client_shallow(
5593    repo: &Repository,
5594    included: &HashSet<ObjectId>,
5595    client_shallow: &HashSet<ObjectId>,
5596) -> Vec<ObjectId> {
5597    let mut out = Vec::new();
5598    let mut seen_out = HashSet::new();
5599    for &c in included {
5600        if client_shallow.contains(&c) {
5601            continue;
5602        }
5603        let parents = commit_parent_ids(repo, c);
5604        let is_border = parents.iter().any(|p| !included.contains(p));
5605        if is_border && seen_out.insert(c) {
5606            out.push(c);
5607        }
5608    }
5609    out.sort();
5610    out
5611}
5612
5613/// Shallow boundary commits for `upload-pack` when the client uses `deepen-since` and/or
5614/// `deepen-not` (Git runs `rev-list --max-age=…` / `--not` and derives border commits).
5615///
5616/// Returns OIDs to advertise as `shallow` lines and pass to `pack-objects --shallow`.
5617pub fn shallow_grafts_for_upload_pack_rev_list(
5618    repo: &Repository,
5619    wants: &[ObjectId],
5620    client_shallow: &[ObjectId],
5621    deepen_since: Option<i64>,
5622    deepen_not: &[ObjectId],
5623) -> Result<Vec<ObjectId>> {
5624    if wants.is_empty() || (deepen_since.is_none() && deepen_not.is_empty()) {
5625        return Ok(Vec::new());
5626    }
5627
5628    let positive: Vec<String> = wants.iter().map(|o| o.to_hex()).collect();
5629    let negative: Vec<String> = deepen_not
5630        .iter()
5631        .map(|o| format!("^{}", o.to_hex()))
5632        .collect();
5633
5634    let options = RevListOptions {
5635        since_cutoff: deepen_since,
5636        missing_action: MissingAction::Allow,
5637        ..Default::default()
5638    };
5639
5640    let res = rev_list(repo, &positive, &negative, &options)?;
5641    let included: HashSet<ObjectId> = res.commits.iter().copied().collect();
5642    let client_set: HashSet<ObjectId> = client_shallow.iter().copied().collect();
5643    Ok(border_commits_not_in_client_shallow(
5644        repo,
5645        &included,
5646        &client_set,
5647    ))
5648}
5649
5650#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5651struct CommitDateKey {
5652    oid: ObjectId,
5653    date: i64,
5654    /// Insertion-order tiebreaker for equal dates, mirroring Git's `prio_queue` `ctr`
5655    /// (prio-queue.c): among commits with the same committer date, the one inserted *earlier*
5656    /// is emitted first. In this max-heap that means an earlier insertion must compare *greater*,
5657    /// so we order by the negated sequence. Walks that do not care about insertion order leave
5658    /// this at 0, falling through to the OID tiebreak (preserving prior behavior).
5659    seq: u64,
5660}
5661
5662impl Ord for CommitDateKey {
5663    fn cmp(&self, other: &Self) -> Ordering {
5664        match self.date.cmp(&other.date) {
5665            // Earlier insertion (smaller seq) should pop first => compare as "greater".
5666            Ordering::Equal => match other.seq.cmp(&self.seq) {
5667                Ordering::Equal => self.oid.cmp(&other.oid),
5668                ord => ord,
5669            },
5670            ord => ord,
5671        }
5672    }
5673}
5674
5675impl PartialOrd for CommitDateKey {
5676    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
5677        Some(self.cmp(other))
5678    }
5679}
5680
5681/// Read every line from a newline-delimited file.
5682///
5683/// # Errors
5684///
5685/// Returns [`Error::Io`] when the file cannot be read.
5686pub fn read_lines(path: &Path) -> Result<Vec<String>> {
5687    let content = fs::read_to_string(path)?;
5688    Ok(content.lines().map(|line| line.to_owned()).collect())
5689}
5690
5691/// Check if a token uses the symmetric diff `...` notation.
5692#[must_use]
5693pub fn is_symmetric_diff(token: &str) -> bool {
5694    token.contains("...") && !token.contains("....")
5695}
5696
5697/// Split a symmetric diff token into (lhs, rhs).
5698#[must_use]
5699pub fn split_symmetric_diff(token: &str) -> Option<(String, String)> {
5700    token
5701        .split_once("...")
5702        .map(|(l, r)| (l.to_owned(), r.to_owned()))
5703}
5704
5705/// Maps each tree OID to the minimum traversal depth it was entered at (Git `list-objects` /
5706/// `tree:<n>` semantics: the same tree may be revisited from a shallower path).
5707#[derive(Debug, Default)]
5708struct TreeWalkState {
5709    seen_at_depth: HashMap<ObjectId, u64>,
5710}
5711
5712impl TreeWalkState {
5713    fn new() -> Self {
5714        Self::default()
5715    }
5716
5717    /// Returns `true` if this tree at `depth` should be skipped (already entered at same or
5718    /// shallower depth).
5719    fn should_skip_tree(&mut self, oid: ObjectId, depth: u64) -> bool {
5720        match self.seen_at_depth.get(&oid).copied() {
5721            None => {
5722                self.seen_at_depth.insert(oid, depth);
5723                false
5724            }
5725            Some(prev) if depth >= prev => true,
5726            Some(_) => {
5727                self.seen_at_depth.insert(oid, depth);
5728                false
5729            }
5730        }
5731    }
5732}
5733
5734/// All tree and blob OIDs reachable from `tree_oid` (including `tree_oid` itself).
5735fn collect_tree_closure_objects(
5736    repo: &Repository,
5737    tree_oid: ObjectId,
5738    into: &mut HashSet<ObjectId>,
5739    missing_action: MissingAction,
5740    missing: &mut Vec<ObjectId>,
5741    missing_seen: &mut HashSet<ObjectId>,
5742) -> Result<()> {
5743    let mut stack = vec![tree_oid];
5744    let mut expanded_trees = HashSet::new();
5745    while let Some(t) = stack.pop() {
5746        if !expanded_trees.insert(t) {
5747            continue;
5748        }
5749        into.insert(t);
5750        let object = match repo.odb.read(&t) {
5751            Ok(o) => o,
5752            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
5753                if missing_action.reports_missing() && missing_seen.insert(t) {
5754                    missing.push(t);
5755                }
5756                continue;
5757            }
5758            Err(e) => return Err(e),
5759        };
5760        if object.kind != ObjectKind::Tree {
5761            continue;
5762        }
5763        let entries = parse_tree(&object.data)?;
5764        for entry in entries {
5765            if entry.mode == 0o160000 {
5766                continue;
5767            }
5768            into.insert(entry.oid);
5769            if entry.mode == 0o040000 {
5770                stack.push(entry.oid);
5771            }
5772        }
5773    }
5774    Ok(())
5775}
5776
5777fn union_parent_reachable_objects(
5778    repo: &Repository,
5779    parents: &[ObjectId],
5780    missing_action: MissingAction,
5781    missing: &mut Vec<ObjectId>,
5782    missing_seen: &mut HashSet<ObjectId>,
5783) -> Result<HashSet<ObjectId>> {
5784    let mut out = HashSet::new();
5785    for &p in parents {
5786        let commit = match load_commit(repo, p) {
5787            Ok(c) => c,
5788            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
5789                if missing_action.reports_missing() && missing_seen.insert(p) {
5790                    missing.push(p);
5791                }
5792                continue;
5793            }
5794            Err(e) => return Err(e),
5795        };
5796        collect_tree_closure_objects(
5797            repo,
5798            commit.tree,
5799            &mut out,
5800            missing_action,
5801            missing,
5802            missing_seen,
5803        )?;
5804    }
5805    Ok(out)
5806}
5807
5808/// Collect all reachable non-commit objects (trees and blobs) from a set of commits.
5809/// Returns (included, omitted) object lists.
5810#[allow(dead_code)]
5811fn collect_reachable_objects(
5812    repo: &Repository,
5813    graph: &mut CommitGraph<'_>,
5814    commits: &[ObjectId],
5815    object_roots: &[RootObject],
5816    tip_annotated_tags: &HashMap<ObjectId, ObjectId>,
5817    filter: Option<&ObjectFilter>,
5818    filter_provided: bool,
5819    missing_action: MissingAction,
5820    sparse_lines: Option<&[String]>,
5821    skip_trees_for_type_filter: bool,
5822    omit_object_paths: bool,
5823    packed_set: Option<&HashSet<ObjectId>>,
5824    collect_tree_omits: bool,
5825) -> Result<(Vec<(ObjectId, String)>, Vec<ObjectId>, Vec<ObjectId>)> {
5826    let mut tree_state = TreeWalkState::new();
5827    let mut top_tree_omit =
5828        walk_needs_top_tree_omit_set(filter, collect_tree_omits).then(HashSet::<ObjectId>::new);
5829    let mut combine_states = CombineSubState::prepare_sub_states(filter, collect_tree_omits);
5830    let mut emitted = HashSet::new();
5831    let mut result = Vec::new();
5832    let mut omitted = Vec::new();
5833    let mut missing = Vec::new();
5834    let mut missing_seen = HashSet::new();
5835    for &commit_oid in commits {
5836        let commit = match load_commit(repo, commit_oid) {
5837            Ok(commit) => commit,
5838            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
5839                if missing_seen.insert(commit_oid) && missing_action.reports_missing() {
5840                    missing.push(commit_oid);
5841                }
5842                continue;
5843            }
5844            Err(err) => return Err(err),
5845        };
5846        let parents = graph.parents_of(commit_oid)?;
5847        let parent_union = union_parent_reachable_objects(
5848            repo,
5849            &parents,
5850            missing_action,
5851            &mut missing,
5852            &mut missing_seen,
5853        )?;
5854        if let Some(&tag_oid) = tip_annotated_tags.get(&commit_oid) {
5855            if emitted.insert(tag_oid) {
5856                result.push((tag_oid, "tag".to_owned()));
5857            }
5858        }
5859        collect_tree_objects_filtered(
5860            repo,
5861            commit.tree,
5862            "",
5863            0,
5864            false,
5865            Some(&parent_union),
5866            &mut tree_state,
5867            &mut emitted,
5868            &mut result,
5869            &mut omitted,
5870            &mut missing,
5871            &mut missing_seen,
5872            filter,
5873            filter_provided,
5874            missing_action,
5875            sparse_lines,
5876            skip_trees_for_type_filter,
5877            omit_object_paths,
5878            packed_set,
5879            collect_tree_omits,
5880            &mut top_tree_omit,
5881            &mut combine_states,
5882        )?;
5883    }
5884
5885    for root in object_roots {
5886        collect_root_object(
5887            repo,
5888            root,
5889            &mut tree_state,
5890            &mut emitted,
5891            &mut result,
5892            &mut omitted,
5893            &mut missing,
5894            &mut missing_seen,
5895            filter,
5896            filter_provided,
5897            missing_action,
5898            sparse_lines,
5899            skip_trees_for_type_filter,
5900            omit_object_paths,
5901            packed_set,
5902            collect_tree_omits,
5903            &mut top_tree_omit,
5904            &mut combine_states,
5905        )?;
5906    }
5907
5908    Ok((result, omitted, missing))
5909}
5910
5911fn excluded_object_root_ids(
5912    repo: &Repository,
5913    object_roots: &[RootObject],
5914    missing_action: MissingAction,
5915    sparse_lines: Option<&[String]>,
5916    skip_trees_for_type_filter: bool,
5917    omit_object_paths: bool,
5918    collect_tree_omits: bool,
5919) -> Result<HashSet<ObjectId>> {
5920    if object_roots.is_empty() {
5921        return Ok(HashSet::new());
5922    }
5923
5924    let mut tree_state = TreeWalkState::new();
5925    let mut emitted = HashSet::new();
5926    let mut objects = Vec::new();
5927    let mut omitted = Vec::new();
5928    let mut missing = Vec::new();
5929    let mut missing_seen = HashSet::new();
5930    let mut top_tree_omit = None;
5931    let mut combine_states = None;
5932
5933    for root in object_roots {
5934        collect_root_object(
5935            repo,
5936            root,
5937            &mut tree_state,
5938            &mut emitted,
5939            &mut objects,
5940            &mut omitted,
5941            &mut missing,
5942            &mut missing_seen,
5943            None,
5944            false,
5945            missing_action,
5946            sparse_lines,
5947            skip_trees_for_type_filter,
5948            omit_object_paths,
5949            None,
5950            collect_tree_omits,
5951            &mut top_tree_omit,
5952            &mut combine_states,
5953        )?;
5954    }
5955
5956    Ok(objects.into_iter().map(|(oid, _)| oid).collect())
5957}
5958
5959fn retain_objects_matching_pathspecs(objects: &mut Vec<(ObjectId, String)>, pathspecs: &[String]) {
5960    objects.retain(|(_, path)| {
5961        path.is_empty()
5962            || crate::pathspec::matches_pathspec_list(path, pathspecs)
5963            || pathspecs
5964                .iter()
5965                .any(|spec| crate::pathspec::pathspec_wants_descent_into_tree(spec, path))
5966    });
5967}
5968
5969fn collect_pathspec_matching_tree_objects(
5970    repo: &Repository,
5971    commits: &[ObjectId],
5972    pathspecs: &[String],
5973) -> Result<Vec<(ObjectId, String)>> {
5974    let mut seen = HashSet::new();
5975    let mut out = Vec::new();
5976    for commit_oid in commits {
5977        let commit = load_commit(repo, *commit_oid)?;
5978        collect_pathspec_matching_tree_objects_inner(
5979            repo,
5980            commit.tree,
5981            "",
5982            pathspecs,
5983            &mut seen,
5984            &mut out,
5985        )?;
5986    }
5987    Ok(out)
5988}
5989
5990fn collect_pathspec_matching_tree_objects_inner(
5991    repo: &Repository,
5992    tree_oid: ObjectId,
5993    prefix: &str,
5994    pathspecs: &[String],
5995    seen: &mut HashSet<ObjectId>,
5996    out: &mut Vec<(ObjectId, String)>,
5997) -> Result<()> {
5998    let object = repo.odb.read(&tree_oid)?;
5999    if object.kind != ObjectKind::Tree {
6000        return Err(Error::CorruptObject(format!(
6001            "object {tree_oid} is not a tree"
6002        )));
6003    }
6004    let entries = parse_tree(&object.data)?;
6005    for entry in entries {
6006        if entry.mode == 0o160000 {
6007            continue;
6008        }
6009        let name = String::from_utf8_lossy(&entry.name);
6010        let path = if prefix.is_empty() {
6011            name.into_owned()
6012        } else {
6013            format!("{prefix}/{name}")
6014        };
6015        let is_tree = entry.mode == 0o040000;
6016        let matches = crate::pathspec::matches_pathspec_list(&path, pathspecs);
6017        let wants_descent = pathspecs
6018            .iter()
6019            .any(|spec| crate::pathspec::pathspec_wants_descent_into_tree(spec, &path));
6020        if (matches || (is_tree && wants_descent)) && seen.insert(entry.oid) {
6021            out.push((entry.oid, path.clone()));
6022        }
6023        if is_tree && wants_descent {
6024            collect_pathspec_matching_tree_objects_inner(
6025                repo, entry.oid, &path, pathspecs, seen, out,
6026            )?;
6027        }
6028    }
6029    Ok(())
6030}
6031
6032/// Like [`collect_reachable_objects`], but also returns objects newly discovered per commit walk
6033/// plus one trailing segment for `object_roots`.
6034///
6035/// Matches Git `traverse_commit_list_filtered`: each commit's tree is processed before moving to
6036/// the next commit, with global de-duplication of emitted object OIDs across the full walk.
6037fn collect_reachable_objects_segmented(
6038    repo: &Repository,
6039    graph: &mut CommitGraph<'_>,
6040    commits: &[ObjectId],
6041    object_roots: &[RootObject],
6042    tip_annotated_tags: &HashMap<ObjectId, ObjectId>,
6043    filter: Option<&ObjectFilter>,
6044    filter_provided: bool,
6045    missing_action: MissingAction,
6046    sparse_lines: Option<&[String]>,
6047    skip_trees_for_type_filter: bool,
6048    omit_object_paths: bool,
6049    packed_set: Option<&HashSet<ObjectId>>,
6050    collect_tree_omits: bool,
6051) -> Result<(
6052    Vec<(ObjectId, String)>,
6053    Vec<ObjectId>,
6054    Vec<ObjectId>,
6055    Vec<Vec<(ObjectId, String)>>,
6056)> {
6057    let mut emitted = HashSet::new();
6058    let mut result = Vec::new();
6059    let mut omitted = Vec::new();
6060    let mut missing = Vec::new();
6061    let mut missing_seen = HashSet::new();
6062    let mut segments: Vec<Vec<(ObjectId, String)>> = Vec::with_capacity(commits.len() + 1);
6063    let mut tree_state = TreeWalkState::new();
6064    let mut top_tree_omit =
6065        walk_needs_top_tree_omit_set(filter, collect_tree_omits).then(HashSet::<ObjectId>::new);
6066    let mut combine_states = CombineSubState::prepare_sub_states(filter, collect_tree_omits);
6067
6068    for &commit_oid in commits {
6069        let start = result.len();
6070        let commit = match load_commit(repo, commit_oid) {
6071            Ok(commit) => commit,
6072            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
6073                if missing_action.reports_missing() && missing_seen.insert(commit_oid) {
6074                    missing.push(commit_oid);
6075                }
6076                segments.push(Vec::new());
6077                continue;
6078            }
6079            Err(err) => return Err(err),
6080        };
6081        if let Some(&tag_oid) = tip_annotated_tags.get(&commit_oid) {
6082            if emitted.insert(tag_oid) {
6083                result.push((tag_oid, "tag".to_owned()));
6084            }
6085        }
6086        let parents = graph.parents_of(commit_oid)?;
6087        // The parent_union short-circuit dedups objects already reachable from a parent commit.
6088        // With an active filter this changes which trees are *visited* per commit, which breaks
6089        // Git's `tree:`/`combine:` skip-tree semantics (and the matching GIT_TRACE output): Git
6090        // visits every reachable tree per commit and relies on each filter's own seen state to
6091        // detect re-visits. Dedup of shown/omitted objects is already handled by `emitted` and the
6092        // final omitted set, so skip the union optimization whenever a filter is present.
6093        let parent_union = if filter.is_some() {
6094            None
6095        } else {
6096            Some(union_parent_reachable_objects(
6097                repo,
6098                &parents,
6099                missing_action,
6100                &mut missing,
6101                &mut missing_seen,
6102            )?)
6103        };
6104        collect_tree_objects_filtered(
6105            repo,
6106            commit.tree,
6107            "",
6108            0,
6109            false,
6110            parent_union.as_ref(),
6111            &mut tree_state,
6112            &mut emitted,
6113            &mut result,
6114            &mut omitted,
6115            &mut missing,
6116            &mut missing_seen,
6117            filter,
6118            filter_provided,
6119            missing_action,
6120            sparse_lines,
6121            skip_trees_for_type_filter,
6122            omit_object_paths,
6123            packed_set,
6124            collect_tree_omits,
6125            &mut top_tree_omit,
6126            &mut combine_states,
6127        )?;
6128        segments.push(result[start..].to_vec());
6129    }
6130
6131    let roots_start = result.len();
6132    for root in object_roots {
6133        collect_root_object(
6134            repo,
6135            root,
6136            &mut tree_state,
6137            &mut emitted,
6138            &mut result,
6139            &mut omitted,
6140            &mut missing,
6141            &mut missing_seen,
6142            filter,
6143            filter_provided,
6144            missing_action,
6145            sparse_lines,
6146            skip_trees_for_type_filter,
6147            omit_object_paths,
6148            packed_set,
6149            collect_tree_omits,
6150            &mut top_tree_omit,
6151            &mut combine_states,
6152        )?;
6153    }
6154    segments.push(result[roots_start..].to_vec());
6155
6156    Ok((result, omitted, missing, segments))
6157}
6158
6159#[derive(Clone, Copy, Debug)]
6160struct ListFilterBits {
6161    mark_seen: bool,
6162    do_show: bool,
6163    skip_tree: bool,
6164}
6165
6166impl ListFilterBits {
6167    fn merge_combine(subs: &[ListFilterBits], sub_skipping: &[bool]) -> Self {
6168        let mut out = ListFilterBits {
6169            mark_seen: true,
6170            do_show: true,
6171            skip_tree: true,
6172        };
6173        for (sub, skipping) in subs.iter().zip(sub_skipping.iter()) {
6174            if !sub.do_show {
6175                out.do_show = false;
6176            }
6177            if !sub.mark_seen {
6178                out.mark_seen = false;
6179            }
6180            if !skipping {
6181                out.skip_tree = false;
6182            }
6183        }
6184        out
6185    }
6186}
6187
6188fn walk_needs_top_tree_omit_set(filter: Option<&ObjectFilter>, collect_omits: bool) -> bool {
6189    collect_omits && matches!(filter, Some(ObjectFilter::TreeDepth(_)))
6190}
6191
6192fn trace_skip_tree_contents(prefix: &str) {
6193    let Ok(trace_val) = std::env::var("GIT_TRACE") else {
6194        return;
6195    };
6196    if trace_val.is_empty() || trace_val == "0" || trace_val.eq_ignore_ascii_case("false") {
6197        return;
6198    }
6199    let path = if prefix.is_empty() {
6200        String::new()
6201    } else {
6202        format!("{prefix}/")
6203    };
6204    let line = format!("Skipping contents of tree {path}...\n");
6205    match trace_val.as_str() {
6206        "1" | "true" | "2" => {
6207            let _ = std::io::stderr().write_all(line.as_bytes());
6208        }
6209        path_dest => {
6210            if let Ok(mut f) = std::fs::OpenOptions::new()
6211                .create(true)
6212                .append(true)
6213                .open(path_dest)
6214            {
6215                let _ = f.write_all(line.as_bytes());
6216            }
6217        }
6218    }
6219}
6220
6221fn tree_depth_begin_tree(
6222    tree_oid: ObjectId,
6223    depth: u64,
6224    exclude_depth: u64,
6225    tree_state: &mut TreeWalkState,
6226    tree_omit_set: &mut Option<HashSet<ObjectId>>,
6227    collect_omits: bool,
6228) -> ListFilterBits {
6229    let include_it = depth < exclude_depth;
6230    let already_seen = tree_state.should_skip_tree(tree_oid, depth);
6231    if already_seen {
6232        return ListFilterBits {
6233            mark_seen: false,
6234            do_show: false,
6235            skip_tree: true,
6236        };
6237    }
6238
6239    let been_omitted = if collect_omits {
6240        if let Some(omits) = tree_omit_set.as_mut() {
6241            if include_it {
6242                omits.remove(&tree_oid);
6243                false
6244            } else {
6245                !omits.insert(tree_oid)
6246            }
6247        } else {
6248            false
6249        }
6250    } else {
6251        false
6252    };
6253
6254    let skip_tree = if include_it {
6255        false
6256    } else {
6257        !(collect_omits && !been_omitted)
6258    };
6259
6260    let do_show = include_it;
6261    ListFilterBits {
6262        mark_seen: true,
6263        do_show,
6264        skip_tree,
6265    }
6266}
6267
6268fn tree_depth_blob(
6269    oid: ObjectId,
6270    _size: u64,
6271    parent_depth: u64,
6272    exclude_depth: u64,
6273    tree_omit_set: &mut Option<HashSet<ObjectId>>,
6274    collect_omits: bool,
6275) -> ListFilterBits {
6276    let include_it = parent_depth.saturating_add(1) < exclude_depth;
6277    if collect_omits {
6278        if let Some(omits) = tree_omit_set.as_mut() {
6279            if include_it {
6280                omits.remove(&oid);
6281            } else {
6282                omits.insert(oid);
6283            }
6284        }
6285    }
6286    ListFilterBits {
6287        // Omitted blobs stay not-SEEN so the same OID can be revisited from a shallower path (t6112).
6288        mark_seen: include_it,
6289        do_show: include_it,
6290        skip_tree: false,
6291    }
6292}
6293
6294fn filter_object_bits_tree_begin(
6295    f: &ObjectFilter,
6296    tree_oid: ObjectId,
6297    depth: u64,
6298    tree_state: &mut TreeWalkState,
6299    tree_omit_set: &mut Option<HashSet<ObjectId>>,
6300    collect_omits: bool,
6301    sub_states: Option<&mut [CombineSubState]>,
6302) -> ListFilterBits {
6303    match f {
6304        ObjectFilter::BlobNone | ObjectFilter::BlobLimit(_) => ListFilterBits {
6305            mark_seen: true,
6306            do_show: true,
6307            skip_tree: false,
6308        },
6309        ObjectFilter::TreeDepth(excl) => tree_depth_begin_tree(
6310            tree_oid,
6311            depth,
6312            *excl,
6313            tree_state,
6314            tree_omit_set,
6315            collect_omits,
6316        ),
6317        ObjectFilter::SparseOid(_) => ListFilterBits {
6318            mark_seen: true,
6319            do_show: true,
6320            skip_tree: false,
6321        },
6322        ObjectFilter::ObjectType(k) => {
6323            // Match Git `filter_object_type` LOFS_BEGIN_TREE: only commit/tag filters skip recursion;
6324            // blob filters must walk trees to reach blobs.
6325            let show = *k == FilterObjectKind::Tree;
6326            let skip_tree = matches!(k, FilterObjectKind::Commit | FilterObjectKind::Tag);
6327            ListFilterBits {
6328                mark_seen: true,
6329                do_show: show,
6330                skip_tree,
6331            }
6332        }
6333        ObjectFilter::Combine(parts) => {
6334            // `sub_states` is always `Some` for a `Combine` filter; fall back to the
6335            // neutral merge if the invariant is ever violated rather than panicking.
6336            let Some(states) = sub_states else {
6337                return ListFilterBits::merge_combine(&[], &[]);
6338            };
6339            debug_assert_eq!(states.len(), parts.len());
6340            let mut bits = Vec::with_capacity(parts.len());
6341            let mut skipping = Vec::with_capacity(parts.len());
6342            for (i, p) in parts.iter().enumerate() {
6343                let b = filter_object_bits_tree_begin(
6344                    p,
6345                    tree_oid,
6346                    depth,
6347                    &mut states[i].tree_state,
6348                    &mut states[i].tree_omit_set,
6349                    collect_omits,
6350                    None,
6351                );
6352                if b.skip_tree {
6353                    states[i].is_skipping_tree = true;
6354                    states[i].skip_tree_oid = Some(tree_oid);
6355                } else {
6356                    states[i].is_skipping_tree = false;
6357                    states[i].skip_tree_oid = None;
6358                }
6359                bits.push(b);
6360                skipping.push(states[i].is_skipping_tree);
6361            }
6362            ListFilterBits::merge_combine(&bits, &skipping)
6363        }
6364    }
6365}
6366
6367fn filter_object_bits_blob(
6368    f: &ObjectFilter,
6369    oid: ObjectId,
6370    size: u64,
6371    parent_depth: u64,
6372    tree_omit_set: &mut Option<HashSet<ObjectId>>,
6373    collect_omits: bool,
6374    sub_states: Option<&mut [CombineSubState]>,
6375) -> ListFilterBits {
6376    match f {
6377        ObjectFilter::BlobNone => ListFilterBits {
6378            mark_seen: true,
6379            do_show: false,
6380            skip_tree: false,
6381        },
6382        ObjectFilter::BlobLimit(limit) => {
6383            let include = size < *limit;
6384            ListFilterBits {
6385                mark_seen: true,
6386                do_show: include,
6387                skip_tree: false,
6388            }
6389        }
6390        ObjectFilter::TreeDepth(excl) => {
6391            tree_depth_blob(oid, size, parent_depth, *excl, tree_omit_set, collect_omits)
6392        }
6393        ObjectFilter::SparseOid(_) => ListFilterBits {
6394            mark_seen: true,
6395            do_show: true,
6396            skip_tree: false,
6397        },
6398        ObjectFilter::ObjectType(k) => {
6399            let show = *k == FilterObjectKind::Blob;
6400            ListFilterBits {
6401                mark_seen: true,
6402                do_show: show,
6403                skip_tree: false,
6404            }
6405        }
6406        ObjectFilter::Combine(parts) => {
6407            // `sub_states` is always `Some` for a `Combine` filter; fall back to the
6408            // neutral merge if the invariant is ever violated rather than panicking.
6409            let Some(states) = sub_states else {
6410                return ListFilterBits::merge_combine(&[], &[]);
6411            };
6412            let mut bits = Vec::with_capacity(parts.len());
6413            let mut skipping = Vec::with_capacity(parts.len());
6414            for (i, p) in parts.iter().enumerate() {
6415                let b = filter_object_bits_blob(
6416                    p,
6417                    oid,
6418                    size,
6419                    parent_depth,
6420                    &mut states[i].tree_omit_set,
6421                    collect_omits,
6422                    None,
6423                );
6424                bits.push(b);
6425                skipping.push(states[i].is_skipping_tree);
6426            }
6427            ListFilterBits::merge_combine(&bits, &skipping)
6428        }
6429    }
6430}
6431
6432#[derive(Debug)]
6433struct CombineSubState {
6434    tree_state: TreeWalkState,
6435    tree_omit_set: Option<HashSet<ObjectId>>,
6436    is_skipping_tree: bool,
6437    skip_tree_oid: Option<ObjectId>,
6438}
6439
6440impl CombineSubState {
6441    fn new(parts_len: usize, collect_omits: bool) -> Vec<Self> {
6442        (0..parts_len)
6443            .map(|_| Self {
6444                tree_state: TreeWalkState::new(),
6445                tree_omit_set: collect_omits.then(HashSet::new),
6446                is_skipping_tree: false,
6447                skip_tree_oid: None,
6448            })
6449            .collect()
6450    }
6451
6452    fn prepare_sub_states(
6453        filter: Option<&ObjectFilter>,
6454        collect_omits: bool,
6455    ) -> Option<Vec<CombineSubState>> {
6456        match filter {
6457            Some(ObjectFilter::Combine(parts)) => {
6458                Some(CombineSubState::new(parts.len(), collect_omits))
6459            }
6460            _ => None,
6461        }
6462    }
6463}
6464
6465#[allow(dead_code)]
6466fn collect_tree_objects_filtered(
6467    repo: &Repository,
6468    tree_oid: ObjectId,
6469    prefix: &str,
6470    depth: u64,
6471    explicit_root: bool,
6472    parent_union: Option<&HashSet<ObjectId>>,
6473    tree_state: &mut TreeWalkState,
6474    emitted: &mut HashSet<ObjectId>,
6475    result: &mut Vec<(ObjectId, String)>,
6476    omitted: &mut Vec<ObjectId>,
6477    missing: &mut Vec<ObjectId>,
6478    missing_seen: &mut HashSet<ObjectId>,
6479    filter: Option<&ObjectFilter>,
6480    filter_provided: bool,
6481    missing_action: MissingAction,
6482    sparse_lines: Option<&[String]>,
6483    skip_trees_for_type_filter: bool,
6484    omit_object_paths: bool,
6485    packed_set: Option<&HashSet<ObjectId>>,
6486    collect_tree_omits: bool,
6487    tree_omit_set: &mut Option<HashSet<ObjectId>>,
6488    combine_states: &mut Option<Vec<CombineSubState>>,
6489) -> Result<()> {
6490    if !explicit_root {
6491        if let Some(pu) = parent_union {
6492            if pu.contains(&tree_oid) {
6493                return Ok(());
6494            }
6495        }
6496    }
6497    let object = match repo.odb.read(&tree_oid) {
6498        Ok(object) => object,
6499        Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
6500            if missing_action.reports_missing() && missing_seen.insert(tree_oid) {
6501                missing.push(tree_oid);
6502            }
6503            return Ok(());
6504        }
6505        Err(err) => return Err(err),
6506    };
6507    if object.kind != ObjectKind::Tree {
6508        return Err(Error::CorruptObject(format!(
6509            "object {tree_oid} is not a tree"
6510        )));
6511    }
6512
6513    let bits = match filter {
6514        None => ListFilterBits {
6515            mark_seen: true,
6516            do_show: true,
6517            skip_tree: false,
6518        },
6519        Some(f) => {
6520            if explicit_root && matches!(f, ObjectFilter::TreeDepth(0)) {
6521                ListFilterBits {
6522                    mark_seen: true,
6523                    do_show: true,
6524                    skip_tree: true,
6525                }
6526            } else if explicit_root && !filter_provided {
6527                ListFilterBits {
6528                    mark_seen: true,
6529                    do_show: true,
6530                    skip_tree: false,
6531                }
6532            } else {
6533                match f {
6534                    ObjectFilter::Combine(_) => filter_object_bits_tree_begin(
6535                        f,
6536                        tree_oid,
6537                        depth,
6538                        tree_state,
6539                        tree_omit_set,
6540                        collect_tree_omits,
6541                        combine_states.as_mut().map(Vec::as_mut_slice),
6542                    ),
6543                    _ => filter_object_bits_tree_begin(
6544                        f,
6545                        tree_oid,
6546                        depth,
6547                        tree_state,
6548                        tree_omit_set,
6549                        collect_tree_omits,
6550                        None,
6551                    ),
6552                }
6553            }
6554        }
6555    };
6556
6557    // Git `filter_sparse` always shows tree objects; sparse patterns gate blobs (and inherited
6558    // default_match), not whether the tree OID is listed.
6559    let tree_included = bits.do_show;
6560    if tree_included {
6561        if !packed_set.is_some_and(|p| p.contains(&tree_oid)) && emitted.insert(tree_oid) {
6562            let out_path = if omit_object_paths {
6563                String::new()
6564            } else {
6565                prefix.to_owned()
6566            };
6567            result.push((tree_oid, out_path));
6568        }
6569    } else if bits.mark_seen {
6570        omitted.push(tree_oid);
6571    }
6572
6573    if bits.skip_tree {
6574        trace_skip_tree_contents(prefix);
6575    }
6576
6577    if skip_trees_for_type_filter && depth == 0 && !explicit_root {
6578        return Ok(());
6579    }
6580
6581    if bits.skip_tree {
6582        return Ok(());
6583    }
6584
6585    let entries = parse_tree(&object.data)?;
6586    for entry in entries {
6587        if entry.mode == 0o160000 {
6588            continue;
6589        }
6590        let name = String::from_utf8_lossy(&entry.name).to_string();
6591        let path = if prefix.is_empty() {
6592            name.clone()
6593        } else {
6594            format!("{prefix}/{name}")
6595        };
6596        let child_obj = match repo.odb.read(&entry.oid) {
6597            Ok(object) => object,
6598            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
6599                if missing_action.reports_missing() && missing_seen.insert(entry.oid) {
6600                    missing.push(entry.oid);
6601                }
6602                continue;
6603            }
6604            Err(err) => return Err(err),
6605        };
6606        if entry.mode == 0o040000 {
6607            if child_obj.kind != ObjectKind::Tree {
6608                return Err(Error::CorruptObject(format!(
6609                    "object {} is not a tree",
6610                    entry.oid
6611                )));
6612            }
6613            if let Some(pu) = parent_union {
6614                if pu.contains(&entry.oid) {
6615                    continue;
6616                }
6617            }
6618            let child_tree_depth = depth + 1;
6619            collect_tree_objects_filtered(
6620                repo,
6621                entry.oid,
6622                &path,
6623                child_tree_depth,
6624                false,
6625                parent_union,
6626                tree_state,
6627                emitted,
6628                result,
6629                omitted,
6630                missing,
6631                missing_seen,
6632                filter,
6633                filter_provided,
6634                missing_action,
6635                sparse_lines,
6636                skip_trees_for_type_filter,
6637                omit_object_paths,
6638                packed_set,
6639                collect_tree_omits,
6640                tree_omit_set,
6641                combine_states,
6642            )?;
6643        } else {
6644            if let Some(pu) = parent_union {
6645                if pu.contains(&entry.oid) {
6646                    continue;
6647                }
6648            }
6649            if child_obj.kind == ObjectKind::Blob {
6650                let sparse_blob = sparse_filter_includes_path(repo, &path, sparse_lines);
6651                let blob_bits = match filter {
6652                    None => ListFilterBits {
6653                        mark_seen: true,
6654                        do_show: true,
6655                        skip_tree: false,
6656                    },
6657                    Some(f) => {
6658                        if explicit_root && !filter_provided {
6659                            ListFilterBits {
6660                                mark_seen: true,
6661                                do_show: true,
6662                                skip_tree: false,
6663                            }
6664                        } else {
6665                            match f {
6666                                ObjectFilter::Combine(_) => filter_object_bits_blob(
6667                                    f,
6668                                    entry.oid,
6669                                    child_obj.data.len() as u64,
6670                                    depth,
6671                                    tree_omit_set,
6672                                    collect_tree_omits,
6673                                    combine_states.as_mut().map(Vec::as_mut_slice),
6674                                ),
6675                                _ => filter_object_bits_blob(
6676                                    f,
6677                                    entry.oid,
6678                                    child_obj.data.len() as u64,
6679                                    depth,
6680                                    tree_omit_set,
6681                                    collect_tree_omits,
6682                                    None,
6683                                ),
6684                            }
6685                        }
6686                    }
6687                };
6688                let blob_included = blob_bits.do_show && sparse_blob;
6689                if !blob_included {
6690                    omitted.push(entry.oid);
6691                } else if blob_bits.mark_seen
6692                    && emitted.insert(entry.oid)
6693                    && !packed_set.is_some_and(|p| p.contains(&entry.oid))
6694                {
6695                    let out_path = if omit_object_paths {
6696                        String::new()
6697                    } else {
6698                        path.clone()
6699                    };
6700                    result.push((entry.oid, out_path));
6701                }
6702            } else {
6703                if emitted.contains(&entry.oid) {
6704                    return Err(Error::CorruptObject(format!(
6705                        "object {} is not a blob",
6706                        entry.oid
6707                    )));
6708                }
6709                if emitted.insert(entry.oid) {
6710                    result.push((entry.oid, path));
6711                }
6712            }
6713        }
6714    }
6715
6716    if let Some(f) = filter {
6717        if let ObjectFilter::Combine(_) = f {
6718            if let Some(states) = combine_states.as_mut() {
6719                for st in states.iter_mut() {
6720                    if st.is_skipping_tree && st.skip_tree_oid == Some(tree_oid) {
6721                        st.is_skipping_tree = false;
6722                        st.skip_tree_oid = None;
6723                    }
6724                }
6725            }
6726        }
6727    }
6728
6729    Ok(())
6730}
6731
6732fn collect_root_object(
6733    repo: &Repository,
6734    root: &RootObject,
6735    tree_state: &mut TreeWalkState,
6736    emitted: &mut HashSet<ObjectId>,
6737    result: &mut Vec<(ObjectId, String)>,
6738    omitted: &mut Vec<ObjectId>,
6739    missing: &mut Vec<ObjectId>,
6740    missing_seen: &mut HashSet<ObjectId>,
6741    filter: Option<&ObjectFilter>,
6742    filter_provided: bool,
6743    missing_action: MissingAction,
6744    sparse_lines: Option<&[String]>,
6745    skip_trees_for_type_filter: bool,
6746    omit_object_paths: bool,
6747    packed_set: Option<&HashSet<ObjectId>>,
6748    collect_tree_omits: bool,
6749    tree_omit_set: &mut Option<HashSet<ObjectId>>,
6750    combine_states: &mut Option<Vec<CombineSubState>>,
6751) -> Result<()> {
6752    if let Some(tag_oid) = root.wrap_with_tag {
6753        if root.expected_kind != Some(ExpectedObjectKind::Commit) {
6754            let show_tag = match filter {
6755                None => true,
6756                Some(f) => f.includes_commit_or_tag_object(ObjectKind::Tag),
6757            };
6758            if show_tag && emitted.insert(tag_oid) {
6759                result.push((tag_oid, "tag".to_owned()));
6760            }
6761        }
6762    }
6763
6764    let object = match repo.odb.read(&root.oid) {
6765        Ok(object) => object,
6766        Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
6767            if missing_action.reports_missing() && missing_seen.insert(root.oid) {
6768                missing.push(root.oid);
6769            }
6770            return Ok(());
6771        }
6772        Err(err) => return Err(err),
6773    };
6774
6775    if let Some(expected) = root.expected_kind {
6776        if !expected.matches(object.kind) {
6777            return Err(Error::CorruptObject(format!(
6778                "object {} is not a {}",
6779                root.input,
6780                expected.as_str()
6781            )));
6782        }
6783    }
6784
6785    match object.kind {
6786        ObjectKind::Commit => {
6787            if emitted.insert(root.oid) {
6788                result.push((root.oid, "\0".to_owned()));
6789            }
6790            if let Some(tag_oid) = root.wrap_with_tag {
6791                let show_tag = match filter {
6792                    None => true,
6793                    Some(f) => f.includes_commit_or_tag_object(ObjectKind::Tag),
6794                };
6795                if show_tag && emitted.insert(tag_oid) {
6796                    result.push((tag_oid, "tag".to_owned()));
6797                }
6798            }
6799            let commit = parse_commit(&object.data)?;
6800            // See the note in `collect_objects_segmented`: the parent_union short-circuit must be
6801            // disabled when a filter is active so that trees are visited per commit exactly like
6802            // Git, preserving `tree:`/`combine:` skip-tree semantics and trace output.
6803            let parent_union = if filter.is_some() {
6804                None
6805            } else {
6806                Some(union_parent_reachable_objects(
6807                    repo,
6808                    &commit.parents,
6809                    missing_action,
6810                    missing,
6811                    missing_seen,
6812                )?)
6813            };
6814            collect_tree_objects_filtered(
6815                repo,
6816                commit.tree,
6817                "",
6818                0,
6819                false,
6820                parent_union.as_ref(),
6821                tree_state,
6822                emitted,
6823                result,
6824                omitted,
6825                missing,
6826                missing_seen,
6827                filter,
6828                filter_provided,
6829                missing_action,
6830                sparse_lines,
6831                skip_trees_for_type_filter,
6832                omit_object_paths,
6833                packed_set,
6834                collect_tree_omits,
6835                tree_omit_set,
6836                combine_states,
6837            )?;
6838        }
6839        ObjectKind::Tree => {
6840            let root_path = root.root_path.as_deref().unwrap_or("");
6841            collect_tree_objects_filtered(
6842                repo,
6843                root.oid,
6844                root_path,
6845                0,
6846                true,
6847                None,
6848                tree_state,
6849                emitted,
6850                result,
6851                omitted,
6852                missing,
6853                missing_seen,
6854                filter,
6855                filter_provided,
6856                missing_action,
6857                sparse_lines,
6858                skip_trees_for_type_filter,
6859                omit_object_paths,
6860                packed_set,
6861                collect_tree_omits,
6862                tree_omit_set,
6863                combine_states,
6864            )?;
6865        }
6866        ObjectKind::Blob => {
6867            let path_for_sparse = root.root_path.as_deref().unwrap_or("");
6868            let sparse_blob = sparse_filter_includes_path(repo, path_for_sparse, sparse_lines);
6869            let blob_bits = match filter {
6870                None => ListFilterBits {
6871                    mark_seen: true,
6872                    do_show: true,
6873                    skip_tree: false,
6874                },
6875                Some(f) => {
6876                    if !filter_provided {
6877                        ListFilterBits {
6878                            mark_seen: true,
6879                            do_show: true,
6880                            skip_tree: false,
6881                        }
6882                    } else {
6883                        match f {
6884                            ObjectFilter::Combine(_) => filter_object_bits_blob(
6885                                f,
6886                                root.oid,
6887                                object.data.len() as u64,
6888                                0,
6889                                tree_omit_set,
6890                                collect_tree_omits,
6891                                combine_states.as_mut().map(Vec::as_mut_slice),
6892                            ),
6893                            _ => filter_object_bits_blob(
6894                                f,
6895                                root.oid,
6896                                object.data.len() as u64,
6897                                0,
6898                                tree_omit_set,
6899                                collect_tree_omits,
6900                                None,
6901                            ),
6902                        }
6903                    }
6904                }
6905            };
6906            let blob_included = blob_bits.do_show && sparse_blob;
6907            if !blob_included {
6908                if blob_bits.mark_seen {
6909                    omitted.push(root.oid);
6910                }
6911                return Ok(());
6912            }
6913            if packed_set.is_some_and(|p| p.contains(&root.oid)) {
6914                return Ok(());
6915            }
6916            if blob_bits.mark_seen && !emitted.insert(root.oid) {
6917                return Ok(());
6918            }
6919            let out_path = if omit_object_paths {
6920                String::new()
6921            } else {
6922                path_for_sparse.to_owned()
6923            };
6924            result.push((root.oid, out_path));
6925        }
6926        ObjectKind::Tag => {
6927            let tag = parse_tag(&object.data)?;
6928            let expected_kind =
6929                ExpectedObjectKind::from_tag_type(&tag.object_type).ok_or_else(|| {
6930                    Error::CorruptObject(format!(
6931                        "object {} has unsupported tag type '{}'",
6932                        root.input, tag.object_type
6933                    ))
6934                })?;
6935            let nested = RootObject {
6936                oid: tag.object,
6937                input: root.input.clone(),
6938                expected_kind: Some(expected_kind),
6939                root_path: None,
6940                wrap_with_tag: None,
6941            };
6942            collect_root_object(
6943                repo,
6944                &nested,
6945                tree_state,
6946                emitted,
6947                result,
6948                omitted,
6949                missing,
6950                missing_seen,
6951                filter,
6952                filter_provided,
6953                missing_action,
6954                sparse_lines,
6955                skip_trees_for_type_filter,
6956                omit_object_paths,
6957                packed_set,
6958                collect_tree_omits,
6959                tree_omit_set,
6960                combine_states,
6961            )?;
6962        }
6963    }
6964
6965    Ok(())
6966}
6967
6968/// Collect reachable objects in commit order: objects for each commit are emitted
6969/// right after that commit, rather than all objects after all commits.
6970/// Returns (objects, omitted, per_commit_counts).
6971fn collect_reachable_objects_in_commit_order(
6972    repo: &Repository,
6973    _graph: &mut CommitGraph<'_>,
6974    commits: &[ObjectId],
6975    object_roots: &[RootObject],
6976    tip_annotated_tags: &HashMap<ObjectId, ObjectId>,
6977    filter: Option<&ObjectFilter>,
6978    filter_provided: bool,
6979    missing_action: MissingAction,
6980    sparse_lines: Option<&[String]>,
6981    skip_trees_for_type_filter: bool,
6982    omit_object_paths: bool,
6983    packed_set: Option<&HashSet<ObjectId>>,
6984    collect_tree_omits: bool,
6985) -> Result<(
6986    Vec<(ObjectId, String)>,
6987    Vec<ObjectId>,
6988    Vec<ObjectId>,
6989    Vec<usize>,
6990)> {
6991    let mut tree_state = TreeWalkState::new();
6992    let mut top_tree_omit =
6993        walk_needs_top_tree_omit_set(filter, collect_tree_omits).then(HashSet::<ObjectId>::new);
6994    let mut combine_states = CombineSubState::prepare_sub_states(filter, collect_tree_omits);
6995    let mut emitted = HashSet::new();
6996    let mut result = Vec::new();
6997    let mut omitted = Vec::new();
6998    let mut missing = Vec::new();
6999    let mut missing_seen = HashSet::new();
7000    let mut counts = Vec::with_capacity(commits.len());
7001    for &commit_oid in commits {
7002        let commit = match load_commit(repo, commit_oid) {
7003            Ok(commit) => commit,
7004            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
7005                if missing_action.reports_missing() && missing_seen.insert(commit_oid) {
7006                    missing.push(commit_oid);
7007                }
7008                counts.push(0);
7009                continue;
7010            }
7011            Err(err) => return Err(err),
7012        };
7013        let before = result.len();
7014        if let Some(&tag_oid) = tip_annotated_tags.get(&commit_oid) {
7015            if emitted.insert(tag_oid) {
7016                result.push((tag_oid, "tag".to_owned()));
7017            }
7018        }
7019        // Match Git `rev-list --objects`: walk each commit's tree in full traversal order and rely
7020        // on `emitted` for OID de-duplication. Do not subtract parent reachability here — that would
7021        // skip blobs that still belong after this commit's tree line (t6100-rev-list-in-order).
7022        collect_tree_objects_filtered(
7023            repo,
7024            commit.tree,
7025            "",
7026            0,
7027            false,
7028            None,
7029            &mut tree_state,
7030            &mut emitted,
7031            &mut result,
7032            &mut omitted,
7033            &mut missing,
7034            &mut missing_seen,
7035            filter,
7036            filter_provided,
7037            missing_action,
7038            sparse_lines,
7039            skip_trees_for_type_filter,
7040            omit_object_paths,
7041            packed_set,
7042            collect_tree_omits,
7043            &mut top_tree_omit,
7044            &mut combine_states,
7045        )?;
7046        counts.push(result.len() - before);
7047    }
7048
7049    for root in object_roots {
7050        collect_root_object(
7051            repo,
7052            root,
7053            &mut tree_state,
7054            &mut emitted,
7055            &mut result,
7056            &mut omitted,
7057            &mut missing,
7058            &mut missing_seen,
7059            filter,
7060            filter_provided,
7061            missing_action,
7062            sparse_lines,
7063            skip_trees_for_type_filter,
7064            omit_object_paths,
7065            packed_set,
7066            collect_tree_omits,
7067            &mut top_tree_omit,
7068            &mut combine_states,
7069        )?;
7070    }
7071
7072    Ok((result, omitted, missing, counts))
7073}
7074
7075/// Collect OIDs of all objects in packs that have a `.keep` file.
7076fn kept_object_ids(repo: &Repository) -> Result<HashSet<ObjectId>> {
7077    let pack_dir = repo.git_dir.join("objects/pack");
7078    let mut kept = HashSet::new();
7079    if !pack_dir.is_dir() {
7080        return Ok(kept);
7081    }
7082    for entry in std::fs::read_dir(&pack_dir)? {
7083        let entry = entry?;
7084        let path = entry.path();
7085        if path.extension().is_some_and(|ext| ext == "keep") {
7086            // Find the corresponding .idx file
7087            let idx_path = path.with_extension("idx");
7088            if idx_path.exists() {
7089                if let Ok(oids) = crate::pack::read_idx_object_ids(&idx_path) {
7090                    kept.extend(oids);
7091                }
7092            }
7093        }
7094    }
7095    Ok(kept)
7096}
7097
7098/// Like [`flatten_tree`] but also carries each blob's file mode, so callers can detect
7099/// mode-only changes (e.g. an executable-bit flip) when deciding whether a path changed.
7100/// Git's tree diff treats a path as modified when *either* the object id or the (canonical)
7101/// mode differs; the OID-only [`flatten_tree`] cannot see a mode-only change.
7102fn flatten_tree_with_mode(
7103    repo: &Repository,
7104    tree_oid: ObjectId,
7105    prefix: &str,
7106) -> Result<Vec<(String, (ObjectId, u32))>> {
7107    let mut result = Vec::new();
7108    let object = match repo.odb.read(&tree_oid) {
7109        Ok(o) => o,
7110        Err(_) => return Ok(result),
7111    };
7112    if object.kind != ObjectKind::Tree {
7113        return Ok(result);
7114    }
7115    let entries = parse_tree(&object.data)?;
7116    for entry in entries {
7117        let name = String::from_utf8_lossy(&entry.name).to_string();
7118        let path = if prefix.is_empty() {
7119            name
7120        } else {
7121            format!("{prefix}/{name}")
7122        };
7123        let child = match repo.odb.read(&entry.oid) {
7124            Ok(o) => o,
7125            Err(Error::ObjectNotFound(_)) => continue,
7126            Err(err) => return Err(err),
7127        };
7128        if child.kind == ObjectKind::Tree {
7129            result.extend(flatten_tree_with_mode(repo, entry.oid, &path)?);
7130        } else {
7131            result.push((path, (entry.oid, canon_tree_mode(entry.mode))));
7132        }
7133    }
7134    Ok(result)
7135}
7136
7137/// Canonicalize a tree entry mode for path-change comparison, mirroring Git's `canon_mode`:
7138/// regular files collapse to `0100644`/`0100755`, symlinks to `0120000`, gitlinks to
7139/// `0160000`. This keeps mode comparison stable regardless of incidental low-bit noise.
7140fn canon_tree_mode(mode: u32) -> u32 {
7141    const S_IFMT: u32 = 0o170000;
7142    const S_IFREG: u32 = 0o100000;
7143    const S_IFLNK: u32 = 0o120000;
7144    const S_IFGITLINK: u32 = 0o160000;
7145    match mode & S_IFMT {
7146        S_IFREG => {
7147            if mode & 0o111 != 0 {
7148                0o100755
7149            } else {
7150                0o100644
7151            }
7152        }
7153        S_IFLNK => 0o120000,
7154        S_IFGITLINK => 0o160000,
7155        _ => mode,
7156    }
7157}
7158
7159/// Compute merge bases between two commits.
7160pub fn merge_bases(
7161    repo: &Repository,
7162    a: ObjectId,
7163    b: ObjectId,
7164    first_parent_only: bool,
7165) -> Result<Vec<ObjectId>> {
7166    let mut graph = CommitGraph::new(repo, first_parent_only);
7167    let ancestors_a = walk_closure(&mut graph, &[a])?;
7168    let ancestors_b = walk_closure(&mut graph, &[b])?;
7169    let common: HashSet<ObjectId> = ancestors_a.intersection(&ancestors_b).copied().collect();
7170    if common.is_empty() {
7171        return Ok(Vec::new());
7172    }
7173    // Merge bases: common ancestors not dominated by other common ancestors
7174    let mut bases = Vec::new();
7175    for &c in &common {
7176        let is_dominated = common.iter().any(|&other| {
7177            if other == c {
7178                return false;
7179            }
7180            let other_anc = walk_closure(&mut graph, &[other]).unwrap_or_default();
7181            other_anc.contains(&c)
7182        });
7183        if !is_dominated {
7184            bases.push(c);
7185        }
7186    }
7187    if bases.is_empty() {
7188        let mut sorted: Vec<_> = common.into_iter().collect();
7189        sorted.sort_by_key(|b| std::cmp::Reverse(graph.committer_time(*b)));
7190        bases.push(sorted[0]);
7191    }
7192    Ok(bases)
7193}