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