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::Index;
21use crate::objects::{parse_commit, parse_tag, parse_tree, ObjectId, ObjectKind};
22use crate::pack;
23use crate::patch_ids::compute_patch_id;
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 silently ignore missing objects.
49    Allow,
50}
51
52/// Kind selector for `object:type=<kind>` filters.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum FilterObjectKind {
55    Blob,
56    Tree,
57    Commit,
58    Tag,
59}
60
61/// Object filter specification for `--filter=`.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum ObjectFilter {
64    /// `blob:none` — omit all blobs.
65    BlobNone,
66    /// `blob:limit=<n>` — omit blobs larger than `n` bytes.
67    BlobLimit(u64),
68    /// `tree:<depth>` — omit trees deeper than `depth`.
69    TreeDepth(u64),
70    /// `sparse:oid=<rev>:<path>` or raw hex — sparse-checkout style path filter from a blob.
71    SparseOid(String),
72    /// `object:type=(blob|tree|commit|tag)` — keep only objects of that type.
73    ObjectType(FilterObjectKind),
74    /// `combine:<filter>+<filter>+…` — apply multiple filters.
75    Combine(Vec<ObjectFilter>),
76}
77
78impl ObjectFilter {
79    /// Parse a `--filter=<spec>` value.
80    pub fn parse(spec: &str) -> std::result::Result<Self, String> {
81        Self::parse_inner(spec.trim(), false)
82    }
83
84    fn parse_inner(spec: &str, from_combine_subfilter: bool) -> std::result::Result<Self, String> {
85        if spec == "blob:none" {
86            return Ok(ObjectFilter::BlobNone);
87        }
88        if let Some(rest) = spec.strip_prefix("blob:limit=") {
89            let bytes = parse_size_suffix(rest)
90                .ok_or_else(|| format!("invalid blob:limit value: {rest}"))?;
91            return Ok(ObjectFilter::BlobLimit(bytes));
92        }
93        if let Some(rest) = spec.strip_prefix("tree:") {
94            if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit()) {
95                return Err(if from_combine_subfilter {
96                    "expected 'tree:<depth>'.".to_owned()
97                } else {
98                    format!("invalid tree depth: {rest}")
99                });
100            }
101            let depth: u64 = rest.parse().map_err(|_| {
102                if from_combine_subfilter {
103                    "expected 'tree:<depth>'.".to_owned()
104                } else {
105                    format!("invalid tree depth: {rest}")
106                }
107            })?;
108            return Ok(ObjectFilter::TreeDepth(depth));
109        }
110        if let Some(rest) = spec.strip_prefix("object:type=") {
111            let kind = match rest {
112                "blob" => FilterObjectKind::Blob,
113                "tree" => FilterObjectKind::Tree,
114                "commit" => FilterObjectKind::Commit,
115                "tag" => FilterObjectKind::Tag,
116                "" => return Err("invalid object type".to_owned()),
117                _ => return Err(format!("invalid object type: {rest}")),
118            };
119            return Ok(ObjectFilter::ObjectType(kind));
120        }
121        if let Some(rest) = spec.strip_prefix("sparse:oid=") {
122            if rest.is_empty() {
123                return Err("invalid sparse:oid value: ".to_owned());
124            }
125            return Ok(ObjectFilter::SparseOid(rest.to_owned()));
126        }
127        if let Some(rest) = spec.strip_prefix("combine:") {
128            if rest.is_empty() {
129                return Err("expected something after combine:".to_owned());
130            }
131            let parts = split_combine_raw_parts(rest);
132            if parts.is_empty() {
133                return Err("expected something after combine:".to_owned());
134            }
135            let mut filters = Vec::new();
136            for part in parts {
137                filters.push(Self::parse_from_combine_subfilter(part)?);
138            }
139            return Ok(ObjectFilter::Combine(filters));
140        }
141        Err(format!("invalid filter-spec '{spec}'"))
142    }
143
144    fn parse_from_combine_subfilter(encoded: &str) -> std::result::Result<Self, String> {
145        if let Some(ch) = combine_subfilter_has_reserved(encoded) {
146            return Err(format!("must escape char in sub-filter-spec: '{ch}'"));
147        }
148        let decoded = url_decode(encoded);
149        Self::parse_inner(&decoded, true)
150    }
151
152    /// Merge another `--filter` argument (Git joins multiple filters with AND).
153    #[must_use]
154    pub fn merge_with(self, other: Self) -> Self {
155        match (self, other) {
156            (ObjectFilter::Combine(mut a), ObjectFilter::Combine(mut b)) => {
157                a.append(&mut b);
158                ObjectFilter::Combine(a)
159            }
160            (ObjectFilter::Combine(mut a), b) => {
161                a.push(b);
162                ObjectFilter::Combine(a)
163            }
164            (a, ObjectFilter::Combine(mut b)) => {
165                let mut v = vec![a];
166                v.append(&mut b);
167                ObjectFilter::Combine(v)
168            }
169            (a, b) => ObjectFilter::Combine(vec![a, b]),
170        }
171    }
172
173    /// Check if a blob should be included given its size.
174    pub fn includes_blob(&self, size: u64) -> bool {
175        match self {
176            ObjectFilter::BlobNone => false,
177            ObjectFilter::BlobLimit(limit) => size < *limit,
178            // Depth is applied via [`ObjectFilter::includes_blob_under_tree`]; this stays permissive
179            // for callers that only have a size (e.g. loose-object scans).
180            ObjectFilter::TreeDepth(_) => true,
181            ObjectFilter::SparseOid(_) => true,
182            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Blob,
183            ObjectFilter::Combine(filters) => filters.iter().all(|f| f.includes_blob(size)),
184        }
185    }
186
187    /// Whether a blob that lives directly under a tree at `parent_tree_depth` passes this filter.
188    ///
189    /// For `tree:<n>` filters, Git assigns blobs the traversal depth after entering the parent tree,
190    /// which matches `parent_tree_depth + 1` in our walk where the commit root tree is depth `0`.
191    #[must_use]
192    pub fn includes_blob_under_tree(&self, size: u64, parent_tree_depth: u64) -> bool {
193        match self {
194            ObjectFilter::BlobNone => false,
195            ObjectFilter::BlobLimit(limit) => size < *limit,
196            ObjectFilter::TreeDepth(max_depth) => parent_tree_depth.saturating_add(1) < *max_depth,
197            ObjectFilter::SparseOid(_) => true,
198            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Blob,
199            ObjectFilter::Combine(filters) => filters
200                .iter()
201                .all(|f| f.includes_blob_under_tree(size, parent_tree_depth)),
202        }
203    }
204
205    /// Check if a tree at given depth should be included.
206    pub fn includes_tree(&self, depth: u64) -> bool {
207        match self {
208            ObjectFilter::BlobNone => true,
209            ObjectFilter::BlobLimit(_) => true,
210            ObjectFilter::TreeDepth(max_depth) => depth < *max_depth,
211            ObjectFilter::SparseOid(_) => true,
212            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Tree,
213            ObjectFilter::Combine(filters) => filters.iter().all(|f| f.includes_tree(depth)),
214        }
215    }
216
217    /// Whether a commit or tag object should appear in a flat object scan (e.g. `cat-file --batch-all-objects`).
218    pub fn includes_commit_or_tag_object(&self, kind: ObjectKind) -> bool {
219        let expected = match kind {
220            ObjectKind::Commit => Some(FilterObjectKind::Commit),
221            ObjectKind::Tag => Some(FilterObjectKind::Tag),
222            _ => None,
223        };
224        match self {
225            ObjectFilter::BlobNone | ObjectFilter::BlobLimit(_) => true,
226            ObjectFilter::TreeDepth(_) => true,
227            ObjectFilter::SparseOid(_) => true,
228            ObjectFilter::ObjectType(t) => expected == Some(*t),
229            ObjectFilter::Combine(filters) => filters
230                .iter()
231                .all(|f| f.includes_commit_or_tag_object(kind)),
232        }
233    }
234
235    /// True if `kind` / `size` pass this filter when enumerating a single object (no tree path).
236    pub fn includes_loose_object(&self, kind: ObjectKind, size: u64) -> bool {
237        match kind {
238            ObjectKind::Blob => self.includes_blob(size),
239            ObjectKind::Tree => self.includes_tree(0),
240            ObjectKind::Commit | ObjectKind::Tag => self.includes_commit_or_tag_object(kind),
241        }
242    }
243
244    /// Whether an object passes this filter for direct OID lookup (`git cat-file --filter`).
245    #[must_use]
246    pub fn passes_for_object(&self, kind: ObjectKind, size: usize) -> bool {
247        self.includes_loose_object(kind, size as u64)
248    }
249}
250
251/// Reachable object IDs enumerated the same way as `git rev-list --objects --no-object-names --all`,
252/// optionally with `--filter` and `--filter-provided-objects` (used by `git cat-file --batch-all-objects`).
253#[must_use]
254pub fn reachable_object_ids_for_cat_file(
255    repo: &Repository,
256    filter: Option<&ObjectFilter>,
257    filter_provided_objects: bool,
258) -> Result<Vec<ObjectId>> {
259    let opts = RevListOptions {
260        all_refs: true,
261        objects: true,
262        no_object_names: true,
263        quiet: true,
264        filter: filter.cloned(),
265        filter_provided_objects,
266        ..Default::default()
267    };
268    let result = rev_list(repo, &[], &[], &opts)?;
269    let mut set = BTreeSet::new();
270    for (i, oid) in result.commits.iter().enumerate() {
271        if result.objects_print_commit.get(i).copied().unwrap_or(true) {
272            set.insert(*oid);
273        }
274    }
275    for (oid, _) in &result.objects {
276        set.insert(*oid);
277    }
278    Ok(set.into_iter().collect())
279}
280
281/// Objects matching `filter`, for `cat-file --batch-all-objects --filter` (same set as
282/// `rev-list --objects --all --filter --filter-provided-objects`).
283#[must_use]
284pub fn object_ids_for_cat_file_filtered(
285    repo: &Repository,
286    filter: &ObjectFilter,
287) -> Result<Vec<ObjectId>> {
288    reachable_object_ids_for_cat_file(repo, Some(filter), true)
289}
290
291/// Parse a size with optional k/m/g suffix.
292fn parse_size_suffix(s: &str) -> Option<u64> {
293    let s = s.trim();
294    if s.is_empty() {
295        return None;
296    }
297    let (num_str, multiplier) = match s.as_bytes().last()? {
298        b'k' | b'K' => (&s[..s.len() - 1], 1024u64),
299        b'm' | b'M' => (&s[..s.len() - 1], 1024 * 1024),
300        b'g' | b'G' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
301        _ => (s, 1u64),
302    };
303    let num: u64 = num_str.parse().ok()?;
304    Some(num * multiplier)
305}
306
307/// Raw substrings between `+` in a `combine:` value (still percent-encoded).
308fn split_combine_raw_parts(spec: &str) -> Vec<&str> {
309    spec.split('+').filter(|s| !s.is_empty()).collect()
310}
311
312/// Characters that must not appear raw inside a `combine:` sub-filter (matches Git
313/// `RESERVED_NON_WS` + whitespace; `%` is allowed because subspecs are percent-encoded).
314fn combine_subfilter_has_reserved(encoded: &str) -> Option<char> {
315    const RESERVED: &str = "~`!@#$^&*()[]{}\\;'\",<>?";
316    for ch in encoded.chars() {
317        if ch.is_control() || ch.is_whitespace() {
318            return Some(ch);
319        }
320        if RESERVED.contains(ch) {
321            return Some(ch);
322        }
323    }
324    None
325}
326
327/// Expand a user filter for protocol lines (`blob:limit=1k` → `blob:limit=1024`).
328#[must_use]
329pub fn expand_object_filter_for_protocol(spec: &str) -> std::result::Result<String, String> {
330    let f = ObjectFilter::parse(spec)?;
331    match f {
332        ObjectFilter::BlobLimit(n) => Ok(format!("blob:limit={n}")),
333        _ => Ok(spec.to_owned()),
334    }
335}
336
337fn combine_filter_allow_unencoded(ch: char) -> bool {
338    if ch.is_control() || ch.is_whitespace() || ch == '%' || ch == '+' {
339        return false;
340    }
341    !"~`!@#$^&*()[]{}\\;'\",<>?".contains(ch)
342}
343
344/// Append URL-encoded `raw` for Git `filter_spec` / trace (matches `allow_unencoded` in Git).
345pub fn url_encode_object_filter_subspec(raw: &str) -> String {
346    let mut out = String::new();
347    for b in raw.as_bytes() {
348        let ch = *b as char;
349        if combine_filter_allow_unencoded(ch) {
350            out.push(ch);
351        } else {
352            out.push_str(&format!("%{:02x}", b));
353        }
354    }
355    out
356}
357
358/// Emit `Add to combine filter-spec: …` when `GIT_TRACE` is enabled (Git `list-objects-filter-options.c`).
359pub fn trace_combine_filter_append(encoded_segment: &str) {
360    let Ok(trace_val) = std::env::var("GIT_TRACE") else {
361        return;
362    };
363    if trace_val.is_empty() || trace_val == "0" || trace_val.eq_ignore_ascii_case("false") {
364        return;
365    }
366    let line = format!("Add to combine filter-spec: {encoded_segment}\n");
367    match trace_val.as_str() {
368        "1" | "true" | "2" => {
369            let _ = std::io::stderr().write_all(line.as_bytes());
370        }
371        path => {
372            if let Ok(mut f) = std::fs::OpenOptions::new()
373                .create(true)
374                .append(true)
375                .open(path)
376            {
377                let _ = f.write_all(line.as_bytes());
378            }
379        }
380    }
381}
382
383/// Simple URL percent-decoding.
384fn url_decode(s: &str) -> String {
385    let mut result = String::new();
386    let mut chars = s.chars();
387    while let Some(ch) = chars.next() {
388        if ch == '%' {
389            let hi = chars.next().unwrap_or('0');
390            let lo = chars.next().unwrap_or('0');
391            let byte = u8::from_str_radix(&format!("{hi}{lo}"), 16).unwrap_or(b'?');
392            result.push(byte as char);
393        } else {
394            result.push(ch);
395        }
396    }
397    result
398}
399
400/// Ordering mode for commit output.
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum OrderingMode {
403    /// Reverse-chronological by committer date (default `rev-list` / `log` when no ordering flags).
404    Default,
405    /// Commit-date heap walk (`--date-order` without `--topo-order`; matches Git for `t6012`).
406    DateOrderWalk,
407    /// Author-date heap walk (`--author-date-order` without `--topo-order`).
408    AuthorDateWalk,
409    /// Topological walk with committer-date tie-breaks (`--topo-order`, `--simplify-merges`).
410    Topo,
411    /// Topological walk with author-date tie-breaks (`--topo-order --author-date-order`).
412    AuthorDateTopo,
413}
414
415/// Parsed and normalized options for rev-list traversal.
416#[derive(Debug, Clone)]
417pub struct RevListOptions {
418    /// Include all refs (`--all`) as positive tips.
419    pub all_refs: bool,
420    /// Follow only first parent when walking merges.
421    pub first_parent: bool,
422    /// Enable ancestry-path filtering.
423    pub ancestry_path: bool,
424    /// Optional explicit ancestry-path pivot commits.
425    pub ancestry_path_bottoms: Vec<ObjectId>,
426    /// Keep only decorated commits after traversal.
427    pub simplify_by_decoration: bool,
428    /// Commit output mode.
429    pub output_mode: OutputMode,
430    /// Suppress commit output.
431    pub quiet: bool,
432    /// Print only final count.
433    pub count: bool,
434    /// Skip N commits from selected list.
435    pub skip: usize,
436    /// Optional maximum selected commits.
437    pub max_count: Option<usize>,
438    /// Ordering strategy.
439    pub ordering: OrderingMode,
440    /// Reverse selected output order.
441    pub reverse: bool,
442    /// List reachable objects (trees, blobs) in addition to commits.
443    pub objects: bool,
444    /// Suppress object path names in --objects output.
445    pub no_object_names: bool,
446    /// Show boundary commits with `-` prefix.
447    pub boundary: bool,
448    /// Show left/right markers for symmetric diff.
449    pub left_right: bool,
450    /// Filter to left-only commits in symmetric diff.
451    pub left_only: bool,
452    /// Filter to right-only commits in symmetric diff.
453    pub right_only: bool,
454    /// Cherry-mark equivalent commits with `=` instead of `+`.
455    pub cherry_mark: bool,
456    /// Cherry-pick: omit equivalent commits from output.
457    pub cherry_pick: bool,
458    /// Minimum number of parents a commit must have to be included.
459    pub min_parents: Option<usize>,
460    /// Maximum number of parents a commit may have to be included.
461    pub max_parents: Option<usize>,
462    /// Symmetric-diff left OID (set by caller when A...B is used).
463    pub symmetric_left: Option<ObjectId>,
464    /// Symmetric-diff right OID (set by caller when A...B is used).
465    pub symmetric_right: Option<ObjectId>,
466    /// Path filters (files after `--`).
467    pub paths: Vec<String>,
468    /// Show full history (don't simplify) for path-limited walks.
469    pub full_history: bool,
470    /// Sparse mode: don't prune non-matching commits.
471    pub sparse: bool,
472    /// Further simplify history after path limiting (`--simplify-merges`).
473    pub simplify_merges: bool,
474    /// Include "diverted" merge commits on the first-parent spine (`--show-pulls`).
475    pub show_pulls: bool,
476    /// When walking excluded commits, only follow the first parent (`--exclude-first-parent-only`).
477    pub exclude_first_parent_only: bool,
478    /// Object filter for `--filter=<spec>`.
479    pub filter: Option<ObjectFilter>,
480    /// Raw `--filter=` argument strings in order (for `GIT_TRACE` when Git combines multiple filters).
481    pub filter_raw_specs: Vec<String>,
482    /// When set with `--filter`, explicitly given revision objects are filtered too.
483    pub filter_provided_objects: bool,
484    /// Print omitted objects prefixed with `~`.
485    pub filter_print_omitted: bool,
486    /// Emit objects interleaved with their introducing commit.
487    pub in_commit_order: bool,
488    /// Exclude objects in `.keep` pack files.
489    pub no_kept_objects: bool,
490    /// Behavior when referenced objects are missing.
491    pub missing_action: MissingAction,
492    /// When set with `--objects`, omit path names from non-commit object lines (bitmap-style output).
493    pub use_bitmap_index: bool,
494    /// When set with `--objects`, list only objects not present in any pack file.
495    pub unpacked_only: bool,
496    /// With `--use-bitmap-index`, emit OID-only object lines (no paths / trailing space) for filters
497    /// that match Git's bitmap object formatting.
498    pub bitmap_oid_only_objects: bool,
499    /// Reorder path-limited results for graph-friendly parent ordering (Git `log` / `rev-list`).
500    /// Internal dense passes for `--sparse` set this to `false` to avoid recursion.
501    pub path_graph_reorder: bool,
502    /// Exclude commits with committer date strictly after this Unix timestamp (`--until` / `--before`).
503    pub until_cutoff: Option<i64>,
504    /// Exclude commits with committer date strictly before this Unix timestamp (`--since` / `--after`).
505    pub since_cutoff: Option<i64>,
506    /// Include OIDs from all reflogs as extra commit tips (`git pack-objects --reflog`).
507    pub include_reflog_entries: bool,
508    /// Include blob OIDs from the index as object roots (`git pack-objects --indexed-objects`).
509    pub include_indexed_objects: bool,
510    /// When true with pathspecs, consult commit-graph Bloom filters (matches `core.commitGraph`).
511    pub use_commit_graph_bloom: bool,
512    /// `commitGraph.readChangedPaths` (default true).
513    pub commit_graph_read_changed_paths: bool,
514    /// `commitGraph.changedPathsVersion` (-1 = autodetect from graph).
515    pub commit_graph_changed_paths_version: i32,
516    /// Optional trace counters for `GIT_TRACE2_PERF` Bloom statistics.
517    pub bloom_stats: Option<BloomWalkStatsHandle>,
518}
519
520impl Default for RevListOptions {
521    fn default() -> Self {
522        Self {
523            all_refs: false,
524            first_parent: false,
525            ancestry_path: false,
526            ancestry_path_bottoms: Vec::new(),
527            simplify_by_decoration: false,
528            output_mode: OutputMode::OidOnly,
529            quiet: false,
530            count: false,
531            skip: 0,
532            max_count: None,
533            ordering: OrderingMode::Default,
534            reverse: false,
535            objects: false,
536            no_object_names: false,
537            boundary: false,
538            left_right: false,
539            left_only: false,
540            right_only: false,
541            cherry_mark: false,
542            cherry_pick: false,
543            min_parents: None,
544            max_parents: None,
545            symmetric_left: None,
546            symmetric_right: None,
547            paths: Vec::new(),
548            full_history: false,
549            sparse: false,
550            simplify_merges: false,
551            show_pulls: false,
552            exclude_first_parent_only: false,
553            filter: None,
554            filter_raw_specs: Vec::new(),
555            filter_provided_objects: false,
556            filter_print_omitted: false,
557            in_commit_order: false,
558            no_kept_objects: false,
559            missing_action: MissingAction::Error,
560            use_bitmap_index: false,
561            unpacked_only: false,
562            bitmap_oid_only_objects: false,
563            path_graph_reorder: true,
564            until_cutoff: None,
565            since_cutoff: None,
566            include_reflog_entries: false,
567            include_indexed_objects: false,
568            use_commit_graph_bloom: false,
569            commit_graph_read_changed_paths: true,
570            commit_graph_changed_paths_version: -1,
571            bloom_stats: None,
572        }
573    }
574}
575
576/// Final commit selection result.
577#[derive(Debug, Clone)]
578pub struct RevListResult {
579    /// Selected commits in final output order, after skip/max/reverse.
580    pub commits: Vec<ObjectId>,
581    /// Reachable non-commit objects when `--objects` is active.
582    /// Each entry is `(oid, optional_path)`.
583    pub objects: Vec<(ObjectId, String)>,
584    /// Objects omitted by `--filter` (for `--filter-print-omitted`).
585    pub omitted_objects: Vec<ObjectId>,
586    /// Referenced objects missing from the object database.
587    pub missing_objects: Vec<ObjectId>,
588    /// Boundary commits (excluded parents shown with `-` prefix).
589    pub boundary_commits: Vec<ObjectId>,
590    /// For `--left-right`: mapping commit OID -> true=left, false=right.
591    pub left_right_map: HashMap<ObjectId, bool>,
592    /// For `--cherry-mark`: set of commits that are equivalent (patch-id match).
593    pub cherry_equivalent: HashSet<ObjectId>,
594    /// Per-commit object counts (parallel to `commits`) for `--in-commit-order`.
595    /// When non-empty, `objects[sum(counts[..i])..sum(counts[..=i])]` are the objects
596    /// introduced by `commits[i]`.
597    pub per_commit_object_counts: Vec<usize>,
598    /// Commit OIDs given as positive revision tips (for Git `USER_GIVEN` / filter edge cases).
599    pub object_walk_tips: Vec<ObjectId>,
600    /// When `--objects` is active, whether to print the commit line before that commit's objects.
601    /// Aligns with Git marking user-given tips vs `NOT_USER_GIVEN` commits in list-objects.
602    pub objects_print_commit: Vec<bool>,
603    /// When `--objects` is active and not `--in-commit-order`, objects grouped per commit walk plus
604    /// a final segment for explicit `object_roots` (length `commits.len() + 1`).
605    pub object_segments: Vec<Vec<(ObjectId, String)>>,
606    /// True when `--use-bitmap-index --objects` should format trees/blobs as bare OIDs (no paths).
607    pub bitmap_object_format: bool,
608    /// When a positive spec named a ref to an annotated tag of a commit, maps peeled commit → tag OID.
609    pub tip_annotated_tag_by_commit: HashMap<ObjectId, ObjectId>,
610}
611
612/// Resolve and walk revisions for the requested options.
613///
614/// # Parameters
615///
616/// - `repo` - repository used for ref/object lookup.
617/// - `positive_specs` - positive revision tokens (e.g. `HEAD`, `A..B` rhs).
618/// - `negative_specs` - negative revision tokens (`^A`, `A..B` lhs).
619/// - `options` - traversal and output selection options.
620///
621/// # Errors
622///
623/// Returns [`Error::ObjectNotFound`] / [`Error::InvalidRef`] for bad revision
624/// specs and [`Error::CorruptObject`] for non-commit or malformed commit data.
625pub fn rev_list(
626    repo: &Repository,
627    positive_specs: &[String],
628    negative_specs: &[String],
629    options: &RevListOptions,
630) -> Result<RevListResult> {
631    let mut graph = CommitGraph::new(repo, options.first_parent);
632
633    let (mut include, object_roots, tip_annotated_tag_by_commit) = if options.objects {
634        resolve_specs_for_objects(repo, positive_specs)?
635    } else {
636        (
637            resolve_specs(repo, positive_specs)?,
638            Vec::new(),
639            HashMap::new(),
640        )
641    };
642    let exclude = resolve_specs(repo, negative_specs)?;
643
644    if options.all_refs {
645        include.extend(all_ref_tips(repo, &RefExclusions::default())?);
646    }
647
648    if options.objects && options.include_reflog_entries {
649        include.extend(reflog_commit_tips(repo)?);
650    }
651
652    let mut index_blob_roots: Vec<RootObject> = Vec::new();
653    if options.objects && options.include_indexed_objects && repo.work_tree.is_some() {
654        let index_path = repo.git_dir.join("index");
655        if index_path.is_file() {
656            let idx = Index::load(&index_path)?;
657            for e in &idx.entries {
658                if e.stage() != 0 {
659                    continue;
660                }
661                let path_str = String::from_utf8_lossy(&e.path).into_owned();
662                index_blob_roots.push(RootObject {
663                    oid: e.oid,
664                    input: format!(":{path_str}"),
665                    expected_kind: Some(ExpectedObjectKind::Blob),
666                    root_path: Some(path_str),
667                    wrap_with_tag: None,
668                });
669            }
670        }
671    }
672
673    let object_roots = if index_blob_roots.is_empty() {
674        object_roots
675    } else {
676        let mut merged = object_roots;
677        merged.extend(index_blob_roots);
678        merged
679    };
680
681    if include.is_empty() && object_roots.is_empty() {
682        return Err(Error::InvalidRef("no revisions specified".to_owned()));
683    }
684
685    let object_walk_tip_commits: Vec<ObjectId> = if options.objects {
686        include.clone()
687    } else {
688        Vec::new()
689    };
690
691    let (mut included, _discovery_order) = if include.is_empty() {
692        (HashSet::new(), Vec::new())
693    } else {
694        walk_closure_ordered(&mut graph, &include)?
695    };
696    let excluded = if exclude.is_empty() {
697        HashSet::new()
698    } else if options.exclude_first_parent_only {
699        walk_closure_first_parent_only(&mut graph, &exclude)?
700    } else {
701        walk_closure(&mut graph, &exclude)?
702    };
703    included.retain(|oid| !excluded.contains(oid));
704
705    if options.simplify_by_decoration {
706        let decorated = all_ref_tips(repo, &RefExclusions::default())?;
707        included.retain(|oid| decorated.contains(oid));
708    }
709
710    if options.ancestry_path {
711        let mut bottoms = options.ancestry_path_bottoms.clone();
712        if bottoms.is_empty() {
713            bottoms.extend(exclude.iter().copied());
714        }
715        if bottoms.is_empty() {
716            return Err(Error::InvalidRef(
717                "--ancestry-path requires a range with excluded tips".to_owned(),
718            ));
719        }
720        limit_to_ancestry(&mut graph, &mut included, &bottoms)?;
721    }
722
723    // Git: `--ancestry-path` implies `--full-history` for path-limited walks.
724    // `--simplify-merges` with pathspecs uses a full parent walk first, then simplifies merges.
725    let path_effective_full = options.full_history
726        || options.ancestry_path
727        || (options.simplify_merges && !options.paths.is_empty());
728
729    // Filter by parent count (--merges, --no-merges, --min-parents, --max-parents)
730    if options.min_parents.is_some() || options.max_parents.is_some() {
731        let min_p = options.min_parents.unwrap_or(0);
732        let max_p = options.max_parents.unwrap_or(usize::MAX);
733        included.retain(|oid| {
734            let count = graph.parents_of(*oid).map(|p| p.len()).unwrap_or(0);
735            count >= min_p && count <= max_p
736        });
737    }
738
739    let mut ordered = match options.ordering {
740        OrderingMode::Default | OrderingMode::DateOrderWalk | OrderingMode::AuthorDateWalk => {
741            let author_dates = options.ordering == OrderingMode::AuthorDateWalk;
742            let parent_count_filter_active =
743                options.min_parents.is_some() || options.max_parents.is_some();
744            if parent_count_filter_active {
745                // When parent-count filters (`--max-parents=1` / `--no-merges`, …) drop a user-given
746                // tip (e.g. a merge under `--no-merges`), Git still seeds the walk from that tip's
747                // parents so both sides of the merge remain reachable (`format-patch`, `rev-list`).
748                let mut tips: Vec<ObjectId> = Vec::new();
749                let mut tip_seen = HashSet::new();
750                for &tip in &include {
751                    if included.contains(&tip) {
752                        if tip_seen.insert(tip) {
753                            tips.push(tip);
754                        }
755                        continue;
756                    }
757                    let parents = graph.parents_of(tip).unwrap_or_default();
758                    for p in parents {
759                        if tip_seen.insert(p) {
760                            tips.push(p);
761                        }
762                    }
763                }
764                date_order_walk_with_tips(&mut graph, &tips, &included, author_dates)?
765            } else {
766                date_order_walk(&mut graph, &included, author_dates)?
767            }
768        }
769        OrderingMode::Topo => topo_sort(&mut graph, &included, false)?,
770        OrderingMode::AuthorDateTopo => topo_sort(&mut graph, &included, true)?,
771    };
772
773    // Path filtering: keep only commits that modify given paths
774    if !options.paths.is_empty() {
775        let paths = &options.paths;
776        let cfg = ConfigSet::load(Some(&repo.git_dir), true).unwrap_or_default();
777        let mut core_cg = match cfg.get_bool("core.commitgraph") {
778            Some(Ok(b)) => b,
779            _ => true,
780        };
781        if std::env::var("GIT_TEST_COMMIT_GRAPH").ok().as_deref() == Some("0") {
782            core_cg = false;
783        }
784        let read_paths = cfg
785            .get("commitgraph.readchangedpaths")
786            .and_then(|v| crate::config::parse_bool(&v).ok())
787            .unwrap_or(true);
788        let version = cfg
789            .get("commitgraph.changedpathsversion")
790            .and_then(|s| s.parse::<i32>().ok())
791            .unwrap_or(-1);
792
793        let use_bloom = core_cg
794            && options.use_commit_graph_bloom
795            && crate::pathspec::pathspecs_allow_bloom(paths);
796        let read_changed = read_paths && options.commit_graph_read_changed_paths;
797        let bloom_chain = if use_bloom {
798            CommitGraphChain::load(&repo.git_dir.join("objects"))
799        } else {
800            None
801        };
802        let bloom_version = if options.commit_graph_changed_paths_version != -1 {
803            options.commit_graph_changed_paths_version
804        } else {
805            version
806        };
807        let bloom_cwd = repo.bloom_pathspec_cwd();
808
809        ordered.retain(|oid| {
810            commit_touches_paths(
811                repo,
812                &mut graph,
813                *oid,
814                paths,
815                path_effective_full,
816                options.sparse,
817                options.simplify_merges,
818                options.show_pulls,
819                bloom_chain.as_ref(),
820                read_changed,
821                bloom_version,
822                options.bloom_stats.as_ref(),
823                bloom_cwd.as_deref(),
824            )
825            .unwrap_or(false)
826        });
827    }
828
829    if !options.paths.is_empty() && options.simplify_merges && !ordered.is_empty() {
830        ordered = simplify_merges_commit_list(repo, &ordered)?;
831    }
832
833    // Git-style path-limited parent reordering (dense history and `--sparse` only). Pure
834    // `--full-history` walks keep rev-list order (`t6012` full-history path expectations).
835    let path_needs_graph_reorder = !options.paths.is_empty()
836        && options.path_graph_reorder
837        && (!options.full_history || options.sparse);
838    if path_needs_graph_reorder && !ordered.is_empty() {
839        if options.sparse {
840            let mut dense_opts = options.clone();
841            dense_opts.sparse = false;
842            dense_opts.path_graph_reorder = false;
843            let dense_result = rev_list(repo, positive_specs, negative_specs, &dense_opts)?;
844            let dense_ordered = reorder_path_limited_graph_commits(
845                repo,
846                &dense_result.commits,
847                options.first_parent,
848            )?;
849            ordered = expand_sparse_path_limited_graph_history(repo, &dense_ordered)?;
850        } else {
851            ordered = reorder_path_limited_graph_commits(repo, &ordered, options.first_parent)?;
852        }
853    }
854
855    // Left-right classification for symmetric diffs
856    let mut left_right_map = HashMap::new();
857    if options.left_right
858        || options.left_only
859        || options.right_only
860        || options.cherry_mark
861        || options.cherry_pick
862    {
863        if let (Some(left_oid), Some(right_oid)) = (options.symmetric_left, options.symmetric_right)
864        {
865            // Match Git's `SYMMETRIC_LEFT` / right-only classification (`revision.c`): a commit is
866            // "left" iff it is reachable from the left tip but not from the right tip, and vice
867            // versa.  Using plain set intersection incorrectly labels the shared spine as "right"
868            // only, which breaks `--cherry-pick` on `A...B` (t3419-rebase-patch-id).
869            let left_reach = walk_closure(&mut graph, &[left_oid])?;
870            let right_reach = walk_closure(&mut graph, &[right_oid])?;
871            for &oid in &ordered {
872                let from_left = left_reach.contains(&oid);
873                let from_right = right_reach.contains(&oid);
874                left_right_map.insert(oid, from_left && !from_right);
875            }
876        }
877    }
878
879    // Cherry-pick / cherry-mark: match commits by Git-compatible patch-id (see `git revision.c`
880    // `cherry_pick_list`, used by `git rebase` todo generation).
881    let mut cherry_equivalent = HashSet::new();
882    if options.cherry_pick || options.cherry_mark {
883        let left_commits: Vec<_> = ordered
884            .iter()
885            .filter(|o| left_right_map.get(o) == Some(&true))
886            .copied()
887            .collect();
888        let right_commits: Vec<_> = ordered
889            .iter()
890            .filter(|o| left_right_map.get(o) == Some(&false))
891            .copied()
892            .collect();
893        let left_first = !left_commits.is_empty()
894            && !right_commits.is_empty()
895            && left_commits.len() < right_commits.len();
896
897        let mut by_patch: HashMap<ObjectId, ObjectId> = HashMap::new();
898        if left_first {
899            for oid in &left_commits {
900                if let Ok(Some(pid)) = compute_patch_id(&repo.odb, oid) {
901                    by_patch.entry(pid).or_insert(*oid);
902                }
903            }
904            for oid in &right_commits {
905                if let Ok(Some(pid)) = compute_patch_id(&repo.odb, oid) {
906                    if let Some(&other) = by_patch.get(&pid) {
907                        cherry_equivalent.insert(*oid);
908                        cherry_equivalent.insert(other);
909                    }
910                }
911            }
912        } else {
913            for oid in &right_commits {
914                if let Ok(Some(pid)) = compute_patch_id(&repo.odb, oid) {
915                    by_patch.entry(pid).or_insert(*oid);
916                }
917            }
918            for oid in &left_commits {
919                if let Ok(Some(pid)) = compute_patch_id(&repo.odb, oid) {
920                    if let Some(&other) = by_patch.get(&pid) {
921                        cherry_equivalent.insert(*oid);
922                        cherry_equivalent.insert(other);
923                    }
924                }
925            }
926        }
927    }
928
929    // Filter left-only / right-only
930    if options.left_only {
931        ordered.retain(|oid| left_right_map.get(oid) == Some(&true));
932    }
933    if options.right_only {
934        ordered.retain(|oid| left_right_map.get(oid) == Some(&false));
935    }
936
937    // Cherry-pick: remove equivalent commits
938    if options.cherry_pick {
939        ordered.retain(|oid| !cherry_equivalent.contains(oid));
940    }
941
942    if options.until_cutoff.is_some() || options.since_cutoff.is_some() {
943        let until = options.until_cutoff;
944        let since = options.since_cutoff;
945        ordered.retain(|oid| {
946            let ts = graph.committer_time(*oid);
947            if let Some(u) = until {
948                if ts > u {
949                    return false;
950                }
951            }
952            if let Some(s) = since {
953                if ts < s {
954                    return false;
955                }
956            }
957            true
958        });
959    }
960
961    if options.skip > 0 {
962        ordered = ordered.into_iter().skip(options.skip).collect();
963    }
964    if let Some(max_count) = options.max_count {
965        ordered.truncate(max_count);
966    }
967    if options.reverse {
968        ordered.reverse();
969    }
970
971    // Collect boundary commits: parents of included commits that are in the excluded set
972    let boundary_commits = if options.boundary {
973        let included_set: HashSet<ObjectId> = ordered.iter().copied().collect();
974        let mut boundary = Vec::new();
975        let mut boundary_seen = HashSet::new();
976        for &oid in &ordered {
977            if let Ok(parents) = graph.parents_of(oid).map(|p| p.to_vec()) {
978                for parent in parents {
979                    if !included_set.contains(&parent) && boundary_seen.insert(parent) {
980                        boundary.push(parent);
981                    }
982                }
983            }
984        }
985        boundary
986    } else {
987        Vec::new()
988    };
989
990    // Filter kept objects when --no-kept-objects is set
991    let kept_set = if options.no_kept_objects {
992        kept_object_ids(repo).unwrap_or_default()
993    } else {
994        HashSet::new()
995    };
996
997    if options.no_kept_objects {
998        ordered.retain(|oid| !kept_set.contains(oid));
999    }
1000
1001    if options.unpacked_only {
1002        let packed = packed_object_set(repo);
1003        ordered.retain(|oid| !packed.contains(oid));
1004    }
1005
1006    let commit_tips_set: HashSet<ObjectId> = object_walk_tip_commits.iter().copied().collect();
1007    let objects_print_commit: Vec<bool> = if options.objects {
1008        ordered
1009            .iter()
1010            .map(|&c| {
1011                let user_given = !options.filter_provided_objects && commit_tips_set.contains(&c);
1012                object_walk_print_commit_line(
1013                    options.filter_provided_objects,
1014                    options.filter.as_ref(),
1015                    user_given,
1016                )
1017            })
1018            .collect()
1019    } else {
1020        Vec::new()
1021    };
1022
1023    let sparse_lines = sparse_oid_lines_from_filter(repo, options.filter.as_ref());
1024    let skip_trees = skip_tree_descent_for_object_type_filter(options.filter.as_ref());
1025    let walk_cfg = ConfigSet::load(Some(&repo.git_dir), true).unwrap_or_default();
1026    let promisor_repo = crate::promisor::repo_treats_promisor_packs(&repo.git_dir, &walk_cfg);
1027    let object_walk_missing_action =
1028        if options.objects && options.missing_action == MissingAction::Error && promisor_repo {
1029            MissingAction::Allow
1030        } else {
1031            options.missing_action
1032        };
1033    let bitmap_object_format = options.objects
1034        && options.use_bitmap_index
1035        && (options.bitmap_oid_only_objects || !object_roots.is_empty() || options.unpacked_only);
1036    let omit_object_paths = bitmap_object_format;
1037    let packed_set = if options.objects && options.unpacked_only {
1038        Some(packed_object_set(repo))
1039    } else {
1040        None
1041    };
1042
1043    // Git only enables provisional omit recursion (`omits` non-NULL) with `--filter-print-omitted`.
1044    let collect_tree_omits = options.filter_print_omitted;
1045
1046    // Collect reachable objects if --objects
1047    let (objects, omitted_objects, missing_objects, per_commit_object_counts, object_segments) =
1048        if options.objects {
1049            let filter_provided = options.filter_provided_objects;
1050            let (mut objs, omit, miss, counts, segments) = if options.in_commit_order {
1051                let (o, om, mi, c) = collect_reachable_objects_in_commit_order(
1052                    repo,
1053                    &mut graph,
1054                    &ordered,
1055                    &object_roots,
1056                    &tip_annotated_tag_by_commit,
1057                    options.filter.as_ref(),
1058                    filter_provided,
1059                    object_walk_missing_action,
1060                    sparse_lines.as_deref(),
1061                    skip_trees,
1062                    omit_object_paths,
1063                    packed_set.as_ref(),
1064                    collect_tree_omits,
1065                )?;
1066                (o, om, mi, c, Vec::new())
1067            } else {
1068                let (o, om, mi, seg) = collect_reachable_objects_segmented(
1069                    repo,
1070                    &mut graph,
1071                    &ordered,
1072                    &object_roots,
1073                    &tip_annotated_tag_by_commit,
1074                    options.filter.as_ref(),
1075                    filter_provided,
1076                    object_walk_missing_action,
1077                    sparse_lines.as_deref(),
1078                    skip_trees,
1079                    omit_object_paths,
1080                    packed_set.as_ref(),
1081                    collect_tree_omits,
1082                )?;
1083                (o, om, mi, Vec::new(), seg)
1084            };
1085            if options.no_kept_objects {
1086                objs.retain(|(oid, _)| !kept_set.contains(oid));
1087            }
1088            (objs, omit, miss, counts, segments)
1089        } else {
1090            (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new())
1091        };
1092
1093    let omitted_objects = if omitted_objects.is_empty() {
1094        omitted_objects
1095    } else {
1096        let emitted: HashSet<ObjectId> = objects.iter().map(|(o, _)| *o).collect();
1097        omitted_objects
1098            .into_iter()
1099            .filter(|o| !emitted.contains(o))
1100            .collect::<BTreeSet<_>>()
1101            .into_iter()
1102            .collect()
1103    };
1104
1105    Ok(RevListResult {
1106        commits: ordered,
1107        objects,
1108        omitted_objects,
1109        missing_objects,
1110        boundary_commits,
1111        left_right_map,
1112        cherry_equivalent,
1113        per_commit_object_counts,
1114        object_walk_tips: object_walk_tip_commits,
1115        objects_print_commit,
1116        object_segments,
1117        bitmap_object_format,
1118        tip_annotated_tag_by_commit,
1119    })
1120}
1121
1122/// Parse a raw revision token into positive and negative specs.
1123///
1124/// Supports:
1125/// - `<a>..<b>` => negative `<a>`, positive `<b>`
1126/// - `^<rev>` => negative `<rev>`
1127/// - `<rev>` => positive `<rev>`
1128#[must_use]
1129pub fn split_revision_token(token: &str) -> (Vec<String>, Vec<String>) {
1130    if let Some((lhs, rhs)) = crate::rev_parse::split_double_dot_range(token) {
1131        let positive = if rhs.is_empty() {
1132            "HEAD".to_owned()
1133        } else {
1134            rhs.to_owned()
1135        };
1136        let negative = if lhs.is_empty() {
1137            "HEAD".to_owned()
1138        } else {
1139            lhs.to_owned()
1140        };
1141        return (vec![positive], vec![negative]);
1142    }
1143    if let Some(rest) = token.strip_prefix('^') {
1144        return (Vec::new(), vec![rest.to_owned()]);
1145    }
1146    (vec![token.to_owned()], Vec::new())
1147}
1148
1149fn ansi_color_from_name(name: &str) -> String {
1150    match name {
1151        "red" => "\x1b[31m".to_owned(),
1152        "green" => "\x1b[32m".to_owned(),
1153        "yellow" => "\x1b[33m".to_owned(),
1154        "blue" => "\x1b[34m".to_owned(),
1155        "magenta" => "\x1b[35m".to_owned(),
1156        "cyan" => "\x1b[36m".to_owned(),
1157        "white" => "\x1b[37m".to_owned(),
1158        "bold" => "\x1b[1m".to_owned(),
1159        "dim" => "\x1b[2m".to_owned(),
1160        "ul" | "underline" => "\x1b[4m".to_owned(),
1161        "blink" => "\x1b[5m".to_owned(),
1162        "reverse" => "\x1b[7m".to_owned(),
1163        "reset" => "\x1b[m".to_owned(),
1164        _ => String::new(),
1165    }
1166}
1167
1168fn color_name_to_code(name: &str) -> Option<u8> {
1169    match name {
1170        "black" => Some(0),
1171        "red" => Some(1),
1172        "green" => Some(2),
1173        "yellow" => Some(3),
1174        "blue" => Some(4),
1175        "magenta" => Some(5),
1176        "cyan" => Some(6),
1177        "white" => Some(7),
1178        "default" => Some(9),
1179        _ => None,
1180    }
1181}
1182
1183fn ansi_color_from_spec(spec: &str) -> String {
1184    if spec == "reset" {
1185        return "\x1b[m".to_owned();
1186    }
1187    let mut codes = Vec::new();
1188    let mut fg_set = false;
1189    for part in spec.split_whitespace() {
1190        match part {
1191            "bold" => codes.push("1".to_owned()),
1192            "dim" => codes.push("2".to_owned()),
1193            "italic" => codes.push("3".to_owned()),
1194            "ul" | "underline" => codes.push("4".to_owned()),
1195            "blink" => codes.push("5".to_owned()),
1196            "reverse" => codes.push("7".to_owned()),
1197            "strike" => codes.push("9".to_owned()),
1198            "nobold" | "nodim" => codes.push("22".to_owned()),
1199            "noitalic" => codes.push("23".to_owned()),
1200            "noul" | "nounderline" => codes.push("24".to_owned()),
1201            "noblink" => codes.push("25".to_owned()),
1202            "noreverse" => codes.push("27".to_owned()),
1203            "nostrike" => codes.push("29".to_owned()),
1204            _ => {
1205                if let Some(code) = color_name_to_code(part) {
1206                    if !fg_set {
1207                        codes.push(format!("{}", 30 + code));
1208                        fg_set = true;
1209                    } else {
1210                        codes.push(format!("{}", 40 + code));
1211                    }
1212                }
1213            }
1214        }
1215    }
1216    if codes.is_empty() {
1217        String::new()
1218    } else {
1219        format!("\x1b[{}m", codes.join(";"))
1220    }
1221}
1222
1223fn format_relative_date(diff: i64) -> String {
1224    if diff < 0 {
1225        "in the future".to_owned()
1226    } else if diff < 60 {
1227        format!("{} seconds ago", diff)
1228    } else if diff < 3600 {
1229        let m = diff / 60;
1230        if m == 1 {
1231            "1 minute ago".to_owned()
1232        } else {
1233            format!("{m} minutes ago")
1234        }
1235    } else if diff < 86400 {
1236        let h = diff / 3600;
1237        if h == 1 {
1238            "1 hour ago".to_owned()
1239        } else {
1240            format!("{h} hours ago")
1241        }
1242    } else if diff < 86400 * 30 {
1243        let d = diff / 86400;
1244        if d == 1 {
1245            "1 day ago".to_owned()
1246        } else {
1247            format!("{d} days ago")
1248        }
1249    } else if diff < 86400 * 365 {
1250        let months = diff / (86400 * 30);
1251        if months == 1 {
1252            "1 month ago".to_owned()
1253        } else {
1254            format!("{months} months ago")
1255        }
1256    } else {
1257        let years = diff / (86400 * 365);
1258        if years == 1 {
1259            "1 year ago".to_owned()
1260        } else {
1261            format!("{years} years ago")
1262        }
1263    }
1264}
1265
1266/// Render one commit according to the selected output mode.
1267///
1268/// # Errors
1269///
1270/// Returns object decode errors when commit metadata is required.
1271pub fn render_commit(
1272    repo: &Repository,
1273    oid: ObjectId,
1274    mode: &OutputMode,
1275    abbrev_len: usize,
1276) -> Result<String> {
1277    render_commit_with_color(repo, oid, mode, abbrev_len, false)
1278}
1279
1280/// Render one commit, optionally with ANSI color for `%C` placeholders.
1281pub fn render_commit_with_color(
1282    repo: &Repository,
1283    oid: ObjectId,
1284    mode: &OutputMode,
1285    abbrev_len: usize,
1286    use_color: bool,
1287) -> Result<String> {
1288    match mode {
1289        OutputMode::OidOnly => Ok(format!("{oid}")),
1290        OutputMode::Parents => {
1291            let mut out = format!("{oid}");
1292            let commit = load_commit(repo, oid)?;
1293            for parent in commit.parents {
1294                out.push(' ');
1295                out.push_str(&parent.to_hex());
1296            }
1297            Ok(out)
1298        }
1299        OutputMode::Format(fmt) => {
1300            let commit = load_commit(repo, oid)?;
1301            let subject = commit.message.lines().next().unwrap_or_default();
1302            let hex = oid.to_hex();
1303
1304            // Handle named pretty formats
1305            match fmt.as_str() {
1306                "oneline" => {
1307                    return Ok(format!("{} {}", hex, subject));
1308                }
1309                "short" => {
1310                    fn fmt_ident(ident: &str) -> String {
1311                        let name = if let Some(bracket) = ident.find('<') {
1312                            ident[..bracket].trim()
1313                        } else {
1314                            ident.trim()
1315                        };
1316                        let email = if let Some(start) = ident.find('<') {
1317                            if let Some(end) = ident.find('>') {
1318                                &ident[start..=end]
1319                            } else {
1320                                ""
1321                            }
1322                        } else {
1323                            ""
1324                        };
1325                        format!("{} {}", name, email)
1326                    }
1327                    let mut out = String::new();
1328                    out.push_str(&format!("Author: {}\n", fmt_ident(&commit.author)));
1329                    out.push('\n');
1330                    out.push_str(&format!("    {}\n", subject));
1331                    out.push('\n');
1332                    return Ok(out);
1333                }
1334                "medium" => {
1335                    fn extract_ident_display(ident: &str) -> String {
1336                        let name = if let Some(bracket) = ident.find('<') {
1337                            ident[..bracket].trim()
1338                        } else {
1339                            ident.trim()
1340                        };
1341                        let email = if let Some(start) = ident.find('<') {
1342                            if let Some(end) = ident.find('>') {
1343                                &ident[start..=end]
1344                            } else {
1345                                ""
1346                            }
1347                        } else {
1348                            ""
1349                        };
1350                        format!("{} {}", name, email)
1351                    }
1352                    fn format_default_date(ident: &str) -> String {
1353                        let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
1354                        if parts.len() < 2 {
1355                            return String::new();
1356                        }
1357                        let ts_str = parts[1];
1358                        let offset_str = parts[0];
1359                        let ts: i64 = match ts_str.parse() {
1360                            Ok(v) => v,
1361                            Err(_) => return format!("{ts_str} {offset_str}"),
1362                        };
1363                        let tz_bytes = offset_str.as_bytes();
1364                        let tz_secs: i64 = if tz_bytes.len() >= 5 {
1365                            let sign = if tz_bytes[0] == b'-' { -1i64 } else { 1i64 };
1366                            let h: i64 = offset_str[1..3].parse().unwrap_or(0);
1367                            let m: i64 = offset_str[3..5].parse().unwrap_or(0);
1368                            sign * (h * 3600 + m * 60)
1369                        } else {
1370                            0
1371                        };
1372                        let adjusted = ts + tz_secs;
1373                        let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1374                            .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1375                        let weekday = match dt.weekday() {
1376                            time::Weekday::Monday => "Mon",
1377                            time::Weekday::Tuesday => "Tue",
1378                            time::Weekday::Wednesday => "Wed",
1379                            time::Weekday::Thursday => "Thu",
1380                            time::Weekday::Friday => "Fri",
1381                            time::Weekday::Saturday => "Sat",
1382                            time::Weekday::Sunday => "Sun",
1383                        };
1384                        let month = match dt.month() {
1385                            time::Month::January => "Jan",
1386                            time::Month::February => "Feb",
1387                            time::Month::March => "Mar",
1388                            time::Month::April => "Apr",
1389                            time::Month::May => "May",
1390                            time::Month::June => "Jun",
1391                            time::Month::July => "Jul",
1392                            time::Month::August => "Aug",
1393                            time::Month::September => "Sep",
1394                            time::Month::October => "Oct",
1395                            time::Month::November => "Nov",
1396                            time::Month::December => "Dec",
1397                        };
1398                        format!(
1399                            "{} {} {} {:02}:{:02}:{:02} {} {}",
1400                            weekday,
1401                            month,
1402                            dt.day(),
1403                            dt.hour(),
1404                            dt.minute(),
1405                            dt.second(),
1406                            dt.year(),
1407                            offset_str
1408                        )
1409                    }
1410                    let mut out = String::new();
1411                    out.push_str(&format!(
1412                        "Author: {}\n",
1413                        extract_ident_display(&commit.author)
1414                    ));
1415                    out.push_str(&format!(
1416                        "Date:   {}\n",
1417                        format_default_date(&commit.author)
1418                    ));
1419                    out.push('\n');
1420                    for line in commit.message.lines() {
1421                        out.push_str(&format!("    {}\n", line));
1422                    }
1423                    return Ok(out);
1424                }
1425                _ => {}
1426            }
1427
1428            let raw_fmt = if let Some(t) = fmt.strip_prefix("format:") {
1429                t
1430            } else if let Some(t) = fmt.strip_prefix("tformat:") {
1431                t
1432            } else {
1433                fmt.as_str()
1434            };
1435            // Body: everything after the first line (skip blank separator line)
1436            let body = {
1437                let mut lines = commit.message.lines();
1438                lines.next(); // skip subject
1439                              // Skip optional blank line after subject
1440                if let Some(blank) = lines.next() {
1441                    if blank.is_empty() {
1442                        lines.collect::<Vec<_>>().join("\n")
1443                    } else {
1444                        std::iter::once(blank)
1445                            .chain(lines)
1446                            .collect::<Vec<_>>()
1447                            .join("\n")
1448                    }
1449                } else {
1450                    String::new()
1451                }
1452            };
1453            let tree_hex = commit.tree.to_hex();
1454            let parent_hexes: Vec<String> = commit.parents.iter().map(|p| p.to_hex()).collect();
1455            let parent_abbrevs: Vec<String> = commit
1456                .parents
1457                .iter()
1458                .map(|p| {
1459                    let hex = p.to_hex();
1460                    let n = abbrev_len.clamp(4, 40).min(hex.len());
1461                    hex[..n].to_string()
1462                })
1463                .collect();
1464
1465            // Extract name/email components from ident strings
1466            fn extract_name(ident: &str) -> &str {
1467                if let Some(bracket) = ident.find('<') {
1468                    ident[..bracket].trim()
1469                } else {
1470                    ident.trim()
1471                }
1472            }
1473            fn extract_email(ident: &str) -> &str {
1474                if let Some(start) = ident.find('<') {
1475                    if let Some(end) = ident.find('>') {
1476                        return &ident[start + 1..end];
1477                    }
1478                }
1479                ""
1480            }
1481            fn extract_timestamp(ident: &str) -> String {
1482                match parse_signature_times(ident) {
1483                    Some(p) => p.unix_seconds.to_string(),
1484                    None => String::new(),
1485                }
1486            }
1487            fn weekday_str(dt: &time::OffsetDateTime) -> &'static str {
1488                match dt.weekday() {
1489                    time::Weekday::Monday => "Mon",
1490                    time::Weekday::Tuesday => "Tue",
1491                    time::Weekday::Wednesday => "Wed",
1492                    time::Weekday::Thursday => "Thu",
1493                    time::Weekday::Friday => "Fri",
1494                    time::Weekday::Saturday => "Sat",
1495                    time::Weekday::Sunday => "Sun",
1496                }
1497            }
1498            fn month_str(dt: &time::OffsetDateTime) -> &'static str {
1499                match dt.month() {
1500                    time::Month::January => "Jan",
1501                    time::Month::February => "Feb",
1502                    time::Month::March => "Mar",
1503                    time::Month::April => "Apr",
1504                    time::Month::May => "May",
1505                    time::Month::June => "Jun",
1506                    time::Month::July => "Jul",
1507                    time::Month::August => "Aug",
1508                    time::Month::September => "Sep",
1509                    time::Month::October => "Oct",
1510                    time::Month::November => "Nov",
1511                    time::Month::December => "Dec",
1512                }
1513            }
1514            fn extract_email_local(ident: &str) -> &str {
1515                let email = extract_email(ident);
1516                if let Some(at) = email.find('@') {
1517                    &email[..at]
1518                } else {
1519                    email
1520                }
1521            }
1522            fn extract_date_default(ident: &str) -> String {
1523                let Some(p) = parse_signature_times(ident) else {
1524                    return String::new();
1525                };
1526                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
1527                let adjusted = p.unix_seconds + p.tz_offset_secs;
1528                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1529                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1530                format!(
1531                    "{} {} {} {:02}:{:02}:{:02} {} {}",
1532                    weekday_str(&dt),
1533                    month_str(&dt),
1534                    dt.day(),
1535                    dt.hour(),
1536                    dt.minute(),
1537                    dt.second(),
1538                    dt.year(),
1539                    offset_str
1540                )
1541            }
1542            fn extract_date_rfc2822(ident: &str) -> String {
1543                let Some(p) = parse_signature_times(ident) else {
1544                    return String::new();
1545                };
1546                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
1547                let adjusted = p.unix_seconds + p.tz_offset_secs;
1548                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1549                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1550                format!(
1551                    "{}, {} {} {} {:02}:{:02}:{:02} {}",
1552                    weekday_str(&dt),
1553                    dt.day(),
1554                    month_str(&dt),
1555                    dt.year(),
1556                    dt.hour(),
1557                    dt.minute(),
1558                    dt.second(),
1559                    offset_str
1560                )
1561            }
1562            fn extract_date_short(ident: &str) -> String {
1563                let Some(p) = parse_signature_times(ident) else {
1564                    return String::new();
1565                };
1566                let adjusted = p.unix_seconds + p.tz_offset_secs;
1567                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1568                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1569                format!("{:04}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day())
1570            }
1571            fn extract_date_iso(ident: &str) -> String {
1572                let Some(p) = parse_signature_times(ident) else {
1573                    return String::new();
1574                };
1575                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
1576                let adjusted = p.unix_seconds + p.tz_offset_secs;
1577                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1578                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1579                format!(
1580                    "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {}",
1581                    dt.year(),
1582                    dt.month() as u8,
1583                    dt.day(),
1584                    dt.hour(),
1585                    dt.minute(),
1586                    dt.second(),
1587                    offset_str
1588                )
1589            }
1590
1591            // Alignment/truncation state for %<(N), %>(N), %><(N) directives
1592            #[derive(Clone, Copy)]
1593            enum Align {
1594                Left,
1595                Right,
1596                Center,
1597            }
1598            #[derive(Clone, Copy)]
1599            enum Trunc {
1600                None,
1601                Trunc,
1602                LTrunc,
1603                MTrunc,
1604            }
1605            struct ColSpec {
1606                width: usize,
1607                align: Align,
1608                trunc: Trunc,
1609            }
1610            fn apply_col(spec: &ColSpec, s: &str) -> String {
1611                let char_len = s.chars().count();
1612                if char_len > spec.width {
1613                    match spec.trunc {
1614                        Trunc::None => s.to_owned(),
1615                        Trunc::Trunc => {
1616                            let mut out: String =
1617                                s.chars().take(spec.width.saturating_sub(2)).collect();
1618                            out.push_str("..");
1619                            out
1620                        }
1621                        Trunc::LTrunc => {
1622                            let skip = char_len - spec.width + 2;
1623                            let mut out = String::from("..");
1624                            out.extend(s.chars().skip(skip));
1625                            out
1626                        }
1627                        Trunc::MTrunc => {
1628                            let keep = spec.width.saturating_sub(2);
1629                            let left_half = keep / 2;
1630                            let right_half = keep - left_half;
1631                            let mut out: String = s.chars().take(left_half).collect();
1632                            out.push_str("..");
1633                            out.extend(s.chars().skip(char_len - right_half));
1634                            out
1635                        }
1636                    }
1637                } else {
1638                    let pad = spec.width - char_len;
1639                    match spec.align {
1640                        Align::Left => {
1641                            let mut out = s.to_owned();
1642                            for _ in 0..pad {
1643                                out.push(' ');
1644                            }
1645                            out
1646                        }
1647                        Align::Right => {
1648                            let mut out = String::new();
1649                            for _ in 0..pad {
1650                                out.push(' ');
1651                            }
1652                            out.push_str(s);
1653                            out
1654                        }
1655                        Align::Center => {
1656                            let left = pad / 2;
1657                            let right = pad - left;
1658                            let mut out = String::new();
1659                            for _ in 0..left {
1660                                out.push(' ');
1661                            }
1662                            out.push_str(s);
1663                            for _ in 0..right {
1664                                out.push(' ');
1665                            }
1666                            out
1667                        }
1668                    }
1669                }
1670            }
1671            fn parse_col_spec(
1672                chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
1673                align: Align,
1674            ) -> Option<ColSpec> {
1675                // Consume '('
1676                if chars.peek() != Some(&'(') {
1677                    return None;
1678                }
1679                chars.next();
1680                let mut num_str = String::new();
1681                while let Some(&c) = chars.peek() {
1682                    if c.is_ascii_digit() {
1683                        num_str.push(c);
1684                        chars.next();
1685                    } else {
1686                        break;
1687                    }
1688                }
1689                let width: usize = num_str.parse().ok()?;
1690                let trunc = if chars.peek() == Some(&',') {
1691                    chars.next(); // consume comma
1692                    let mut mode = String::new();
1693                    while let Some(&c) = chars.peek() {
1694                        if c == ')' {
1695                            break;
1696                        }
1697                        mode.push(c);
1698                        chars.next();
1699                    }
1700                    match mode.as_str() {
1701                        "trunc" => Trunc::Trunc,
1702                        "ltrunc" => Trunc::LTrunc,
1703                        "mtrunc" => Trunc::MTrunc,
1704                        _ => Trunc::None,
1705                    }
1706                } else {
1707                    Trunc::None
1708                };
1709                // Consume ')'
1710                if chars.peek() == Some(&')') {
1711                    chars.next();
1712                }
1713                Some(ColSpec {
1714                    width,
1715                    align,
1716                    trunc,
1717                })
1718            }
1719
1720            let mut pending_col: Option<ColSpec> = None;
1721            let mut rendered = String::new();
1722            let mut chars = raw_fmt.chars().peekable();
1723            while let Some(ch) = chars.next() {
1724                if ch != '%' {
1725                    rendered.push(ch);
1726                    continue;
1727                }
1728                // Check for alignment directives: %<(...), %>(...), %><(...)
1729                if chars.peek() == Some(&'<') {
1730                    chars.next();
1731                    if let Some(spec) = parse_col_spec(&mut chars, Align::Left) {
1732                        pending_col = Some(spec);
1733                    }
1734                    continue;
1735                }
1736                if chars.peek() == Some(&'>') {
1737                    chars.next();
1738                    if chars.peek() == Some(&'<') {
1739                        chars.next(); // %><(...)
1740                        if let Some(spec) = parse_col_spec(&mut chars, Align::Center) {
1741                            pending_col = Some(spec);
1742                        }
1743                    } else if chars.peek() == Some(&'>') {
1744                        chars.next(); // %>>(...)
1745                        if let Some(spec) = parse_col_spec(&mut chars, Align::Right) {
1746                            pending_col = Some(spec);
1747                        }
1748                    } else if let Some(spec) = parse_col_spec(&mut chars, Align::Right) {
1749                        pending_col = Some(spec);
1750                    }
1751                    continue;
1752                }
1753
1754                // Helper macro-like: expand the placeholder, then apply pending_col
1755                let mut expanded = String::new();
1756                let target = if pending_col.is_some() {
1757                    &mut expanded
1758                } else {
1759                    &mut rendered
1760                };
1761                match chars.peek() {
1762                    Some('%') => {
1763                        chars.next();
1764                        target.push('%');
1765                    }
1766                    Some('H') => {
1767                        chars.next();
1768                        target.push_str(&oid.to_hex());
1769                    }
1770                    Some('h') => {
1771                        chars.next();
1772                        let hex = oid.to_hex();
1773                        let n = abbrev_len.clamp(4, 40).min(hex.len());
1774                        target.push_str(&hex[..n]);
1775                    }
1776                    Some('T') => {
1777                        chars.next();
1778                        target.push_str(&tree_hex);
1779                    }
1780                    Some('t') => {
1781                        chars.next();
1782                        let n = abbrev_len.clamp(4, 40).min(tree_hex.len());
1783                        target.push_str(&tree_hex[..n]);
1784                    }
1785                    Some('P') => {
1786                        chars.next();
1787                        target.push_str(&parent_hexes.join(" "));
1788                    }
1789                    Some('p') => {
1790                        chars.next();
1791                        target.push_str(&parent_abbrevs.join(" "));
1792                    }
1793                    Some('n') => {
1794                        chars.next();
1795                        target.push('\n');
1796                    }
1797                    Some('s') => {
1798                        chars.next();
1799                        target.push_str(subject);
1800                    }
1801                    Some('b') => {
1802                        chars.next();
1803                        target.push_str(&body);
1804                        if !body.is_empty() {
1805                            target.push('\n');
1806                        }
1807                    }
1808                    Some('B') => {
1809                        chars.next();
1810                        target.push_str(&commit.message);
1811                    }
1812                    Some('a') => {
1813                        chars.next();
1814                        match chars.next() {
1815                            Some('n') => target.push_str(extract_name(&commit.author)),
1816                            Some('N') => target.push_str(extract_name(&commit.author)),
1817                            Some('e') => target.push_str(extract_email(&commit.author)),
1818                            Some('E') => target.push_str(extract_email(&commit.author)),
1819                            Some('l') => target.push_str(extract_email_local(&commit.author)),
1820                            Some('d') => target.push_str(&extract_date_default(&commit.author)),
1821                            Some('D') => target.push_str(&extract_date_rfc2822(&commit.author)),
1822                            Some('t') => target.push_str(&extract_timestamp(&commit.author)),
1823                            Some('s') => target.push_str(&extract_date_short(&commit.author)),
1824                            Some('i') => target.push_str(&extract_date_iso(&commit.author)),
1825                            Some('I') => {
1826                                let Some(p) = parse_signature_times(&commit.author) else {
1827                                    break;
1828                                };
1829                                let adjusted = p.unix_seconds + p.tz_offset_secs;
1830                                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1831                                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1832                                let sign_ch = if p.tz_offset_secs >= 0 { '+' } else { '-' };
1833                                let abs_off = p.tz_offset_secs.unsigned_abs();
1834                                target.push_str(&format!(
1835                                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
1836                                    dt.year(),
1837                                    dt.month() as u8,
1838                                    dt.day(),
1839                                    dt.hour(),
1840                                    dt.minute(),
1841                                    dt.second(),
1842                                    sign_ch,
1843                                    abs_off / 3600,
1844                                    (abs_off % 3600) / 60
1845                                ));
1846                            }
1847                            Some('r') => {
1848                                let Some(p) = parse_signature_times(&commit.author) else {
1849                                    break;
1850                                };
1851                                let now = std::time::SystemTime::now()
1852                                    .duration_since(std::time::UNIX_EPOCH)
1853                                    .unwrap_or_default()
1854                                    .as_secs() as i64;
1855                                target.push_str(&format_relative_date(now - p.unix_seconds));
1856                            }
1857                            Some(other) => {
1858                                target.push('%');
1859                                target.push('a');
1860                                target.push(other);
1861                            }
1862                            None => {
1863                                target.push('%');
1864                                target.push('a');
1865                            }
1866                        }
1867                    }
1868                    Some('c') => {
1869                        chars.next();
1870                        match chars.next() {
1871                            Some('n') => target.push_str(extract_name(&commit.committer)),
1872                            Some('N') => target.push_str(extract_name(&commit.committer)),
1873                            Some('e') => target.push_str(extract_email(&commit.committer)),
1874                            Some('E') => target.push_str(extract_email(&commit.committer)),
1875                            Some('l') => target.push_str(extract_email_local(&commit.committer)),
1876                            Some('d') => target.push_str(&extract_date_default(&commit.committer)),
1877                            Some('D') => target.push_str(&extract_date_rfc2822(&commit.committer)),
1878                            Some('t') => target.push_str(&extract_timestamp(&commit.committer)),
1879                            Some('s') => target.push_str(&extract_date_short(&commit.committer)),
1880                            Some('i') => target.push_str(&extract_date_iso(&commit.committer)),
1881                            Some('I') => {
1882                                let Some(p) = parse_signature_times(&commit.committer) else {
1883                                    break;
1884                                };
1885                                let adjusted = p.unix_seconds + p.tz_offset_secs;
1886                                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1887                                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1888                                let sign_ch = if p.tz_offset_secs >= 0 { '+' } else { '-' };
1889                                let abs_off = p.tz_offset_secs.unsigned_abs();
1890                                target.push_str(&format!(
1891                                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
1892                                    dt.year(),
1893                                    dt.month() as u8,
1894                                    dt.day(),
1895                                    dt.hour(),
1896                                    dt.minute(),
1897                                    dt.second(),
1898                                    sign_ch,
1899                                    abs_off / 3600,
1900                                    (abs_off % 3600) / 60
1901                                ));
1902                            }
1903                            Some('r') => {
1904                                let Some(p) = parse_signature_times(&commit.committer) else {
1905                                    break;
1906                                };
1907                                let now = std::time::SystemTime::now()
1908                                    .duration_since(std::time::UNIX_EPOCH)
1909                                    .unwrap_or_default()
1910                                    .as_secs() as i64;
1911                                target.push_str(&format_relative_date(now - p.unix_seconds));
1912                            }
1913                            Some(other) => {
1914                                target.push('%');
1915                                target.push('c');
1916                                target.push(other);
1917                            }
1918                            None => {
1919                                target.push('%');
1920                                target.push('c');
1921                            }
1922                        }
1923                    }
1924                    Some('x') => {
1925                        // Hex escape: %xNN
1926                        chars.next();
1927                        let mut hex_str = String::new();
1928                        if let Some(&c1) = chars.peek() {
1929                            if c1.is_ascii_hexdigit() {
1930                                hex_str.push(c1);
1931                                chars.next();
1932                            }
1933                        }
1934                        if let Some(&c2) = chars.peek() {
1935                            if c2.is_ascii_hexdigit() {
1936                                hex_str.push(c2);
1937                                chars.next();
1938                            }
1939                        }
1940                        if let Ok(byte) = u8::from_str_radix(&hex_str, 16) {
1941                            target.push(byte as char);
1942                        }
1943                    }
1944                    Some('C') => {
1945                        chars.next();
1946                        if chars.peek() == Some(&'(') {
1947                            chars.next();
1948                            let mut spec = String::new();
1949                            for c in chars.by_ref() {
1950                                if c == ')' {
1951                                    break;
1952                                }
1953                                spec.push(c);
1954                            }
1955                            let (force, color_spec) =
1956                                if let Some(rest) = spec.strip_prefix("always,") {
1957                                    (true, rest)
1958                                } else if let Some(rest) = spec.strip_prefix("auto,") {
1959                                    (false, rest)
1960                                } else if spec == "auto" {
1961                                    if use_color {
1962                                        target.push_str("\x1b[m");
1963                                    }
1964                                    continue;
1965                                } else {
1966                                    (false, spec.as_str())
1967                                };
1968                            if use_color || force {
1969                                target.push_str(&ansi_color_from_spec(color_spec));
1970                            }
1971                        } else {
1972                            // Named colors: %Cred, %Cgreen, %Cblue, %Creset, %Cbold
1973                            // Must match known names only, not consume trailing text
1974                            let remaining: String = chars.clone().collect();
1975                            let known = [
1976                                "reset", "red", "green", "blue", "yellow", "magenta", "cyan",
1977                                "white", "bold", "dim", "ul",
1978                            ];
1979                            let mut matched = false;
1980                            for name in &known {
1981                                if remaining.starts_with(name) {
1982                                    for _ in 0..name.len() {
1983                                        chars.next();
1984                                    }
1985                                    if use_color {
1986                                        target.push_str(&ansi_color_from_name(name));
1987                                    }
1988                                    matched = true;
1989                                    break;
1990                                }
1991                            }
1992                            if !matched {
1993                                // Unknown color name — consume alphanumerics
1994                                while let Some(&c) = chars.peek() {
1995                                    if c.is_alphanumeric() {
1996                                        chars.next();
1997                                    } else {
1998                                        break;
1999                                    }
2000                                }
2001                            }
2002                        }
2003                    }
2004                    Some('w') => {
2005                        // %w(...) — wrapping directive, consume and ignore for now
2006                        chars.next();
2007                        if chars.peek() == Some(&'(') {
2008                            chars.next();
2009                            for c in chars.by_ref() {
2010                                if c == ')' {
2011                                    break;
2012                                }
2013                            }
2014                        }
2015                    }
2016                    Some('+') => {
2017                        // %+x — conditional newline: if next placeholder is non-empty, prepend newline
2018                        chars.next();
2019                        // Expand the following placeholder
2020                        if chars.peek() == Some(&'%') {
2021                            // The %+ applies to the NEXT expanded value
2022                            // For simplicity, treat %+x as: if %x is non-empty, emit '\n' + value
2023                            // This needs the *next* placeholder's value
2024                        }
2025                        // Simple: consume the next char as a format code; prepend \n if non-empty
2026                        let mut sub = String::new();
2027                        if let Some(&nc) = chars.peek() {
2028                            match nc {
2029                                'b' => {
2030                                    chars.next();
2031                                    sub.push_str(&body);
2032                                    if !body.is_empty() {
2033                                        sub.push('\n');
2034                                    }
2035                                }
2036                                's' => {
2037                                    chars.next();
2038                                    sub.push_str(subject);
2039                                }
2040                                _ => {
2041                                    chars.next();
2042                                    sub.push('%');
2043                                    sub.push('+');
2044                                    sub.push(nc);
2045                                }
2046                            }
2047                        }
2048                        if !sub.is_empty() {
2049                            target.push('\n');
2050                            target.push_str(&sub);
2051                        }
2052                    }
2053                    Some('-') => {
2054                        // %-x — conditional: suppress newline before placeholder if empty
2055                        chars.next();
2056                        // Consume the next format code
2057                        if let Some(&nc) = chars.peek() {
2058                            match nc {
2059                                'b' => {
2060                                    chars.next();
2061                                    if !body.is_empty() {
2062                                        target.push_str(&body);
2063                                        target.push('\n');
2064                                    }
2065                                }
2066                                's' => {
2067                                    chars.next();
2068                                    target.push_str(subject);
2069                                }
2070                                _ => {
2071                                    chars.next();
2072                                    target.push('%');
2073                                    target.push('-');
2074                                    target.push(nc);
2075                                }
2076                            }
2077                        }
2078                    }
2079                    Some('d') => {
2080                        // Decorations — output empty for now
2081                        chars.next();
2082                    }
2083                    Some('D') => {
2084                        // Decorations without parens — output empty for now
2085                        chars.next();
2086                    }
2087                    Some('e') => {
2088                        // Encoding
2089                        chars.next();
2090                    }
2091                    Some('g') => {
2092                        // Reflog placeholders: %gD, %gd, %gs, %gn, %ge, etc.
2093                        chars.next();
2094                        if let Some(&_nc) = chars.peek() {
2095                            chars.next(); // consume the sub-specifier
2096                                          // For non-reflog commits, these expand to empty
2097                        }
2098                    }
2099                    Some(&other) => {
2100                        chars.next();
2101                        target.push('%');
2102                        target.push(other);
2103                    }
2104                    None => target.push('%'),
2105                }
2106                // Apply pending column formatting
2107                if let Some(spec) = pending_col.take() {
2108                    let formatted = apply_col(&spec, &expanded);
2109                    rendered.push_str(&formatted);
2110                }
2111            }
2112            Ok(rendered)
2113        }
2114    }
2115}
2116
2117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2118enum ExpectedObjectKind {
2119    Commit,
2120    Tree,
2121    Blob,
2122}
2123
2124impl ExpectedObjectKind {
2125    fn from_tag_type(kind: &str) -> Option<Self> {
2126        match kind {
2127            "commit" => Some(Self::Commit),
2128            "tree" => Some(Self::Tree),
2129            "blob" => Some(Self::Blob),
2130            _ => None,
2131        }
2132    }
2133
2134    fn matches(self, kind: ObjectKind) -> bool {
2135        matches!(
2136            (self, kind),
2137            (Self::Commit, ObjectKind::Commit)
2138                | (Self::Tree, ObjectKind::Tree)
2139                | (Self::Blob, ObjectKind::Blob)
2140        )
2141    }
2142
2143    fn as_str(self) -> &'static str {
2144        match self {
2145            Self::Commit => "commit",
2146            Self::Tree => "tree",
2147            Self::Blob => "blob",
2148        }
2149    }
2150}
2151
2152/// Non-commit root from revision arguments (`tag`, `rev:path`, raw tree/blob OID), for object walks.
2153#[derive(Clone, Debug)]
2154pub struct ObjectWalkRoot {
2155    /// Object id of the peeled non-commit (tree or blob) or tag target.
2156    pub oid: ObjectId,
2157    pub input: String,
2158    /// Path within the tree for `rev:path` blob roots.
2159    pub root_path: Option<String>,
2160}
2161
2162#[derive(Clone, Debug)]
2163struct RootObject {
2164    oid: ObjectId,
2165    input: String,
2166    expected_kind: Option<ExpectedObjectKind>,
2167    root_path: Option<String>,
2168    /// When the user named an annotated tag, the tag object to emit before walking [`Self::oid`].
2169    wrap_with_tag: Option<ObjectId>,
2170}
2171
2172fn object_walk_print_commit_line(
2173    filter_provided_objects: bool,
2174    filter: Option<&ObjectFilter>,
2175    user_given_tip: bool,
2176) -> bool {
2177    if !filter_provided_objects {
2178        return user_given_tip || filter_shows_commit_line_when_not_user_given(filter);
2179    }
2180    match filter {
2181        Some(ObjectFilter::ObjectType(FilterObjectKind::Commit)) => {
2182            user_given_tip || filter_shows_commit_line_when_not_user_given(filter)
2183        }
2184        Some(ObjectFilter::ObjectType(_)) => false,
2185        Some(ObjectFilter::Combine(parts)) => {
2186            if parts
2187                .iter()
2188                .all(|p| matches!(p, ObjectFilter::ObjectType(FilterObjectKind::Commit)))
2189            {
2190                user_given_tip || filter_shows_commit_line_when_not_user_given(filter)
2191            } else {
2192                false
2193            }
2194        }
2195        _ => user_given_tip || filter_shows_commit_line_when_not_user_given(filter),
2196    }
2197}
2198
2199/// Whether `LOFS_COMMIT` would receive `LOFR_DO_SHOW` when the commit is `NOT_USER_GIVEN` (Git list-objects-filter).
2200fn filter_shows_commit_line_when_not_user_given(filter: Option<&ObjectFilter>) -> bool {
2201    match filter {
2202        None => true,
2203        Some(ObjectFilter::BlobNone)
2204        | Some(ObjectFilter::BlobLimit(_))
2205        | Some(ObjectFilter::TreeDepth(_))
2206        | Some(ObjectFilter::SparseOid(_)) => true,
2207        Some(ObjectFilter::ObjectType(FilterObjectKind::Commit)) => true,
2208        Some(ObjectFilter::ObjectType(
2209            FilterObjectKind::Blob | FilterObjectKind::Tree | FilterObjectKind::Tag,
2210        )) => false,
2211        Some(ObjectFilter::Combine(parts)) => parts
2212            .iter()
2213            .all(|p| filter_shows_commit_line_when_not_user_given(Some(p))),
2214    }
2215}
2216
2217fn skip_tree_descent_for_object_type_filter(filter: Option<&ObjectFilter>) -> bool {
2218    match filter {
2219        Some(ObjectFilter::ObjectType(FilterObjectKind::Commit | FilterObjectKind::Tag)) => true,
2220        Some(ObjectFilter::Combine(parts)) => parts
2221            .iter()
2222            .any(|p| skip_tree_descent_for_object_type_filter(Some(p))),
2223        _ => false,
2224    }
2225}
2226
2227fn sparse_filter_includes_path(
2228    repo: &Repository,
2229    path: &str,
2230    sparse_lines: Option<&[String]>,
2231) -> bool {
2232    sparse_lines
2233        .map(|lines| path_in_sparse_checkout(path, lines, repo.work_tree.as_deref()))
2234        .unwrap_or(true)
2235}
2236
2237fn sparse_oid_lines_from_filter(
2238    repo: &Repository,
2239    filter: Option<&ObjectFilter>,
2240) -> Option<Vec<String>> {
2241    let f = filter?;
2242    match f {
2243        ObjectFilter::SparseOid(spec) => {
2244            let blob_oid = if let Ok(oid) = spec.parse::<ObjectId>() {
2245                oid
2246            } else if let Some((treeish, path)) = split_treeish_spec(spec) {
2247                let treeish_oid = resolve_revision_for_range_end(repo, treeish).ok()?;
2248                resolve_treeish_path(repo, treeish_oid, path).ok()?
2249            } else {
2250                return None;
2251            };
2252            let obj = repo.odb.read(&blob_oid).ok()?;
2253            if obj.kind != ObjectKind::Blob {
2254                return None;
2255            }
2256            let text = std::str::from_utf8(&obj.data).ok()?;
2257            Some(parse_sparse_patterns_from_blob(text))
2258        }
2259        ObjectFilter::Combine(parts) => {
2260            for p in parts {
2261                if let Some(lines) = sparse_oid_lines_from_filter(repo, Some(p)) {
2262                    return Some(lines);
2263                }
2264            }
2265            None
2266        }
2267        _ => None,
2268    }
2269}
2270
2271fn packed_object_set(repo: &Repository) -> HashSet<ObjectId> {
2272    let mut out = HashSet::new();
2273    let objects_dir = repo.odb.objects_dir();
2274    if let Ok(indexes) = pack::read_local_pack_indexes(objects_dir) {
2275        for idx in indexes {
2276            for e in idx.entries {
2277                if let Ok(oid) = ObjectId::from_bytes(&e.oid) {
2278                    out.insert(oid);
2279                }
2280            }
2281        }
2282    }
2283    out
2284}
2285
2286fn resolve_specs(repo: &Repository, specs: &[String]) -> Result<Vec<ObjectId>> {
2287    let mut out = Vec::with_capacity(specs.len());
2288    for spec in specs {
2289        let oid = resolve_revision_for_range_end(repo, spec)?;
2290        let commit_oid = peel_to_commit(repo, oid)?;
2291        out.push(commit_oid);
2292    }
2293    Ok(out)
2294}
2295
2296/// Resolve revision strings to commit tips and non-commit roots (tags, trees, blobs, `rev:path`).
2297///
2298/// Used by `test-tool path-walk` and similar object walks that mirror `git` revision parsing.
2299/// Resolve revision strings to commit OIDs (for negative specs / exclusions).
2300pub fn resolve_revision_commits(repo: &Repository, specs: &[String]) -> Result<Vec<ObjectId>> {
2301    resolve_specs(repo, specs)
2302}
2303
2304/// Resolve revision argument strings to commit OIDs for history walks (`log`, `rev-list`).
2305///
2306/// Each spec is interpreted like Git's `handle_revision_arg` for a plain commit tip: ranges
2307/// (`A..B`), exclusions (`^rev`), and revision expressions are supported via
2308/// [`split_revision_token`] and [`resolve_revision_for_range_end`].
2309pub fn resolve_revision_specs_to_commits(
2310    repo: &Repository,
2311    specs: &[String],
2312) -> Result<Vec<ObjectId>> {
2313    resolve_specs(repo, specs)
2314}
2315
2316pub fn resolve_object_walk_roots(
2317    repo: &Repository,
2318    specs: &[String],
2319) -> Result<(Vec<ObjectId>, Vec<ObjectWalkRoot>)> {
2320    let (commits, roots, _tip_annotated_tag_by_commit) = resolve_specs_for_objects(repo, specs)?;
2321    Ok((
2322        commits,
2323        roots
2324            .into_iter()
2325            .map(|r| ObjectWalkRoot {
2326                oid: r.oid,
2327                input: r.input,
2328                root_path: r.root_path,
2329            })
2330            .collect(),
2331    ))
2332}
2333
2334fn resolve_specs_for_objects(
2335    repo: &Repository,
2336    specs: &[String],
2337) -> Result<(Vec<ObjectId>, Vec<RootObject>, HashMap<ObjectId, ObjectId>)> {
2338    let mut commits = Vec::new();
2339    let mut roots = Vec::new();
2340    let mut tip_annotated_tag_by_commit: HashMap<ObjectId, ObjectId> = HashMap::new();
2341
2342    for spec in specs {
2343        if let Ok(raw_oid) = spec.parse::<ObjectId>() {
2344            let raw_object = repo.odb.read(&raw_oid)?;
2345            match raw_object.kind {
2346                ObjectKind::Commit => {
2347                    commits.push(raw_oid);
2348                }
2349                ObjectKind::Tag => {
2350                    let tag = parse_tag(&raw_object.data)?;
2351                    let expected_kind = ExpectedObjectKind::from_tag_type(&tag.object_type)
2352                        .ok_or_else(|| {
2353                            Error::CorruptObject(format!(
2354                                "object {spec} has unsupported tag type '{}'",
2355                                tag.object_type
2356                            ))
2357                        })?;
2358                    if expected_kind == ExpectedObjectKind::Commit {
2359                        tip_annotated_tag_by_commit.insert(tag.object, raw_oid);
2360                    }
2361                    roots.push(RootObject {
2362                        oid: tag.object,
2363                        input: spec.clone(),
2364                        expected_kind: Some(expected_kind),
2365                        root_path: None,
2366                        wrap_with_tag: Some(raw_oid),
2367                    });
2368                }
2369                ObjectKind::Tree | ObjectKind::Blob => roots.push(RootObject {
2370                    oid: raw_oid,
2371                    input: spec.clone(),
2372                    expected_kind: None,
2373                    root_path: None,
2374                    wrap_with_tag: None,
2375                }),
2376            }
2377            continue;
2378        }
2379
2380        if let Some((treeish, path)) = split_treeish_spec(spec) {
2381            if !path.is_empty() {
2382                let treeish_oid = resolve_revision_for_range_end(repo, treeish)?;
2383                let blob_oid = resolve_treeish_path(repo, treeish_oid, path)?;
2384                roots.push(RootObject {
2385                    oid: blob_oid,
2386                    input: spec.clone(),
2387                    expected_kind: Some(ExpectedObjectKind::Blob),
2388                    root_path: Some(path.to_owned()),
2389                    wrap_with_tag: None,
2390                });
2391                continue;
2392            }
2393        }
2394
2395        let oid = resolve_revision_for_range_end(repo, spec)?;
2396        if let Ok(obj) = repo.odb.read(&oid) {
2397            if obj.kind == ObjectKind::Tag {
2398                if let Ok(commit_oid) = peel_to_commit(repo, oid) {
2399                    tip_annotated_tag_by_commit.insert(commit_oid, oid);
2400                }
2401            }
2402        }
2403        match peel_to_commit(repo, oid) {
2404            Ok(commit_oid) => commits.push(commit_oid),
2405            Err(Error::CorruptObject(_)) | Err(Error::ObjectNotFound(_)) => {
2406                roots.push(RootObject {
2407                    oid,
2408                    input: spec.clone(),
2409                    expected_kind: None,
2410                    root_path: None,
2411                    wrap_with_tag: None,
2412                })
2413            }
2414            Err(err) => return Err(err),
2415        }
2416    }
2417
2418    Ok((commits, roots, tip_annotated_tag_by_commit))
2419}
2420
2421/// Peel an object (possibly a tag) to the underlying commit.
2422fn peel_to_commit(repo: &Repository, mut oid: ObjectId) -> Result<ObjectId> {
2423    loop {
2424        let object = repo.odb.read(&oid)?;
2425        match object.kind {
2426            ObjectKind::Commit => return Ok(oid),
2427            ObjectKind::Tag => {
2428                let tag = parse_tag(&object.data)?;
2429                oid = tag.object;
2430            }
2431            other => {
2432                return Err(Error::CorruptObject(format!(
2433                    "object {oid} is a {other:?}, not a commit"
2434                )));
2435            }
2436        }
2437    }
2438}
2439
2440fn reflog_commit_tips(repo: &Repository) -> Result<Vec<ObjectId>> {
2441    let z = zero_oid();
2442    let mut out = Vec::new();
2443    let mut seen = HashSet::new();
2444    for refname in list_reflog_refs(&repo.git_dir)? {
2445        let entries = read_reflog(&repo.git_dir, &refname)?;
2446        for e in entries {
2447            for oid in [e.old_oid, e.new_oid] {
2448                if oid == z {
2449                    continue;
2450                }
2451                match peel_to_commit(repo, oid) {
2452                    Ok(c) if seen.insert(c) => out.push(c),
2453                    Err(_) => {}
2454                    _ => {}
2455                }
2456            }
2457        }
2458    }
2459    Ok(out)
2460}
2461
2462pub(crate) fn walk_closure(
2463    graph: &mut CommitGraph<'_>,
2464    starts: &[ObjectId],
2465) -> Result<HashSet<ObjectId>> {
2466    let (seen, _) = walk_closure_ordered(graph, starts)?;
2467    Ok(seen)
2468}
2469
2470/// Like [`walk_closure`] but follows only the first parent from each start (Git
2471/// `--exclude-first-parent-only` for excluded tips).
2472fn walk_closure_first_parent_only(
2473    graph: &mut CommitGraph<'_>,
2474    starts: &[ObjectId],
2475) -> Result<HashSet<ObjectId>> {
2476    let mut seen = HashSet::new();
2477    let mut queue = VecDeque::new();
2478    for &start in starts {
2479        queue.push_back(start);
2480    }
2481    while let Some(oid) = queue.pop_front() {
2482        if !seen.insert(oid) {
2483            continue;
2484        }
2485        let parents = graph.parents_of(oid)?;
2486        if let Some(&p) = parents.first() {
2487            queue.push_back(p);
2488        }
2489    }
2490    Ok(seen)
2491}
2492
2493/// BFS walk that returns both the set and the discovery order.
2494pub(crate) fn walk_closure_ordered(
2495    graph: &mut CommitGraph<'_>,
2496    starts: &[ObjectId],
2497) -> Result<(HashSet<ObjectId>, Vec<ObjectId>)> {
2498    let mut seen = HashSet::new();
2499    let mut order = Vec::new();
2500    let mut queue = VecDeque::new();
2501    for &start in starts {
2502        queue.push_back(start);
2503    }
2504    while let Some(oid) = queue.pop_front() {
2505        if !seen.insert(oid) {
2506            continue;
2507        }
2508        order.push(oid);
2509        for parent in graph.parents_of(oid)? {
2510            queue.push_back(parent);
2511        }
2512    }
2513    Ok((seen, order))
2514}
2515
2516/// Like [`date_order_walk`], but the initial heap is seeded from `tips` (in order) instead of
2517/// every commit with no selected children. Used when `--max-parents` / `--min-parents` filter out
2518/// a user tip (e.g. a merge): parents must be explicit seeds so both sides stay reachable.
2519fn date_order_walk_with_tips(
2520    graph: &mut CommitGraph<'_>,
2521    tips: &[ObjectId],
2522    selected: &HashSet<ObjectId>,
2523    author_dates: bool,
2524) -> Result<Vec<ObjectId>> {
2525    let mut unfinished_children: HashMap<ObjectId, usize> =
2526        selected.iter().map(|&oid| (oid, 0usize)).collect();
2527    for &child in selected {
2528        for parent in graph.parents_of(child)? {
2529            if selected.contains(&parent) {
2530                if let Some(count) = unfinished_children.get_mut(&parent) {
2531                    *count += 1;
2532                }
2533            }
2534        }
2535    }
2536
2537    let mut heap = BinaryHeap::new();
2538    for &tip in tips {
2539        if selected.contains(&tip) {
2540            heap.push(CommitDateKey {
2541                oid: tip,
2542                date: graph.sort_key(tip, author_dates),
2543            });
2544        }
2545    }
2546
2547    let mut emitted = HashSet::new();
2548    let mut out = Vec::with_capacity(selected.len());
2549    while let Some(item) = heap.pop() {
2550        if !emitted.insert(item.oid) {
2551            continue;
2552        }
2553        out.push(item.oid);
2554        for parent in graph.parents_of(item.oid)? {
2555            if !selected.contains(&parent) {
2556                continue;
2557            }
2558            let Some(count) = unfinished_children.get_mut(&parent) else {
2559                continue;
2560            };
2561            *count = count.saturating_sub(1);
2562            if *count == 0 {
2563                heap.push(CommitDateKey {
2564                    oid: parent,
2565                    date: graph.sort_key(parent, author_dates),
2566                });
2567            }
2568        }
2569    }
2570
2571    Ok(out)
2572}
2573
2574/// Git-style default ordering: among commits ready to print, pick the one with the
2575/// greatest committer timestamp; a parent becomes ready only after all of its
2576/// children that remain in the walk have been emitted.
2577///
2578/// This matches `git rev-list` behavior (and differs from sorting the whole set by
2579/// date, which can surface ancestors before descendants when dates are skewed).
2580pub(crate) fn date_order_walk(
2581    graph: &mut CommitGraph<'_>,
2582    selected: &HashSet<ObjectId>,
2583    author_dates: bool,
2584) -> Result<Vec<ObjectId>> {
2585    let mut unfinished_children: HashMap<ObjectId, usize> =
2586        selected.iter().map(|&oid| (oid, 0usize)).collect();
2587    for &child in selected {
2588        for parent in graph.parents_of(child)? {
2589            if selected.contains(&parent) {
2590                if let Some(count) = unfinished_children.get_mut(&parent) {
2591                    *count += 1;
2592                }
2593            }
2594        }
2595    }
2596
2597    // Match Git `commit_list_insert_by_date` / `get_revision_1`: only commits with no selected
2598    // child are initially pending. Seeding every `tip` that happens to appear in `tips` is wrong
2599    // when `tips` is the full selected set (path-walk): inner commits would be popped before
2600    // descendants. Seeding only `tips` is also wrong when a source commit is not a ref tip
2601    // (`rev-list`): start from every selected commit whose in-degree from selected is zero.
2602    let mut heap = BinaryHeap::new();
2603    for &oid in selected {
2604        if unfinished_children.get(&oid).copied().unwrap_or(0) == 0 {
2605            heap.push(CommitDateKey {
2606                oid,
2607                date: graph.sort_key(oid, author_dates),
2608            });
2609        }
2610    }
2611
2612    let mut emitted = HashSet::new();
2613    let mut out = Vec::with_capacity(selected.len());
2614    while let Some(item) = heap.pop() {
2615        if !emitted.insert(item.oid) {
2616            continue;
2617        }
2618        out.push(item.oid);
2619        for parent in graph.parents_of(item.oid)? {
2620            if !selected.contains(&parent) {
2621                continue;
2622            }
2623            let Some(count) = unfinished_children.get_mut(&parent) else {
2624                continue;
2625            };
2626            *count = count.saturating_sub(1);
2627            if *count == 0 {
2628                heap.push(CommitDateKey {
2629                    oid: parent,
2630                    date: graph.sort_key(parent, author_dates),
2631                });
2632            }
2633        }
2634    }
2635
2636    Ok(out)
2637}
2638
2639fn topo_sort(
2640    graph: &mut CommitGraph<'_>,
2641    selected: &HashSet<ObjectId>,
2642    author_dates: bool,
2643) -> Result<Vec<ObjectId>> {
2644    let mut child_count: HashMap<ObjectId, usize> = selected.iter().map(|&oid| (oid, 0)).collect();
2645
2646    for &oid in selected {
2647        for parent in graph.parents_of(oid)? {
2648            if !selected.contains(&parent) {
2649                continue;
2650            }
2651            if let Some(count) = child_count.get_mut(&parent) {
2652                *count += 1;
2653            }
2654        }
2655    }
2656
2657    // Git's `--topo-order`: among commits whose children have all been emitted, take the one
2658    // with the smallest committer date first (Kahn + min-heap). A max-heap on `CommitDateKey`
2659    // inverts this and breaks `rev-list --reverse --topo-order` vs upstream (t3425).
2660    let mut ready: BinaryHeap<Reverse<CommitDateKey>> = BinaryHeap::new();
2661    for (&oid, &count) in &child_count {
2662        if count == 0 {
2663            ready.push(Reverse(CommitDateKey {
2664                oid,
2665                date: graph.sort_key(oid, author_dates),
2666            }));
2667        }
2668    }
2669
2670    let mut out = Vec::with_capacity(selected.len());
2671    while let Some(Reverse(item)) = ready.pop() {
2672        let oid = item.oid;
2673        out.push(oid);
2674        for parent in graph.parents_of(oid)? {
2675            if !selected.contains(&parent) {
2676                continue;
2677            }
2678            if let Some(count) = child_count.get_mut(&parent) {
2679                *count = count.saturating_sub(1);
2680                if *count == 0 {
2681                    ready.push(Reverse(CommitDateKey {
2682                        oid: parent,
2683                        date: graph.sort_key(parent, author_dates),
2684                    }));
2685                }
2686            }
2687        }
2688    }
2689
2690    Ok(out)
2691}
2692
2693fn simplify_merges_commit_list(repo: &Repository, commits: &[ObjectId]) -> Result<Vec<ObjectId>> {
2694    let selected: HashSet<ObjectId> = commits.iter().copied().collect();
2695    let mut out = Vec::new();
2696    for oid in commits {
2697        let raw_parents = load_commit(repo, *oid)?.parents;
2698        let direct: Vec<ObjectId> = raw_parents
2699            .iter()
2700            .copied()
2701            .filter(|p| selected.contains(p))
2702            .collect();
2703        if raw_parents.len() > 1 && direct.len() <= 1 {
2704            continue;
2705        }
2706        if direct.len() <= 1 {
2707            out.push(*oid);
2708            continue;
2709        }
2710        let mut simplified = graph_simplify_parent_list_lib(repo, &selected, &direct)?;
2711        simplified.sort_unstable();
2712        simplified.dedup();
2713        if simplified.len() > 1 {
2714            out.push(*oid);
2715        }
2716    }
2717    Ok(out)
2718}
2719
2720fn graph_simplify_parent_list_lib(
2721    repo: &Repository,
2722    selected: &HashSet<ObjectId>,
2723    parents: &[ObjectId],
2724) -> Result<Vec<ObjectId>> {
2725    let mut out = Vec::new();
2726    for parent in parents {
2727        if parent_reachable_via_others_lib(repo, selected, *parent, parents)? {
2728            continue;
2729        }
2730        out.push(*parent);
2731    }
2732    Ok(out)
2733}
2734
2735fn parent_reachable_via_others_lib(
2736    repo: &Repository,
2737    selected: &HashSet<ObjectId>,
2738    target: ObjectId,
2739    parents: &[ObjectId],
2740) -> Result<bool> {
2741    for parent in parents {
2742        if *parent == target {
2743            continue;
2744        }
2745        if graph_reaches_lib(repo, selected, *parent, target)? {
2746            return Ok(true);
2747        }
2748    }
2749    Ok(false)
2750}
2751
2752fn graph_reaches_lib(
2753    repo: &Repository,
2754    selected: &HashSet<ObjectId>,
2755    start: ObjectId,
2756    target: ObjectId,
2757) -> Result<bool> {
2758    let mut stack = vec![start];
2759    let mut seen = HashSet::new();
2760    while let Some(oid) = stack.pop() {
2761        if !seen.insert(oid) {
2762            continue;
2763        }
2764        if oid == target {
2765            return Ok(true);
2766        }
2767        let mut parents = load_commit(repo, oid)?.parents;
2768        parents.retain(|p| selected.contains(p));
2769        stack.extend(parents);
2770    }
2771    Ok(false)
2772}
2773
2774fn load_raw_parents_lib(repo: &Repository, oid: ObjectId) -> Result<Vec<ObjectId>> {
2775    Ok(load_commit(repo, oid)?.parents)
2776}
2777
2778fn first_parent_of_commit_lib(repo: &Repository, oid: ObjectId) -> Result<Option<ObjectId>> {
2779    let parents = load_raw_parents_lib(repo, oid)?;
2780    Ok(parents.first().copied())
2781}
2782
2783fn first_parent_anchor_in_set_lib(
2784    repo: &Repository,
2785    start: ObjectId,
2786    anchors: &HashSet<ObjectId>,
2787) -> Result<Option<ObjectId>> {
2788    let mut seen = HashSet::new();
2789    let mut cursor = Some(start);
2790    while let Some(oid) = cursor {
2791        if !seen.insert(oid) {
2792            break;
2793        }
2794        if anchors.contains(&oid) {
2795            return Ok(Some(oid));
2796        }
2797        cursor = first_parent_of_commit_lib(repo, oid)?;
2798    }
2799    Ok(None)
2800}
2801
2802fn collect_visible_parent_for_graph_lib(
2803    repo: &Repository,
2804    candidate: ObjectId,
2805    included: &HashSet<ObjectId>,
2806    first_parent_only: bool,
2807    seen: &mut HashSet<ObjectId>,
2808    out: &mut Vec<ObjectId>,
2809) -> Result<()> {
2810    if !seen.insert(candidate) {
2811        return Ok(());
2812    }
2813    if included.contains(&candidate) {
2814        out.push(candidate);
2815        return Ok(());
2816    }
2817    let mut parents = load_raw_parents_lib(repo, candidate)?;
2818    if parents.is_empty() {
2819        return Ok(());
2820    }
2821    if parents.len() > 1 {
2822        parents.truncate(1);
2823    }
2824    for parent in parents {
2825        collect_visible_parent_for_graph_lib(repo, parent, included, first_parent_only, seen, out)?;
2826    }
2827    Ok(())
2828}
2829
2830fn visible_parents_for_graph_lib(
2831    repo: &Repository,
2832    oid: ObjectId,
2833    included: &HashSet<ObjectId>,
2834    first_parent_only: bool,
2835) -> Result<Vec<ObjectId>> {
2836    let mut direct = load_raw_parents_lib(repo, oid)?;
2837    if first_parent_only && direct.len() > 1 {
2838        direct.truncate(1);
2839    }
2840    let mut seen = HashSet::new();
2841    let mut out = Vec::new();
2842    for parent in direct {
2843        collect_visible_parent_for_graph_lib(
2844            repo,
2845            parent,
2846            included,
2847            first_parent_only,
2848            &mut seen,
2849            &mut out,
2850        )?;
2851    }
2852    let mut dedup = HashSet::new();
2853    out.retain(|parent| dedup.insert(*parent));
2854    Ok(out)
2855}
2856
2857fn reorder_path_limited_graph_commits(
2858    repo: &Repository,
2859    commits: &[ObjectId],
2860    first_parent_only: bool,
2861) -> Result<Vec<ObjectId>> {
2862    if commits.is_empty() {
2863        return Ok(Vec::new());
2864    }
2865
2866    let included: HashSet<ObjectId> = commits.iter().copied().collect();
2867    let mut chain = Vec::new();
2868    let mut chain_seen = HashSet::new();
2869    let mut cursor = Some(commits[0]);
2870    while let Some(oid) = cursor {
2871        if !included.contains(&oid) || !chain_seen.insert(oid) {
2872            break;
2873        }
2874        chain.push(oid);
2875        let visible = visible_parents_for_graph_lib(repo, oid, &included, first_parent_only)?;
2876        cursor = visible.first().copied();
2877    }
2878
2879    let chain_set: HashSet<ObjectId> = chain.iter().copied().collect();
2880    let mut grouped: HashMap<Option<ObjectId>, Vec<ObjectId>> = HashMap::new();
2881    for oid in commits {
2882        if chain_set.contains(oid) {
2883            continue;
2884        }
2885        let anchor = first_parent_anchor_in_set_lib(repo, *oid, &chain_set)?;
2886        grouped.entry(anchor).or_default().push(*oid);
2887    }
2888
2889    let mut ordered = Vec::new();
2890    for chain_oid in chain {
2891        if let Some(group) = grouped.remove(&Some(chain_oid)) {
2892            ordered.extend(group);
2893        }
2894        ordered.push(chain_oid);
2895    }
2896    if let Some(group) = grouped.remove(&None) {
2897        ordered.extend(group);
2898    }
2899    for (_anchor, group) in grouped {
2900        ordered.extend(group);
2901    }
2902    Ok(ordered)
2903}
2904
2905fn expand_sparse_path_limited_graph_history(
2906    repo: &Repository,
2907    commits: &[ObjectId],
2908) -> Result<Vec<ObjectId>> {
2909    if commits.is_empty() {
2910        return Ok(Vec::new());
2911    }
2912
2913    let mut expanded = Vec::new();
2914    let mut seen = HashSet::new();
2915    let mut push_unique = |oid: ObjectId, out: &mut Vec<ObjectId>| {
2916        if seen.insert(oid) {
2917            out.push(oid);
2918        }
2919    };
2920
2921    for window in commits.windows(2) {
2922        let from = window[0];
2923        let to = window[1];
2924        push_unique(from, &mut expanded);
2925
2926        let mut cursor = first_parent_of_commit_lib(repo, from)?;
2927        let mut chain = Vec::new();
2928        let mut found_target = false;
2929        let mut local_seen = HashSet::new();
2930        while let Some(oid) = cursor {
2931            if !local_seen.insert(oid) {
2932                break;
2933            }
2934            if oid == to {
2935                found_target = true;
2936                break;
2937            }
2938            chain.push(oid);
2939            cursor = first_parent_of_commit_lib(repo, oid)?;
2940        }
2941        if found_target {
2942            for oid in chain {
2943                push_unique(oid, &mut expanded);
2944            }
2945        }
2946    }
2947
2948    if let Some(&last) = commits.last() {
2949        push_unique(last, &mut expanded);
2950        let mut cursor = first_parent_of_commit_lib(repo, last)?;
2951        let mut tail_seen = HashSet::new();
2952        while let Some(oid) = cursor {
2953            if !tail_seen.insert(oid) {
2954                break;
2955            }
2956            push_unique(oid, &mut expanded);
2957            cursor = first_parent_of_commit_lib(repo, oid)?;
2958        }
2959    }
2960
2961    Ok(expanded)
2962}
2963
2964fn limit_to_ancestry(
2965    graph: &mut CommitGraph<'_>,
2966    selected: &mut HashSet<ObjectId>,
2967    bottoms: &[ObjectId],
2968) -> Result<()> {
2969    let mut keep = HashSet::new();
2970    for &bottom in bottoms {
2971        let ancestors = walk_closure(graph, &[bottom])?;
2972        keep.extend(
2973            ancestors
2974                .iter()
2975                .copied()
2976                .filter(|oid| selected.contains(oid)),
2977        );
2978
2979        for &candidate in selected.iter() {
2980            if candidate == bottom {
2981                keep.insert(candidate);
2982                continue;
2983            }
2984            let closure = walk_closure(graph, &[candidate])?;
2985            if closure.contains(&bottom) {
2986                keep.insert(candidate);
2987            }
2988        }
2989    }
2990    selected.retain(|oid| keep.contains(oid));
2991    Ok(())
2992}
2993
2994/// Check if a commit modifies any of the given paths compared to its parents.
2995///
2996/// `simplify_merges` / `show_pulls` mirror Git's path-limited `try_to_simplify_commit` behavior
2997/// for merge commits when `--full-history` is active (including the implicit full history from
2998/// `--simplify-merges` or `--ancestry-path`).
2999#[allow(clippy::too_many_arguments)]
3000fn commit_touches_paths(
3001    repo: &Repository,
3002    graph: &mut CommitGraph<'_>,
3003    oid: ObjectId,
3004    paths: &[String],
3005    full_history: bool,
3006    sparse: bool,
3007    simplify_merges: bool,
3008    show_pulls: bool,
3009    bloom_chain: Option<&CommitGraphChain>,
3010    read_changed_paths: bool,
3011    changed_paths_version: i32,
3012    bloom_stats: Option<&BloomWalkStatsHandle>,
3013    bloom_cwd: Option<&str>,
3014) -> Result<bool> {
3015    let commit = load_commit(repo, oid)?;
3016    let parents = graph.parents_of(oid)?;
3017    let commit_entries = flatten_tree(repo, commit.tree, "")?;
3018    let commit_map: HashMap<String, ObjectId> = commit_entries.into_iter().collect();
3019
3020    // Root commit: include only when any requested pathspec exists.
3021    if parents.is_empty() {
3022        if sparse {
3023            return Ok(true);
3024        }
3025        let ctx = crate::pathspec::PathspecMatchContext {
3026            is_directory: false,
3027            is_git_submodule: false,
3028        };
3029        return Ok(commit_map
3030            .keys()
3031            .any(|path| crate::pathspec::matches_pathspec_list_with_context(path, paths, ctx)));
3032    }
3033
3034    // Single-parent commit: include only when requested paths changed.
3035    if parents.len() == 1 {
3036        let mut bloom_ret = BloomPrecheck::Inapplicable;
3037        if let Some(chain) = bloom_chain {
3038            bloom_ret = chain.bloom_precheck_for_paths(
3039                &repo.odb,
3040                oid,
3041                paths,
3042                bloom_cwd,
3043                changed_paths_version,
3044                read_changed_paths,
3045            )?;
3046            if let Some(stats) = bloom_stats {
3047                if let Ok(mut g) = stats.lock() {
3048                    g.record_precheck(bloom_ret);
3049                }
3050            }
3051            if bloom_ret == BloomPrecheck::DefinitelyNot {
3052                return Ok(false);
3053            }
3054        }
3055
3056        let parent = load_commit(repo, parents[0])?;
3057        let parent_map: HashMap<String, ObjectId> =
3058            flatten_tree(repo, parent.tree, "")?.into_iter().collect();
3059        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
3060        if bloom_ret == BloomPrecheck::Maybe && !differs {
3061            if let Some(stats) = bloom_stats {
3062                if let Ok(mut g) = stats.lock() {
3063                    g.record_false_positive();
3064                }
3065            }
3066        }
3067        if differs {
3068            return Ok(true);
3069        }
3070        if sparse {
3071            return Ok(true);
3072        }
3073        return Ok(false);
3074    }
3075
3076    // Merge commit: dense history omits the merge when exactly one parent is TREESAME.
3077    let mut treesame_parents = 0usize;
3078    let mut differs_any = false;
3079    let mut first_parent_differs = false;
3080    for (nth, parent_oid) in parents.iter().enumerate() {
3081        let parent = load_commit(repo, *parent_oid)?;
3082        let parent_map: HashMap<String, ObjectId> =
3083            flatten_tree(repo, parent.tree, "")?.into_iter().collect();
3084        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
3085        if nth == 0 {
3086            first_parent_differs = differs;
3087        }
3088        if differs {
3089            differs_any = true;
3090        } else {
3091            treesame_parents += 1;
3092        }
3093    }
3094
3095    // `--full-history`: keep merges that are TREESAME to every parent for the pathspec (Git
3096    // `revision.c` still walks them for path-limited output; `t6012` expects them in the list).
3097    if full_history && !simplify_merges && parents.len() > 1 && treesame_parents == parents.len() {
3098        return Ok(true);
3099    }
3100
3101    if !full_history && treesame_parents == 1 {
3102        return Ok(false);
3103    }
3104
3105    if full_history && simplify_merges {
3106        if treesame_parents == parents.len() {
3107            return Ok(sparse);
3108        }
3109        if treesame_parents > 0 && !differs_any {
3110            return Ok(sparse);
3111        }
3112        if show_pulls && first_parent_differs && treesame_parents > 0 {
3113            return Ok(true);
3114        }
3115        if treesame_parents == 1 {
3116            return Ok(false);
3117        }
3118        return Ok(differs_any || sparse);
3119    }
3120
3121    if differs_any {
3122        return Ok(true);
3123    }
3124
3125    Ok(sparse)
3126}
3127
3128/// Whether `oid` would be included in a dense path-limited history walk for `paths`.
3129///
3130/// Matches the non-Bloom parts of [`commit_touches_paths`] with `full_history = false` and
3131/// `sparse = false`: single-parent commits require a tree change on `paths` vs their parent;
3132/// merge commits are omitted when exactly one parent is tree-same on `paths` (Git `TREESAME`
3133/// simplification). Used by `log -g -- <path>` to align with Git's reflog path filtering.
3134pub fn commit_visible_for_dense_pathspecs(
3135    repo: &Repository,
3136    oid: ObjectId,
3137    paths: &[String],
3138) -> Result<bool> {
3139    if paths.is_empty() {
3140        return Ok(true);
3141    }
3142    let commit = load_commit(repo, oid)?;
3143    let parents = commit.parents.clone();
3144    let commit_entries = flatten_tree(repo, commit.tree, "")?;
3145    let commit_map: HashMap<String, ObjectId> = commit_entries.into_iter().collect();
3146
3147    if parents.is_empty() {
3148        return Ok(commit_map.keys().any(|path| {
3149            paths.iter().any(|spec| {
3150                crate::pathspec::matches_pathspec_with_context(
3151                    spec,
3152                    path,
3153                    crate::pathspec::PathspecMatchContext {
3154                        is_directory: false,
3155                        is_git_submodule: false,
3156                    },
3157                )
3158            })
3159        }));
3160    }
3161
3162    if parents.len() == 1 {
3163        let parent = load_commit(repo, parents[0])?;
3164        let parent_map: HashMap<String, ObjectId> =
3165            flatten_tree(repo, parent.tree, "")?.into_iter().collect();
3166        return Ok(path_differs_for_specs(&commit_map, &parent_map, paths));
3167    }
3168
3169    let mut treesame_parents = 0usize;
3170    let mut differs_any = false;
3171    for parent_oid in &parents {
3172        let parent = load_commit(repo, *parent_oid)?;
3173        let parent_map: HashMap<String, ObjectId> =
3174            flatten_tree(repo, parent.tree, "")?.into_iter().collect();
3175        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
3176        if differs {
3177            differs_any = true;
3178        } else {
3179            treesame_parents += 1;
3180        }
3181    }
3182
3183    if treesame_parents == 1 {
3184        return Ok(false);
3185    }
3186    if differs_any {
3187        return Ok(true);
3188    }
3189    Ok(false)
3190}
3191
3192fn path_differs_for_specs(
3193    current: &HashMap<String, ObjectId>,
3194    parent: &HashMap<String, ObjectId>,
3195    specs: &[String],
3196) -> bool {
3197    let mut paths = std::collections::BTreeSet::new();
3198    paths.extend(current.keys().cloned());
3199    paths.extend(parent.keys().cloned());
3200
3201    for path in &paths {
3202        if !crate::pathspec::matches_pathspec_list(path, specs) {
3203            continue;
3204        }
3205        if current.get(path) != parent.get(path) {
3206            return true;
3207        }
3208    }
3209    false
3210}
3211
3212fn load_commit(repo: &Repository, oid: ObjectId) -> Result<crate::objects::CommitData> {
3213    let object = repo.odb.read(&oid)?;
3214    if object.kind != ObjectKind::Commit {
3215        return Err(Error::CorruptObject(format!(
3216            "object {oid} is not a commit"
3217        )));
3218    }
3219    parse_commit(&object.data)
3220}
3221
3222fn extend_split_token(
3223    token: &str,
3224    not_mode: bool,
3225    positive: &mut Vec<String>,
3226    negative: &mut Vec<String>,
3227) {
3228    let (pos, neg) = split_revision_token(token);
3229    if not_mode {
3230        positive.extend(neg);
3231        negative.extend(pos);
3232    } else {
3233        positive.extend(pos);
3234        negative.extend(neg);
3235    }
3236}
3237
3238/// Git `parse_long_opt`-style parsing for a single argv vector (used for `--stdin` lines).
3239///
3240/// Returns `Some((consumed_arg_count, value))` when `line` is `--<opt>` or `--<opt>=...`.
3241/// Consumed count is 1 for stuck form, 2 for detached form (caller must supply next line as value).
3242fn parse_long_opt_value(opt: &str, argv0: &str, argv1: Option<&str>) -> Option<(usize, String)> {
3243    let rest = argv0.strip_prefix("--")?;
3244    let rest = rest.strip_prefix(opt)?;
3245    if let Some(stripped) = rest.strip_prefix('=') {
3246        return Some((1, stripped.to_owned()));
3247    }
3248    if !rest.is_empty() {
3249        return None;
3250    }
3251    let Some(next) = argv1 else {
3252        return None;
3253    };
3254    Some((2, next.to_owned()))
3255}
3256
3257fn stdin_die_requires_value(opt: &str) -> Error {
3258    Error::Message(format!("fatal: Option '{opt}' requires a value"))
3259}
3260
3261fn apply_stdin_pseudo_opt(
3262    git_dir: &Path,
3263    line: &str,
3264    next_line: Option<&str>,
3265    not_mode: bool,
3266    positive: &mut Vec<String>,
3267    negative: &mut Vec<String>,
3268    stdin_all_refs: &mut bool,
3269) -> Result<Option<usize>> {
3270    if line == "--end-of-options" {
3271        return Ok(Some(1));
3272    }
3273    if line == "--all" {
3274        if not_mode {
3275            for (_, oid) in refs::list_refs(git_dir, "refs/")? {
3276                let s = oid.to_hex();
3277                negative.push(s);
3278            }
3279            if let Ok(head_oid) = refs::resolve_ref(git_dir, "HEAD") {
3280                negative.push(head_oid.to_hex());
3281            }
3282        } else {
3283            *stdin_all_refs = true;
3284        }
3285        return Ok(Some(1));
3286    }
3287    if line == "--not" {
3288        return Ok(Some(1));
3289    }
3290    if line == "--branches" {
3291        for (_, oid) in refs::list_refs(git_dir, "refs/heads/")? {
3292            let s = oid.to_hex();
3293            if not_mode {
3294                negative.push(s);
3295            } else {
3296                positive.push(s);
3297            }
3298        }
3299        return Ok(Some(1));
3300    }
3301    if line == "--tags" {
3302        for (_, oid) in refs::list_refs(git_dir, "refs/tags/")? {
3303            let s = oid.to_hex();
3304            if not_mode {
3305                negative.push(s);
3306            } else {
3307                positive.push(s);
3308            }
3309        }
3310        return Ok(Some(1));
3311    }
3312    if line == "--remotes" {
3313        for (_, oid) in refs::list_refs(git_dir, "refs/remotes/")? {
3314            let s = oid.to_hex();
3315            if not_mode {
3316                negative.push(s);
3317            } else {
3318                positive.push(s);
3319            }
3320        }
3321        return Ok(Some(1));
3322    }
3323    if let Some((consumed, pattern)) = parse_long_opt_value("branches", line, next_line) {
3324        let full_pattern = format!("refs/heads/{pattern}");
3325        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
3326            let s = oid.to_hex();
3327            if not_mode {
3328                negative.push(s);
3329            } else {
3330                positive.push(s);
3331            }
3332        }
3333        return Ok(Some(consumed));
3334    }
3335    if let Some((1, pattern)) = parse_long_opt_value("tags", line, None) {
3336        let full_pattern = format!("refs/tags/{pattern}");
3337        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
3338            let s = oid.to_hex();
3339            if not_mode {
3340                negative.push(s);
3341            } else {
3342                positive.push(s);
3343            }
3344        }
3345        return Ok(Some(1));
3346    }
3347    if let Some((consumed, pattern)) = parse_long_opt_value("tags", line, next_line) {
3348        let full_pattern = format!("refs/tags/{pattern}");
3349        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
3350            let s = oid.to_hex();
3351            if not_mode {
3352                negative.push(s);
3353            } else {
3354                positive.push(s);
3355            }
3356        }
3357        return Ok(Some(consumed));
3358    }
3359    if let Some((1, pattern)) = parse_long_opt_value("remotes", line, None) {
3360        let full_pattern = format!("refs/remotes/{pattern}");
3361        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
3362            let s = oid.to_hex();
3363            if not_mode {
3364                negative.push(s);
3365            } else {
3366                positive.push(s);
3367            }
3368        }
3369        return Ok(Some(1));
3370    }
3371    if let Some((consumed, pattern)) = parse_long_opt_value("remotes", line, next_line) {
3372        let full_pattern = format!("refs/remotes/{pattern}");
3373        for (_, oid) in refs::list_refs_glob(git_dir, &full_pattern)? {
3374            let s = oid.to_hex();
3375            if not_mode {
3376                negative.push(s);
3377            } else {
3378                positive.push(s);
3379            }
3380        }
3381        return Ok(Some(consumed));
3382    }
3383    if line == "--glob" {
3384        return Err(stdin_die_requires_value("--glob"));
3385    }
3386    if let Some((1, pattern)) = parse_long_opt_value("glob", line, None) {
3387        for (_, oid) in refs::list_refs_glob(git_dir, &pattern)? {
3388            let s = oid.to_hex();
3389            if not_mode {
3390                negative.push(s);
3391            } else {
3392                positive.push(s);
3393            }
3394        }
3395        return Ok(Some(1));
3396    }
3397    if let Some((consumed, pattern)) = parse_long_opt_value("glob", line, next_line) {
3398        for (_, oid) in refs::list_refs_glob(git_dir, &pattern)? {
3399            let s = oid.to_hex();
3400            if not_mode {
3401                negative.push(s);
3402            } else {
3403                positive.push(s);
3404            }
3405        }
3406        return Ok(Some(consumed));
3407    }
3408    if line == "--no-walk" || line.starts_with("--no-walk=") {
3409        if let Some(rest) = line.strip_prefix("--no-walk=") {
3410            if rest == "sorted" || rest == "unsorted" {
3411                return Ok(Some(1));
3412            }
3413            eprintln!("error: invalid argument to --no-walk");
3414            return Err(Error::Message(format!(
3415                "fatal: invalid option '{line}' in --stdin mode"
3416            )));
3417        }
3418        return Ok(Some(1));
3419    }
3420    if line.starts_with("--") {
3421        return Err(Error::Message(format!(
3422            "fatal: invalid option '{line}' in --stdin mode"
3423        )));
3424    }
3425    if line.starts_with('-') {
3426        return Err(Error::Message(format!(
3427            "fatal: invalid option '{line}' in --stdin mode"
3428        )));
3429    }
3430    Ok(None)
3431}
3432
3433/// Read `--stdin` revision lines and pathspec tail (after `--`), matching Git `read_revisions_from_stdin`.
3434fn read_revisions_from_stdin_lines(
3435    git_dir: &Path,
3436) -> Result<(Vec<String>, Vec<String>, bool, Vec<String>)> {
3437    let stdin = std::io::read_to_string(std::io::stdin()).map_err(Error::Io)?;
3438    let lines: Vec<String> = stdin.lines().map(std::borrow::ToOwned::to_owned).collect();
3439
3440    let mut positive = Vec::new();
3441    let mut negative = Vec::new();
3442    let mut stdin_all_refs = false;
3443    let mut stdin_not_mode = false;
3444    let mut seen_end_of_options = false;
3445    let mut i = 0usize;
3446
3447    while i < lines.len() {
3448        let line = lines[i].as_str();
3449        if line.is_empty() {
3450            break;
3451        }
3452        if line == "--" {
3453            i += 1;
3454            let paths: Vec<String> = lines[i..].to_vec();
3455            return Ok((positive, negative, stdin_all_refs, paths));
3456        }
3457
3458        if !seen_end_of_options && line.starts_with('-') {
3459            if line == "--end-of-options" {
3460                seen_end_of_options = true;
3461                i += 1;
3462                continue;
3463            }
3464            let next = lines.get(i + 1).map(|s| s.as_str());
3465            match apply_stdin_pseudo_opt(
3466                git_dir,
3467                line,
3468                next,
3469                stdin_not_mode,
3470                &mut positive,
3471                &mut negative,
3472                &mut stdin_all_refs,
3473            )? {
3474                Some(consumed) => {
3475                    if line == "--not" && consumed == 1 {
3476                        stdin_not_mode = !stdin_not_mode;
3477                    }
3478                    i += consumed;
3479                }
3480                None => {
3481                    extend_split_token(line, stdin_not_mode, &mut positive, &mut negative);
3482                    i += 1;
3483                }
3484            }
3485            continue;
3486        }
3487
3488        extend_split_token(line, stdin_not_mode, &mut positive, &mut negative);
3489        i += 1;
3490    }
3491
3492    Ok((positive, negative, stdin_all_refs, Vec::new()))
3493}
3494
3495/// Merge command-line revision strings with `--stdin` lines (and optional stdin pathspecs).
3496///
3497/// Returns `(positive_specs, negative_specs, stdin_saw_all, stdin_pathspecs)`.
3498///
3499/// Command-line `--not` is applied while building `args_specs` (see `rev-list`); stdin uses an
3500/// independent `--not` toggle, matching Git.
3501///
3502/// # Errors
3503///
3504/// Returns [`Error::Message`] for Git-compatible `fatal:` stderr lines from stdin mode.
3505pub fn collect_revision_specs_with_stdin(
3506    git_dir: &Path,
3507    args_specs: &[String],
3508    read_stdin: bool,
3509) -> Result<(Vec<String>, Vec<String>, bool, Vec<String>)> {
3510    let mut positive = Vec::new();
3511    let mut negative = Vec::new();
3512
3513    for spec in args_specs {
3514        let (pos, neg) = split_revision_token(spec);
3515        positive.extend(pos);
3516        negative.extend(neg);
3517    }
3518
3519    if !read_stdin {
3520        return Ok((positive, negative, false, Vec::new()));
3521    }
3522
3523    let (s_pos, s_neg, stdin_all_refs, stdin_paths) = read_revisions_from_stdin_lines(git_dir)?;
3524    positive.extend(s_pos);
3525    negative.extend(s_neg);
3526
3527    Ok((positive, negative, stdin_all_refs, stdin_paths))
3528}
3529
3530/// Resolve every local tag object ID.
3531pub fn tag_targets(git_dir: &Path) -> Result<HashSet<ObjectId>> {
3532    Ok(refs::list_refs(git_dir, "refs/tags/")?
3533        .into_iter()
3534        .map(|(_, oid)| oid)
3535        .collect())
3536}
3537
3538pub(crate) struct CommitGraph<'r> {
3539    repo: &'r Repository,
3540    first_parent_only: bool,
3541    parents: HashMap<ObjectId, Vec<ObjectId>>,
3542    committer_time: HashMap<ObjectId, i64>,
3543    author_time: HashMap<ObjectId, i64>,
3544    shallow_boundaries: HashSet<ObjectId>,
3545    graft_parents: HashMap<ObjectId, Vec<ObjectId>>,
3546}
3547
3548impl<'r> CommitGraph<'r> {
3549    pub(crate) fn new(repo: &'r Repository, first_parent_only: bool) -> Self {
3550        let shallow_boundaries = load_shallow_boundaries(&repo.git_dir);
3551        let graft_parents = load_graft_parents(&repo.git_dir);
3552        Self {
3553            repo,
3554            first_parent_only,
3555            parents: HashMap::new(),
3556            committer_time: HashMap::new(),
3557            author_time: HashMap::new(),
3558            shallow_boundaries,
3559            graft_parents,
3560        }
3561    }
3562
3563    pub(crate) fn parents_of(&mut self, oid: ObjectId) -> Result<Vec<ObjectId>> {
3564        self.populate(oid)?;
3565        Ok(self.parents.get(&oid).cloned().unwrap_or_default())
3566    }
3567
3568    fn committer_time(&mut self, oid: ObjectId) -> i64 {
3569        if self.populate(oid).is_err() {
3570            return 0;
3571        }
3572        self.committer_time.get(&oid).copied().unwrap_or(0)
3573    }
3574
3575    fn author_time(&mut self, oid: ObjectId) -> i64 {
3576        if self.populate(oid).is_err() {
3577            return 0;
3578        }
3579        self.author_time.get(&oid).copied().unwrap_or(0)
3580    }
3581
3582    fn sort_key(&mut self, oid: ObjectId, author: bool) -> i64 {
3583        if author {
3584            self.author_time(oid)
3585        } else {
3586            self.committer_time(oid)
3587        }
3588    }
3589
3590    fn populate(&mut self, oid: ObjectId) -> Result<()> {
3591        if self.parents.contains_key(&oid) {
3592            return Ok(());
3593        }
3594        let commit = load_commit(self.repo, oid)?;
3595        // Shallow boundaries: treat commit as having no parents
3596        let mut parents = if self.shallow_boundaries.contains(&oid) {
3597            Vec::new()
3598        } else {
3599            commit.parents
3600        };
3601        if let Some(graft_parents) = self.graft_parents.get(&oid) {
3602            parents = graft_parents.clone();
3603        }
3604        if self.first_parent_only && parents.len() > 1 {
3605            parents.truncate(1);
3606        }
3607        self.committer_time
3608            .insert(oid, committer_unix_seconds_for_ordering(&commit.committer));
3609        self.author_time
3610            .insert(oid, committer_unix_seconds_for_ordering(&commit.author));
3611        self.parents.insert(oid, parents);
3612        Ok(())
3613    }
3614}
3615
3616/// Load shallow boundary commit OIDs from `.git/shallow`.
3617fn load_shallow_boundaries(git_dir: &Path) -> HashSet<ObjectId> {
3618    let shallow_path = git_dir.join("shallow");
3619    let mut set = HashSet::new();
3620    if let Ok(contents) = fs::read_to_string(&shallow_path) {
3621        for line in contents.lines() {
3622            let line = line.trim();
3623            if !line.is_empty() {
3624                if let Ok(oid) = line.parse::<ObjectId>() {
3625                    set.insert(oid);
3626                }
3627            }
3628        }
3629    }
3630    set
3631}
3632
3633/// Load commit parent overrides from `.git/info/grafts`.
3634fn load_graft_parents(git_dir: &Path) -> HashMap<ObjectId, Vec<ObjectId>> {
3635    let graft_path = git_dir.join("info/grafts");
3636    let mut grafts = HashMap::new();
3637    let Ok(contents) = fs::read_to_string(&graft_path) else {
3638        return grafts;
3639    };
3640    for raw_line in contents.lines() {
3641        let line = raw_line.trim();
3642        if line.is_empty() || line.starts_with('#') {
3643            continue;
3644        }
3645        let mut fields = line.split_whitespace();
3646        let Some(commit_hex) = fields.next() else {
3647            continue;
3648        };
3649        let Ok(commit_oid) = commit_hex.parse::<ObjectId>() else {
3650            continue;
3651        };
3652        let mut parents = Vec::new();
3653        let mut valid = true;
3654        for parent_hex in fields {
3655            match parent_hex.parse::<ObjectId>() {
3656                Ok(parent_oid) => parents.push(parent_oid),
3657                Err(_) => {
3658                    valid = false;
3659                    break;
3660                }
3661            }
3662        }
3663        if valid {
3664            grafts.insert(commit_oid, parents);
3665        }
3666    }
3667    grafts
3668}
3669
3670fn commit_tips_from_ref_pairs(
3671    repo: &Repository,
3672    pairs: &[(String, ObjectId)],
3673    exclusions: &RefExclusions,
3674) -> Result<Vec<ObjectId>> {
3675    let namespace_prefix = git_namespace_prefix();
3676    let mut raw = Vec::new();
3677    for (refname, oid) in pairs {
3678        if exclusions.ref_excluded(strip_git_namespace(refname, &namespace_prefix), refname) {
3679            continue;
3680        }
3681        raw.push(*oid);
3682    }
3683    peel_ref_oids_to_unique_commits(repo, raw)
3684}
3685
3686fn peel_ref_oids_to_unique_commits(repo: &Repository, raw: Vec<ObjectId>) -> Result<Vec<ObjectId>> {
3687    let mut out = Vec::new();
3688    let mut seen = HashSet::new();
3689    for oid in raw {
3690        match peel_to_commit(repo, oid) {
3691            Ok(commit_oid) if seen.insert(commit_oid) => out.push(commit_oid),
3692            Err(_) => {}
3693            _ => {}
3694        }
3695    }
3696    out.sort();
3697    Ok(out)
3698}
3699
3700fn all_ref_tips(repo: &Repository, exclusions: &RefExclusions) -> Result<Vec<ObjectId>> {
3701    let mut pairs = Vec::new();
3702    if let Ok(head) = refs::resolve_ref(&repo.git_dir, "HEAD") {
3703        pairs.push(("HEAD".to_owned(), head));
3704    }
3705    pairs.extend(refs::list_refs(&repo.git_dir, "refs/")?);
3706    commit_tips_from_ref_pairs(repo, &pairs, exclusions)
3707}
3708
3709/// Expand named refs to peeled unique commit tips, applying `--exclude` / `--exclude-hidden` rules.
3710pub fn commit_tips_from_named_refs(
3711    repo: &Repository,
3712    pairs: &[(String, ObjectId)],
3713    exclusions: &RefExclusions,
3714) -> Result<Vec<ObjectId>> {
3715    commit_tips_from_ref_pairs(repo, pairs, exclusions)
3716}
3717
3718/// Commit OIDs listed in `.git/shallow` (shallow clone boundaries).
3719///
3720/// At each boundary commit, history is cut: parents are omitted from the object store and must
3721/// not be traversed for connectivity checks (`git fsck`, `pack-objects` reachability, etc.).
3722#[must_use]
3723pub fn shallow_boundary_oids(git_dir: &Path) -> HashSet<ObjectId> {
3724    crate::shallow::load_shallow_boundaries(git_dir)
3725}
3726
3727/// Shallow boundary commits on paths from `wants` when the server repository is shallow.
3728///
3729/// Matches Git `get_shallow_commits` with infinite depth and no client-advertised shallows: walk
3730/// from wanted tips, stop parent traversal at `.git/shallow` entries, and return those boundary
3731/// commits (for protocol v2 `shallow-info` and `pack-objects --shallow`).
3732#[must_use]
3733pub fn shallow_borders_reachable_from_wants(
3734    repo: &Repository,
3735    wants: &[ObjectId],
3736) -> Vec<ObjectId> {
3737    let boundaries = shallow_boundary_oids(&repo.git_dir);
3738    if boundaries.is_empty() || wants.is_empty() {
3739        return Vec::new();
3740    }
3741    let mut out: Vec<ObjectId> = Vec::new();
3742    let mut seen_out = HashSet::new();
3743    let mut visited = HashSet::new();
3744    let mut q: VecDeque<ObjectId> = wants.iter().copied().collect();
3745    while let Some(oid) = q.pop_front() {
3746        if !visited.insert(oid) {
3747            continue;
3748        }
3749        if boundaries.contains(&oid) {
3750            if seen_out.insert(oid) {
3751                out.push(oid);
3752            }
3753            continue;
3754        }
3755        for p in commit_parent_ids(repo, oid) {
3756            q.push_back(p);
3757        }
3758    }
3759    out.sort();
3760    out
3761}
3762
3763/// Compute new shallow boundary commits for `upload-pack` when the client sends `deepen <n>`.
3764///
3765/// This approximates Git's `get_shallow_commits` / `get_shallows_or_depth` for the common case:
3766/// non-`deepen-relative` fetches where the client lists its shallow commits and requests more
3767/// history. Returns commit OIDs that should be sent as `shallow` lines and registered as grafts
3768/// for `pack-objects --shallow`.
3769///
3770/// # Parameters
3771///
3772/// - `wants` — wanted commit OIDs (usually remote `HEAD` / ref tips).
3773/// - `client_shallow` — OIDs the client advertised with `shallow <oid>` before `deepen`.
3774/// - `deepen` — positive integer from the `deepen` pkt-line (`deepen 2` → `2`).
3775#[must_use]
3776pub fn shallow_grafts_for_upload_pack_deepen(
3777    repo: &Repository,
3778    wants: &[ObjectId],
3779    client_shallow: &[ObjectId],
3780    deepen: usize,
3781) -> Vec<ObjectId> {
3782    if deepen == 0 || wants.is_empty() {
3783        return Vec::new();
3784    }
3785
3786    let server_shallow = shallow_boundary_oids(&repo.git_dir);
3787    let client_set: HashSet<ObjectId> = client_shallow.iter().copied().collect();
3788
3789    let min_hit = min_client_shallow_distance(repo, wants, &client_set, &server_shallow);
3790    let base = min_hit.unwrap_or(1);
3791    let target_depth = base.saturating_add(deepen);
3792
3793    let included = commits_within_parent_depth(repo, wants, target_depth, &server_shallow);
3794    border_commits_not_in_client_shallow(repo, &included, &client_set)
3795}
3796
3797fn commit_parent_ids(repo: &Repository, oid: ObjectId) -> Vec<ObjectId> {
3798    let Ok(obj) = repo.odb.read(&oid) else {
3799        return Vec::new();
3800    };
3801    if obj.kind != ObjectKind::Commit {
3802        return Vec::new();
3803    }
3804    parse_commit(&obj.data)
3805        .map(|c| c.parents)
3806        .unwrap_or_default()
3807}
3808
3809fn min_client_shallow_distance(
3810    repo: &Repository,
3811    wants: &[ObjectId],
3812    client_shallow: &HashSet<ObjectId>,
3813    server_shallow: &HashSet<ObjectId>,
3814) -> Option<usize> {
3815    if client_shallow.is_empty() {
3816        return None;
3817    }
3818    let mut best: Option<usize> = None;
3819    let mut dist: HashMap<ObjectId, usize> = HashMap::new();
3820    let mut q: VecDeque<(ObjectId, usize)> = VecDeque::new();
3821    for &w in wants {
3822        dist.insert(w, 0);
3823        q.push_back((w, 0));
3824    }
3825    while let Some((oid, d)) = q.pop_front() {
3826        if client_shallow.contains(&oid) {
3827            best = Some(best.map(|b| b.min(d)).unwrap_or(d));
3828        }
3829        if server_shallow.contains(&oid) {
3830            continue;
3831        }
3832        for p in commit_parent_ids(repo, oid) {
3833            let nd = d.saturating_add(1);
3834            let prev = dist.get(&p).copied().unwrap_or(usize::MAX);
3835            if nd < prev {
3836                dist.insert(p, nd);
3837                q.push_back((p, nd));
3838            }
3839        }
3840    }
3841    best
3842}
3843
3844fn commits_within_parent_depth(
3845    repo: &Repository,
3846    wants: &[ObjectId],
3847    max_depth: usize,
3848    server_shallow: &HashSet<ObjectId>,
3849) -> HashSet<ObjectId> {
3850    let mut best_depth: HashMap<ObjectId, usize> = HashMap::new();
3851    let mut q: VecDeque<(ObjectId, usize)> = VecDeque::new();
3852    for &w in wants {
3853        best_depth.insert(w, 1);
3854        q.push_back((w, 1));
3855    }
3856    while let Some((oid, depth)) = q.pop_front() {
3857        if depth > max_depth {
3858            continue;
3859        }
3860        if best_depth.get(&oid).copied() != Some(depth) {
3861            continue;
3862        }
3863        if depth == max_depth {
3864            continue;
3865        }
3866        if server_shallow.contains(&oid) {
3867            continue;
3868        }
3869        for p in commit_parent_ids(repo, oid) {
3870            let nd = depth.saturating_add(1);
3871            if nd > max_depth {
3872                continue;
3873            }
3874            let prev = best_depth.get(&p).copied().unwrap_or(usize::MAX);
3875            if nd < prev {
3876                best_depth.insert(p, nd);
3877                q.push_back((p, nd));
3878            }
3879        }
3880    }
3881    best_depth.into_keys().collect()
3882}
3883
3884fn border_commits_not_in_client_shallow(
3885    repo: &Repository,
3886    included: &HashSet<ObjectId>,
3887    client_shallow: &HashSet<ObjectId>,
3888) -> Vec<ObjectId> {
3889    let mut out = Vec::new();
3890    let mut seen_out = HashSet::new();
3891    for &c in included {
3892        if client_shallow.contains(&c) {
3893            continue;
3894        }
3895        let parents = commit_parent_ids(repo, c);
3896        let is_border = parents.iter().any(|p| !included.contains(p));
3897        if is_border && seen_out.insert(c) {
3898            out.push(c);
3899        }
3900    }
3901    out.sort();
3902    out
3903}
3904
3905/// Shallow boundary commits for `upload-pack` when the client uses `deepen-since` and/or
3906/// `deepen-not` (Git runs `rev-list --max-age=…` / `--not` and derives border commits).
3907///
3908/// Returns OIDs to advertise as `shallow` lines and pass to `pack-objects --shallow`.
3909pub fn shallow_grafts_for_upload_pack_rev_list(
3910    repo: &Repository,
3911    wants: &[ObjectId],
3912    client_shallow: &[ObjectId],
3913    deepen_since: Option<i64>,
3914    deepen_not: &[ObjectId],
3915) -> Result<Vec<ObjectId>> {
3916    if wants.is_empty() || (deepen_since.is_none() && deepen_not.is_empty()) {
3917        return Ok(Vec::new());
3918    }
3919
3920    let positive: Vec<String> = wants.iter().map(|o| o.to_hex()).collect();
3921    let negative: Vec<String> = deepen_not
3922        .iter()
3923        .map(|o| format!("^{}", o.to_hex()))
3924        .collect();
3925
3926    let options = RevListOptions {
3927        since_cutoff: deepen_since,
3928        missing_action: MissingAction::Allow,
3929        ..Default::default()
3930    };
3931
3932    let res = rev_list(repo, &positive, &negative, &options)?;
3933    let included: HashSet<ObjectId> = res.commits.iter().copied().collect();
3934    let client_set: HashSet<ObjectId> = client_shallow.iter().copied().collect();
3935    Ok(border_commits_not_in_client_shallow(
3936        repo,
3937        &included,
3938        &client_set,
3939    ))
3940}
3941
3942#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3943struct CommitDateKey {
3944    oid: ObjectId,
3945    date: i64,
3946}
3947
3948impl Ord for CommitDateKey {
3949    fn cmp(&self, other: &Self) -> Ordering {
3950        match self.date.cmp(&other.date) {
3951            Ordering::Equal => self.oid.cmp(&other.oid),
3952            ord => ord,
3953        }
3954    }
3955}
3956
3957impl PartialOrd for CommitDateKey {
3958    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
3959        Some(self.cmp(other))
3960    }
3961}
3962
3963/// Read every line from a newline-delimited file.
3964///
3965/// # Errors
3966///
3967/// Returns [`Error::Io`] when the file cannot be read.
3968pub fn read_lines(path: &Path) -> Result<Vec<String>> {
3969    let content = fs::read_to_string(path)?;
3970    Ok(content.lines().map(|line| line.to_owned()).collect())
3971}
3972
3973/// Check if a token uses the symmetric diff `...` notation.
3974#[must_use]
3975pub fn is_symmetric_diff(token: &str) -> bool {
3976    token.contains("...") && !token.contains("....")
3977}
3978
3979/// Split a symmetric diff token into (lhs, rhs).
3980#[must_use]
3981pub fn split_symmetric_diff(token: &str) -> Option<(String, String)> {
3982    token
3983        .split_once("...")
3984        .map(|(l, r)| (l.to_owned(), r.to_owned()))
3985}
3986
3987/// Maps each tree OID to the minimum traversal depth it was entered at (Git `list-objects` /
3988/// `tree:<n>` semantics: the same tree may be revisited from a shallower path).
3989#[derive(Debug, Default)]
3990struct TreeWalkState {
3991    seen_at_depth: HashMap<ObjectId, u64>,
3992}
3993
3994impl TreeWalkState {
3995    fn new() -> Self {
3996        Self::default()
3997    }
3998
3999    /// Returns `true` if this tree at `depth` should be skipped (already entered at same or
4000    /// shallower depth).
4001    fn should_skip_tree(&mut self, oid: ObjectId, depth: u64) -> bool {
4002        match self.seen_at_depth.get(&oid).copied() {
4003            None => {
4004                self.seen_at_depth.insert(oid, depth);
4005                false
4006            }
4007            Some(prev) if depth >= prev => true,
4008            Some(_) => {
4009                self.seen_at_depth.insert(oid, depth);
4010                false
4011            }
4012        }
4013    }
4014}
4015
4016/// All tree and blob OIDs reachable from `tree_oid` (including `tree_oid` itself).
4017fn collect_tree_closure_objects(
4018    repo: &Repository,
4019    tree_oid: ObjectId,
4020    into: &mut HashSet<ObjectId>,
4021    missing_action: MissingAction,
4022    missing: &mut Vec<ObjectId>,
4023    missing_seen: &mut HashSet<ObjectId>,
4024) -> Result<()> {
4025    let mut stack = vec![tree_oid];
4026    let mut expanded_trees = HashSet::new();
4027    while let Some(t) = stack.pop() {
4028        if !expanded_trees.insert(t) {
4029            continue;
4030        }
4031        into.insert(t);
4032        let object = match repo.odb.read(&t) {
4033            Ok(o) => o,
4034            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
4035                if missing_action == MissingAction::Print && missing_seen.insert(t) {
4036                    missing.push(t);
4037                }
4038                continue;
4039            }
4040            Err(e) => return Err(e),
4041        };
4042        if object.kind != ObjectKind::Tree {
4043            continue;
4044        }
4045        let entries = parse_tree(&object.data)?;
4046        for entry in entries {
4047            if entry.mode == 0o160000 {
4048                continue;
4049            }
4050            into.insert(entry.oid);
4051            if entry.mode == 0o040000 {
4052                stack.push(entry.oid);
4053            }
4054        }
4055    }
4056    Ok(())
4057}
4058
4059fn union_parent_reachable_objects(
4060    repo: &Repository,
4061    parents: &[ObjectId],
4062    missing_action: MissingAction,
4063    missing: &mut Vec<ObjectId>,
4064    missing_seen: &mut HashSet<ObjectId>,
4065) -> Result<HashSet<ObjectId>> {
4066    let mut out = HashSet::new();
4067    for &p in parents {
4068        let commit = match load_commit(repo, p) {
4069            Ok(c) => c,
4070            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
4071                if missing_action == MissingAction::Print && missing_seen.insert(p) {
4072                    missing.push(p);
4073                }
4074                continue;
4075            }
4076            Err(e) => return Err(e),
4077        };
4078        collect_tree_closure_objects(
4079            repo,
4080            commit.tree,
4081            &mut out,
4082            missing_action,
4083            missing,
4084            missing_seen,
4085        )?;
4086    }
4087    Ok(out)
4088}
4089
4090/// Collect all reachable non-commit objects (trees and blobs) from a set of commits.
4091/// Returns (included, omitted) object lists.
4092#[allow(dead_code)]
4093fn collect_reachable_objects(
4094    repo: &Repository,
4095    graph: &mut CommitGraph<'_>,
4096    commits: &[ObjectId],
4097    object_roots: &[RootObject],
4098    tip_annotated_tags: &HashMap<ObjectId, ObjectId>,
4099    filter: Option<&ObjectFilter>,
4100    filter_provided: bool,
4101    missing_action: MissingAction,
4102    sparse_lines: Option<&[String]>,
4103    skip_trees_for_type_filter: bool,
4104    omit_object_paths: bool,
4105    packed_set: Option<&HashSet<ObjectId>>,
4106    collect_tree_omits: bool,
4107) -> Result<(Vec<(ObjectId, String)>, Vec<ObjectId>, Vec<ObjectId>)> {
4108    let mut tree_state = TreeWalkState::new();
4109    let mut top_tree_omit =
4110        walk_needs_top_tree_omit_set(filter, collect_tree_omits).then(HashSet::<ObjectId>::new);
4111    let mut combine_states = CombineSubState::prepare_sub_states(filter, collect_tree_omits);
4112    let mut emitted = HashSet::new();
4113    let mut result = Vec::new();
4114    let mut omitted = Vec::new();
4115    let mut missing = Vec::new();
4116    let mut missing_seen = HashSet::new();
4117    for &commit_oid in commits {
4118        let commit = match load_commit(repo, commit_oid) {
4119            Ok(commit) => commit,
4120            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
4121                if missing_seen.insert(commit_oid) && missing_action == MissingAction::Print {
4122                    missing.push(commit_oid);
4123                }
4124                continue;
4125            }
4126            Err(err) => return Err(err),
4127        };
4128        let parents = graph.parents_of(commit_oid)?;
4129        let parent_union = union_parent_reachable_objects(
4130            repo,
4131            &parents,
4132            missing_action,
4133            &mut missing,
4134            &mut missing_seen,
4135        )?;
4136        if let Some(&tag_oid) = tip_annotated_tags.get(&commit_oid) {
4137            if emitted.insert(tag_oid) {
4138                result.push((tag_oid, "tag".to_owned()));
4139            }
4140        }
4141        collect_tree_objects_filtered(
4142            repo,
4143            commit.tree,
4144            "",
4145            0,
4146            false,
4147            Some(&parent_union),
4148            &mut tree_state,
4149            &mut emitted,
4150            &mut result,
4151            &mut omitted,
4152            &mut missing,
4153            &mut missing_seen,
4154            filter,
4155            filter_provided,
4156            missing_action,
4157            sparse_lines,
4158            skip_trees_for_type_filter,
4159            omit_object_paths,
4160            packed_set,
4161            collect_tree_omits,
4162            &mut top_tree_omit,
4163            &mut combine_states,
4164        )?;
4165    }
4166
4167    for root in object_roots {
4168        collect_root_object(
4169            repo,
4170            root,
4171            &mut tree_state,
4172            &mut emitted,
4173            &mut result,
4174            &mut omitted,
4175            &mut missing,
4176            &mut missing_seen,
4177            filter,
4178            filter_provided,
4179            missing_action,
4180            sparse_lines,
4181            skip_trees_for_type_filter,
4182            omit_object_paths,
4183            packed_set,
4184            collect_tree_omits,
4185            &mut top_tree_omit,
4186            &mut combine_states,
4187        )?;
4188    }
4189
4190    Ok((result, omitted, missing))
4191}
4192
4193/// Like [`collect_reachable_objects`], but also returns objects newly discovered per commit walk
4194/// plus one trailing segment for `object_roots`.
4195///
4196/// Matches Git `traverse_commit_list_filtered`: each commit's tree is processed before moving to
4197/// the next commit, with global de-duplication of emitted object OIDs across the full walk.
4198fn collect_reachable_objects_segmented(
4199    repo: &Repository,
4200    _graph: &mut CommitGraph<'_>,
4201    commits: &[ObjectId],
4202    object_roots: &[RootObject],
4203    tip_annotated_tags: &HashMap<ObjectId, ObjectId>,
4204    filter: Option<&ObjectFilter>,
4205    filter_provided: bool,
4206    missing_action: MissingAction,
4207    sparse_lines: Option<&[String]>,
4208    skip_trees_for_type_filter: bool,
4209    omit_object_paths: bool,
4210    packed_set: Option<&HashSet<ObjectId>>,
4211    collect_tree_omits: bool,
4212) -> Result<(
4213    Vec<(ObjectId, String)>,
4214    Vec<ObjectId>,
4215    Vec<ObjectId>,
4216    Vec<Vec<(ObjectId, String)>>,
4217)> {
4218    let mut emitted = HashSet::new();
4219    let mut result = Vec::new();
4220    let mut omitted = Vec::new();
4221    let mut missing = Vec::new();
4222    let mut missing_seen = HashSet::new();
4223    let mut segments: Vec<Vec<(ObjectId, String)>> = Vec::with_capacity(commits.len() + 1);
4224    let mut tree_state = TreeWalkState::new();
4225    let mut top_tree_omit =
4226        walk_needs_top_tree_omit_set(filter, collect_tree_omits).then(HashSet::<ObjectId>::new);
4227    let mut combine_states = CombineSubState::prepare_sub_states(filter, collect_tree_omits);
4228
4229    for &commit_oid in commits {
4230        let start = result.len();
4231        let commit = match load_commit(repo, commit_oid) {
4232            Ok(commit) => commit,
4233            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
4234                if missing_action == MissingAction::Print && missing_seen.insert(commit_oid) {
4235                    missing.push(commit_oid);
4236                }
4237                segments.push(Vec::new());
4238                continue;
4239            }
4240            Err(err) => return Err(err),
4241        };
4242        if let Some(&tag_oid) = tip_annotated_tags.get(&commit_oid) {
4243            if emitted.insert(tag_oid) {
4244                result.push((tag_oid, "tag".to_owned()));
4245            }
4246        }
4247        // Same as `collect_reachable_objects_in_commit_order`: Git lists objects in walk order with
4248        // global OID de-duplication only (`emitted`), not parent-closure subtraction.
4249        collect_tree_objects_filtered(
4250            repo,
4251            commit.tree,
4252            "",
4253            0,
4254            false,
4255            None,
4256            &mut tree_state,
4257            &mut emitted,
4258            &mut result,
4259            &mut omitted,
4260            &mut missing,
4261            &mut missing_seen,
4262            filter,
4263            filter_provided,
4264            missing_action,
4265            sparse_lines,
4266            skip_trees_for_type_filter,
4267            omit_object_paths,
4268            packed_set,
4269            collect_tree_omits,
4270            &mut top_tree_omit,
4271            &mut combine_states,
4272        )?;
4273        segments.push(result[start..].to_vec());
4274    }
4275
4276    let roots_start = result.len();
4277    for root in object_roots {
4278        collect_root_object(
4279            repo,
4280            root,
4281            &mut tree_state,
4282            &mut emitted,
4283            &mut result,
4284            &mut omitted,
4285            &mut missing,
4286            &mut missing_seen,
4287            filter,
4288            filter_provided,
4289            missing_action,
4290            sparse_lines,
4291            skip_trees_for_type_filter,
4292            omit_object_paths,
4293            packed_set,
4294            collect_tree_omits,
4295            &mut top_tree_omit,
4296            &mut combine_states,
4297        )?;
4298    }
4299    segments.push(result[roots_start..].to_vec());
4300
4301    Ok((result, omitted, missing, segments))
4302}
4303
4304#[derive(Clone, Copy, Debug)]
4305struct ListFilterBits {
4306    mark_seen: bool,
4307    do_show: bool,
4308    skip_tree: bool,
4309}
4310
4311impl ListFilterBits {
4312    fn merge_combine(subs: &[ListFilterBits], sub_skipping: &[bool]) -> Self {
4313        let mut out = ListFilterBits {
4314            mark_seen: true,
4315            do_show: true,
4316            skip_tree: true,
4317        };
4318        for (sub, skipping) in subs.iter().zip(sub_skipping.iter()) {
4319            if !sub.do_show {
4320                out.do_show = false;
4321            }
4322            if !sub.mark_seen {
4323                out.mark_seen = false;
4324            }
4325            if !skipping {
4326                out.skip_tree = false;
4327            }
4328        }
4329        out
4330    }
4331}
4332
4333fn walk_needs_top_tree_omit_set(filter: Option<&ObjectFilter>, collect_omits: bool) -> bool {
4334    collect_omits && matches!(filter, Some(ObjectFilter::TreeDepth(_)))
4335}
4336
4337fn trace_skip_tree_contents(prefix: &str) {
4338    let Ok(trace_val) = std::env::var("GIT_TRACE") else {
4339        return;
4340    };
4341    if trace_val.is_empty() || trace_val == "0" || trace_val.eq_ignore_ascii_case("false") {
4342        return;
4343    }
4344    let path = if prefix.is_empty() {
4345        String::new()
4346    } else {
4347        format!("{prefix}/")
4348    };
4349    let line = format!("Skipping contents of tree {path}...\n");
4350    match trace_val.as_str() {
4351        "1" | "true" | "2" => {
4352            let _ = std::io::stderr().write_all(line.as_bytes());
4353        }
4354        path_dest => {
4355            if let Ok(mut f) = std::fs::OpenOptions::new()
4356                .create(true)
4357                .append(true)
4358                .open(path_dest)
4359            {
4360                let _ = f.write_all(line.as_bytes());
4361            }
4362        }
4363    }
4364}
4365
4366fn tree_depth_begin_tree(
4367    tree_oid: ObjectId,
4368    depth: u64,
4369    exclude_depth: u64,
4370    tree_state: &mut TreeWalkState,
4371    tree_omit_set: &mut Option<HashSet<ObjectId>>,
4372    collect_omits: bool,
4373) -> ListFilterBits {
4374    let include_it = depth < exclude_depth;
4375    let already_seen = tree_state.should_skip_tree(tree_oid, depth);
4376    if already_seen {
4377        return ListFilterBits {
4378            mark_seen: false,
4379            do_show: false,
4380            skip_tree: true,
4381        };
4382    }
4383
4384    let been_omitted = if collect_omits {
4385        if let Some(omits) = tree_omit_set.as_mut() {
4386            if include_it {
4387                omits.remove(&tree_oid);
4388                false
4389            } else {
4390                !omits.insert(tree_oid)
4391            }
4392        } else {
4393            false
4394        }
4395    } else {
4396        false
4397    };
4398
4399    let skip_tree = if include_it {
4400        false
4401    } else {
4402        !(collect_omits && !been_omitted)
4403    };
4404
4405    let do_show = include_it;
4406    ListFilterBits {
4407        mark_seen: true,
4408        do_show,
4409        skip_tree,
4410    }
4411}
4412
4413fn tree_depth_blob(
4414    oid: ObjectId,
4415    _size: u64,
4416    parent_depth: u64,
4417    exclude_depth: u64,
4418    tree_omit_set: &mut Option<HashSet<ObjectId>>,
4419    collect_omits: bool,
4420) -> ListFilterBits {
4421    let include_it = parent_depth.saturating_add(1) < exclude_depth;
4422    if collect_omits {
4423        if let Some(omits) = tree_omit_set.as_mut() {
4424            if include_it {
4425                omits.remove(&oid);
4426            } else {
4427                omits.insert(oid);
4428            }
4429        }
4430    }
4431    ListFilterBits {
4432        // Omitted blobs stay not-SEEN so the same OID can be revisited from a shallower path (t6112).
4433        mark_seen: include_it,
4434        do_show: include_it,
4435        skip_tree: false,
4436    }
4437}
4438
4439fn filter_object_bits_tree_begin(
4440    f: &ObjectFilter,
4441    tree_oid: ObjectId,
4442    depth: u64,
4443    tree_state: &mut TreeWalkState,
4444    tree_omit_set: &mut Option<HashSet<ObjectId>>,
4445    collect_omits: bool,
4446    sub_states: Option<&mut [CombineSubState]>,
4447) -> ListFilterBits {
4448    match f {
4449        ObjectFilter::BlobNone | ObjectFilter::BlobLimit(_) => ListFilterBits {
4450            mark_seen: true,
4451            do_show: true,
4452            skip_tree: false,
4453        },
4454        ObjectFilter::TreeDepth(excl) => tree_depth_begin_tree(
4455            tree_oid,
4456            depth,
4457            *excl,
4458            tree_state,
4459            tree_omit_set,
4460            collect_omits,
4461        ),
4462        ObjectFilter::SparseOid(_) => ListFilterBits {
4463            mark_seen: true,
4464            do_show: true,
4465            skip_tree: false,
4466        },
4467        ObjectFilter::ObjectType(k) => {
4468            // Match Git `filter_object_type` LOFS_BEGIN_TREE: only commit/tag filters skip recursion;
4469            // blob filters must walk trees to reach blobs.
4470            let show = *k == FilterObjectKind::Tree;
4471            let skip_tree = matches!(k, FilterObjectKind::Commit | FilterObjectKind::Tag);
4472            ListFilterBits {
4473                mark_seen: true,
4474                do_show: show,
4475                skip_tree,
4476            }
4477        }
4478        ObjectFilter::Combine(parts) => {
4479            let states = sub_states.expect("combine sub-states");
4480            debug_assert_eq!(states.len(), parts.len());
4481            let mut bits = Vec::with_capacity(parts.len());
4482            let mut skipping = Vec::with_capacity(parts.len());
4483            for (i, p) in parts.iter().enumerate() {
4484                let b = filter_object_bits_tree_begin(
4485                    p,
4486                    tree_oid,
4487                    depth,
4488                    &mut states[i].tree_state,
4489                    &mut states[i].tree_omit_set,
4490                    collect_omits,
4491                    None,
4492                );
4493                if b.skip_tree {
4494                    states[i].is_skipping_tree = true;
4495                    states[i].skip_tree_oid = Some(tree_oid);
4496                } else {
4497                    states[i].is_skipping_tree = false;
4498                    states[i].skip_tree_oid = None;
4499                }
4500                bits.push(b);
4501                skipping.push(states[i].is_skipping_tree);
4502            }
4503            ListFilterBits::merge_combine(&bits, &skipping)
4504        }
4505    }
4506}
4507
4508fn filter_object_bits_blob(
4509    f: &ObjectFilter,
4510    oid: ObjectId,
4511    size: u64,
4512    parent_depth: u64,
4513    tree_omit_set: &mut Option<HashSet<ObjectId>>,
4514    collect_omits: bool,
4515    sub_states: Option<&mut [CombineSubState]>,
4516) -> ListFilterBits {
4517    match f {
4518        ObjectFilter::BlobNone => ListFilterBits {
4519            mark_seen: true,
4520            do_show: false,
4521            skip_tree: false,
4522        },
4523        ObjectFilter::BlobLimit(limit) => {
4524            let include = size < *limit;
4525            ListFilterBits {
4526                mark_seen: true,
4527                do_show: include,
4528                skip_tree: false,
4529            }
4530        }
4531        ObjectFilter::TreeDepth(excl) => {
4532            tree_depth_blob(oid, size, parent_depth, *excl, tree_omit_set, collect_omits)
4533        }
4534        ObjectFilter::SparseOid(_) => ListFilterBits {
4535            mark_seen: true,
4536            do_show: true,
4537            skip_tree: false,
4538        },
4539        ObjectFilter::ObjectType(k) => {
4540            let show = *k == FilterObjectKind::Blob;
4541            ListFilterBits {
4542                mark_seen: true,
4543                do_show: show,
4544                skip_tree: false,
4545            }
4546        }
4547        ObjectFilter::Combine(parts) => {
4548            let states = sub_states.expect("combine sub-states");
4549            let mut bits = Vec::with_capacity(parts.len());
4550            let mut skipping = Vec::with_capacity(parts.len());
4551            for (i, p) in parts.iter().enumerate() {
4552                let b = filter_object_bits_blob(
4553                    p,
4554                    oid,
4555                    size,
4556                    parent_depth,
4557                    &mut states[i].tree_omit_set,
4558                    collect_omits,
4559                    None,
4560                );
4561                bits.push(b);
4562                skipping.push(states[i].is_skipping_tree);
4563            }
4564            ListFilterBits::merge_combine(&bits, &skipping)
4565        }
4566    }
4567}
4568
4569#[derive(Debug)]
4570struct CombineSubState {
4571    tree_state: TreeWalkState,
4572    tree_omit_set: Option<HashSet<ObjectId>>,
4573    is_skipping_tree: bool,
4574    skip_tree_oid: Option<ObjectId>,
4575}
4576
4577impl CombineSubState {
4578    fn new(parts_len: usize, collect_omits: bool) -> Vec<Self> {
4579        (0..parts_len)
4580            .map(|_| Self {
4581                tree_state: TreeWalkState::new(),
4582                tree_omit_set: collect_omits.then(HashSet::new),
4583                is_skipping_tree: false,
4584                skip_tree_oid: None,
4585            })
4586            .collect()
4587    }
4588
4589    fn prepare_sub_states(
4590        filter: Option<&ObjectFilter>,
4591        collect_omits: bool,
4592    ) -> Option<Vec<CombineSubState>> {
4593        match filter {
4594            Some(ObjectFilter::Combine(parts)) => {
4595                Some(CombineSubState::new(parts.len(), collect_omits))
4596            }
4597            _ => None,
4598        }
4599    }
4600}
4601
4602#[allow(dead_code)]
4603fn collect_tree_objects_filtered(
4604    repo: &Repository,
4605    tree_oid: ObjectId,
4606    prefix: &str,
4607    depth: u64,
4608    explicit_root: bool,
4609    parent_union: Option<&HashSet<ObjectId>>,
4610    tree_state: &mut TreeWalkState,
4611    emitted: &mut HashSet<ObjectId>,
4612    result: &mut Vec<(ObjectId, String)>,
4613    omitted: &mut Vec<ObjectId>,
4614    missing: &mut Vec<ObjectId>,
4615    missing_seen: &mut HashSet<ObjectId>,
4616    filter: Option<&ObjectFilter>,
4617    filter_provided: bool,
4618    missing_action: MissingAction,
4619    sparse_lines: Option<&[String]>,
4620    skip_trees_for_type_filter: bool,
4621    omit_object_paths: bool,
4622    packed_set: Option<&HashSet<ObjectId>>,
4623    collect_tree_omits: bool,
4624    tree_omit_set: &mut Option<HashSet<ObjectId>>,
4625    combine_states: &mut Option<Vec<CombineSubState>>,
4626) -> Result<()> {
4627    if !explicit_root {
4628        if let Some(pu) = parent_union {
4629            if pu.contains(&tree_oid) {
4630                return Ok(());
4631            }
4632        }
4633    }
4634    let object = match repo.odb.read(&tree_oid) {
4635        Ok(object) => object,
4636        Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
4637            if missing_action == MissingAction::Print && missing_seen.insert(tree_oid) {
4638                missing.push(tree_oid);
4639            }
4640            return Ok(());
4641        }
4642        Err(err) => return Err(err),
4643    };
4644    if object.kind != ObjectKind::Tree {
4645        return Err(Error::CorruptObject(format!(
4646            "object {tree_oid} is not a tree"
4647        )));
4648    }
4649
4650    let bits = match filter {
4651        None => ListFilterBits {
4652            mark_seen: true,
4653            do_show: true,
4654            skip_tree: false,
4655        },
4656        Some(f) => {
4657            if explicit_root && !filter_provided {
4658                ListFilterBits {
4659                    mark_seen: true,
4660                    do_show: true,
4661                    skip_tree: false,
4662                }
4663            } else {
4664                match f {
4665                    ObjectFilter::Combine(_) => {
4666                        let states = combine_states.as_mut().expect("combine states");
4667                        filter_object_bits_tree_begin(
4668                            f,
4669                            tree_oid,
4670                            depth,
4671                            tree_state,
4672                            tree_omit_set,
4673                            collect_tree_omits,
4674                            Some(states.as_mut_slice()),
4675                        )
4676                    }
4677                    _ => filter_object_bits_tree_begin(
4678                        f,
4679                        tree_oid,
4680                        depth,
4681                        tree_state,
4682                        tree_omit_set,
4683                        collect_tree_omits,
4684                        None,
4685                    ),
4686                }
4687            }
4688        }
4689    };
4690
4691    // Git `filter_sparse` always shows tree objects; sparse patterns gate blobs (and inherited
4692    // default_match), not whether the tree OID is listed.
4693    let tree_included = bits.do_show;
4694    if tree_included {
4695        if !packed_set.is_some_and(|p| p.contains(&tree_oid)) && emitted.insert(tree_oid) {
4696            let out_path = if omit_object_paths {
4697                String::new()
4698            } else {
4699                prefix.to_owned()
4700            };
4701            result.push((tree_oid, out_path));
4702        }
4703    } else if bits.mark_seen {
4704        omitted.push(tree_oid);
4705    }
4706
4707    if bits.skip_tree {
4708        trace_skip_tree_contents(prefix);
4709    }
4710
4711    if skip_trees_for_type_filter && depth == 0 && !explicit_root {
4712        return Ok(());
4713    }
4714
4715    if bits.skip_tree {
4716        return Ok(());
4717    }
4718
4719    let entries = parse_tree(&object.data)?;
4720    for entry in entries {
4721        if entry.mode == 0o160000 {
4722            continue;
4723        }
4724        let name = String::from_utf8_lossy(&entry.name).to_string();
4725        let path = if prefix.is_empty() {
4726            name.clone()
4727        } else {
4728            format!("{prefix}/{name}")
4729        };
4730        let child_obj = match repo.odb.read(&entry.oid) {
4731            Ok(object) => object,
4732            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
4733                if missing_action == MissingAction::Print && missing_seen.insert(entry.oid) {
4734                    missing.push(entry.oid);
4735                }
4736                continue;
4737            }
4738            Err(err) => return Err(err),
4739        };
4740        if entry.mode == 0o040000 {
4741            if child_obj.kind != ObjectKind::Tree {
4742                return Err(Error::CorruptObject(format!(
4743                    "object {} is not a tree",
4744                    entry.oid
4745                )));
4746            }
4747            if let Some(pu) = parent_union {
4748                if pu.contains(&entry.oid) {
4749                    continue;
4750                }
4751            }
4752            let child_tree_depth = depth + 1;
4753            collect_tree_objects_filtered(
4754                repo,
4755                entry.oid,
4756                &path,
4757                child_tree_depth,
4758                false,
4759                parent_union,
4760                tree_state,
4761                emitted,
4762                result,
4763                omitted,
4764                missing,
4765                missing_seen,
4766                filter,
4767                filter_provided,
4768                missing_action,
4769                sparse_lines,
4770                skip_trees_for_type_filter,
4771                omit_object_paths,
4772                packed_set,
4773                collect_tree_omits,
4774                tree_omit_set,
4775                combine_states,
4776            )?;
4777        } else {
4778            if let Some(pu) = parent_union {
4779                if pu.contains(&entry.oid) {
4780                    continue;
4781                }
4782            }
4783            if child_obj.kind == ObjectKind::Blob {
4784                let sparse_blob = sparse_filter_includes_path(repo, &path, sparse_lines);
4785                let blob_bits = match filter {
4786                    None => ListFilterBits {
4787                        mark_seen: true,
4788                        do_show: true,
4789                        skip_tree: false,
4790                    },
4791                    Some(f) => {
4792                        if explicit_root && !filter_provided {
4793                            ListFilterBits {
4794                                mark_seen: true,
4795                                do_show: true,
4796                                skip_tree: false,
4797                            }
4798                        } else {
4799                            match f {
4800                                ObjectFilter::Combine(_) => {
4801                                    let states = combine_states.as_mut().unwrap();
4802                                    filter_object_bits_blob(
4803                                        f,
4804                                        entry.oid,
4805                                        child_obj.data.len() as u64,
4806                                        depth,
4807                                        tree_omit_set,
4808                                        collect_tree_omits,
4809                                        Some(states.as_mut_slice()),
4810                                    )
4811                                }
4812                                _ => filter_object_bits_blob(
4813                                    f,
4814                                    entry.oid,
4815                                    child_obj.data.len() as u64,
4816                                    depth,
4817                                    tree_omit_set,
4818                                    collect_tree_omits,
4819                                    None,
4820                                ),
4821                            }
4822                        }
4823                    }
4824                };
4825                let blob_included = blob_bits.do_show && sparse_blob;
4826                if !blob_included {
4827                    omitted.push(entry.oid);
4828                } else if blob_bits.mark_seen
4829                    && emitted.insert(entry.oid)
4830                    && !packed_set.is_some_and(|p| p.contains(&entry.oid))
4831                {
4832                    let out_path = if omit_object_paths {
4833                        String::new()
4834                    } else {
4835                        path.clone()
4836                    };
4837                    result.push((entry.oid, out_path));
4838                }
4839            } else {
4840                if emitted.contains(&entry.oid) {
4841                    return Err(Error::CorruptObject(format!(
4842                        "object {} is not a blob",
4843                        entry.oid
4844                    )));
4845                }
4846                if emitted.insert(entry.oid) {
4847                    result.push((entry.oid, path));
4848                }
4849            }
4850        }
4851    }
4852
4853    if let Some(f) = filter {
4854        if let ObjectFilter::Combine(_) = f {
4855            if let Some(states) = combine_states.as_mut() {
4856                for st in states.iter_mut() {
4857                    if st.is_skipping_tree && st.skip_tree_oid == Some(tree_oid) {
4858                        st.is_skipping_tree = false;
4859                        st.skip_tree_oid = None;
4860                    }
4861                }
4862            }
4863        }
4864    }
4865
4866    Ok(())
4867}
4868
4869fn collect_root_object(
4870    repo: &Repository,
4871    root: &RootObject,
4872    tree_state: &mut TreeWalkState,
4873    emitted: &mut HashSet<ObjectId>,
4874    result: &mut Vec<(ObjectId, String)>,
4875    omitted: &mut Vec<ObjectId>,
4876    missing: &mut Vec<ObjectId>,
4877    missing_seen: &mut HashSet<ObjectId>,
4878    filter: Option<&ObjectFilter>,
4879    filter_provided: bool,
4880    missing_action: MissingAction,
4881    sparse_lines: Option<&[String]>,
4882    skip_trees_for_type_filter: bool,
4883    omit_object_paths: bool,
4884    packed_set: Option<&HashSet<ObjectId>>,
4885    collect_tree_omits: bool,
4886    tree_omit_set: &mut Option<HashSet<ObjectId>>,
4887    combine_states: &mut Option<Vec<CombineSubState>>,
4888) -> Result<()> {
4889    if let Some(tag_oid) = root.wrap_with_tag {
4890        let show_tag = match filter {
4891            None => true,
4892            Some(f) => f.includes_commit_or_tag_object(ObjectKind::Tag),
4893        };
4894        if show_tag && emitted.insert(tag_oid) {
4895            result.push((tag_oid, "tag".to_owned()));
4896        }
4897    }
4898
4899    let object = match repo.odb.read(&root.oid) {
4900        Ok(object) => object,
4901        Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
4902            if missing_action == MissingAction::Print && missing_seen.insert(root.oid) {
4903                missing.push(root.oid);
4904            }
4905            return Ok(());
4906        }
4907        Err(err) => return Err(err),
4908    };
4909
4910    if let Some(expected) = root.expected_kind {
4911        if !expected.matches(object.kind) {
4912            return Err(Error::CorruptObject(format!(
4913                "object {} is not a {}",
4914                root.input,
4915                expected.as_str()
4916            )));
4917        }
4918    }
4919
4920    match object.kind {
4921        ObjectKind::Commit => {
4922            let commit = parse_commit(&object.data)?;
4923            let parent_union = union_parent_reachable_objects(
4924                repo,
4925                &commit.parents,
4926                missing_action,
4927                missing,
4928                missing_seen,
4929            )?;
4930            collect_tree_objects_filtered(
4931                repo,
4932                commit.tree,
4933                "",
4934                0,
4935                false,
4936                Some(&parent_union),
4937                tree_state,
4938                emitted,
4939                result,
4940                omitted,
4941                missing,
4942                missing_seen,
4943                filter,
4944                filter_provided,
4945                missing_action,
4946                sparse_lines,
4947                skip_trees_for_type_filter,
4948                omit_object_paths,
4949                packed_set,
4950                collect_tree_omits,
4951                tree_omit_set,
4952                combine_states,
4953            )?;
4954        }
4955        ObjectKind::Tree => {
4956            collect_tree_objects_filtered(
4957                repo,
4958                root.oid,
4959                "",
4960                0,
4961                true,
4962                None,
4963                tree_state,
4964                emitted,
4965                result,
4966                omitted,
4967                missing,
4968                missing_seen,
4969                filter,
4970                filter_provided,
4971                missing_action,
4972                sparse_lines,
4973                skip_trees_for_type_filter,
4974                omit_object_paths,
4975                packed_set,
4976                collect_tree_omits,
4977                tree_omit_set,
4978                combine_states,
4979            )?;
4980        }
4981        ObjectKind::Blob => {
4982            let path_for_sparse = root.root_path.as_deref().unwrap_or("");
4983            let sparse_blob = sparse_filter_includes_path(repo, path_for_sparse, sparse_lines);
4984            let blob_bits = match filter {
4985                None => ListFilterBits {
4986                    mark_seen: true,
4987                    do_show: true,
4988                    skip_tree: false,
4989                },
4990                Some(f) => {
4991                    if !filter_provided {
4992                        ListFilterBits {
4993                            mark_seen: true,
4994                            do_show: true,
4995                            skip_tree: false,
4996                        }
4997                    } else {
4998                        match f {
4999                            ObjectFilter::Combine(_) => {
5000                                let states = combine_states.as_mut().expect("combine states");
5001                                filter_object_bits_blob(
5002                                    f,
5003                                    root.oid,
5004                                    object.data.len() as u64,
5005                                    0,
5006                                    tree_omit_set,
5007                                    collect_tree_omits,
5008                                    Some(states.as_mut_slice()),
5009                                )
5010                            }
5011                            _ => filter_object_bits_blob(
5012                                f,
5013                                root.oid,
5014                                object.data.len() as u64,
5015                                0,
5016                                tree_omit_set,
5017                                collect_tree_omits,
5018                                None,
5019                            ),
5020                        }
5021                    }
5022                }
5023            };
5024            let blob_included = blob_bits.do_show && sparse_blob;
5025            if !blob_included {
5026                if blob_bits.mark_seen {
5027                    omitted.push(root.oid);
5028                }
5029                return Ok(());
5030            }
5031            if packed_set.is_some_and(|p| p.contains(&root.oid)) {
5032                return Ok(());
5033            }
5034            if blob_bits.mark_seen && !emitted.insert(root.oid) {
5035                return Ok(());
5036            }
5037            let out_path = if omit_object_paths {
5038                String::new()
5039            } else {
5040                path_for_sparse.to_owned()
5041            };
5042            result.push((root.oid, out_path));
5043        }
5044        ObjectKind::Tag => {
5045            let tag = parse_tag(&object.data)?;
5046            let expected_kind =
5047                ExpectedObjectKind::from_tag_type(&tag.object_type).ok_or_else(|| {
5048                    Error::CorruptObject(format!(
5049                        "object {} has unsupported tag type '{}'",
5050                        root.input, tag.object_type
5051                    ))
5052                })?;
5053            let nested = RootObject {
5054                oid: tag.object,
5055                input: root.input.clone(),
5056                expected_kind: Some(expected_kind),
5057                root_path: None,
5058                wrap_with_tag: None,
5059            };
5060            collect_root_object(
5061                repo,
5062                &nested,
5063                tree_state,
5064                emitted,
5065                result,
5066                omitted,
5067                missing,
5068                missing_seen,
5069                filter,
5070                filter_provided,
5071                missing_action,
5072                sparse_lines,
5073                skip_trees_for_type_filter,
5074                omit_object_paths,
5075                packed_set,
5076                collect_tree_omits,
5077                tree_omit_set,
5078                combine_states,
5079            )?;
5080        }
5081    }
5082
5083    Ok(())
5084}
5085
5086/// Collect reachable objects in commit order: objects for each commit are emitted
5087/// right after that commit, rather than all objects after all commits.
5088/// Returns (objects, omitted, per_commit_counts).
5089fn collect_reachable_objects_in_commit_order(
5090    repo: &Repository,
5091    _graph: &mut CommitGraph<'_>,
5092    commits: &[ObjectId],
5093    object_roots: &[RootObject],
5094    tip_annotated_tags: &HashMap<ObjectId, ObjectId>,
5095    filter: Option<&ObjectFilter>,
5096    filter_provided: bool,
5097    missing_action: MissingAction,
5098    sparse_lines: Option<&[String]>,
5099    skip_trees_for_type_filter: bool,
5100    omit_object_paths: bool,
5101    packed_set: Option<&HashSet<ObjectId>>,
5102    collect_tree_omits: bool,
5103) -> Result<(
5104    Vec<(ObjectId, String)>,
5105    Vec<ObjectId>,
5106    Vec<ObjectId>,
5107    Vec<usize>,
5108)> {
5109    let mut tree_state = TreeWalkState::new();
5110    let mut top_tree_omit =
5111        walk_needs_top_tree_omit_set(filter, collect_tree_omits).then(HashSet::<ObjectId>::new);
5112    let mut combine_states = CombineSubState::prepare_sub_states(filter, collect_tree_omits);
5113    let mut emitted = HashSet::new();
5114    let mut result = Vec::new();
5115    let mut omitted = Vec::new();
5116    let mut missing = Vec::new();
5117    let mut missing_seen = HashSet::new();
5118    let mut counts = Vec::with_capacity(commits.len());
5119    for &commit_oid in commits {
5120        let commit = match load_commit(repo, commit_oid) {
5121            Ok(commit) => commit,
5122            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
5123                if missing_action == MissingAction::Print && missing_seen.insert(commit_oid) {
5124                    missing.push(commit_oid);
5125                }
5126                counts.push(0);
5127                continue;
5128            }
5129            Err(err) => return Err(err),
5130        };
5131        let before = result.len();
5132        if let Some(&tag_oid) = tip_annotated_tags.get(&commit_oid) {
5133            if emitted.insert(tag_oid) {
5134                result.push((tag_oid, "tag".to_owned()));
5135            }
5136        }
5137        // Match Git `rev-list --objects`: walk each commit's tree in full traversal order and rely
5138        // on `emitted` for OID de-duplication. Do not subtract parent reachability here — that would
5139        // skip blobs that still belong after this commit's tree line (t6100-rev-list-in-order).
5140        collect_tree_objects_filtered(
5141            repo,
5142            commit.tree,
5143            "",
5144            0,
5145            false,
5146            None,
5147            &mut tree_state,
5148            &mut emitted,
5149            &mut result,
5150            &mut omitted,
5151            &mut missing,
5152            &mut missing_seen,
5153            filter,
5154            filter_provided,
5155            missing_action,
5156            sparse_lines,
5157            skip_trees_for_type_filter,
5158            omit_object_paths,
5159            packed_set,
5160            collect_tree_omits,
5161            &mut top_tree_omit,
5162            &mut combine_states,
5163        )?;
5164        counts.push(result.len() - before);
5165    }
5166
5167    for root in object_roots {
5168        collect_root_object(
5169            repo,
5170            root,
5171            &mut tree_state,
5172            &mut emitted,
5173            &mut result,
5174            &mut omitted,
5175            &mut missing,
5176            &mut missing_seen,
5177            filter,
5178            filter_provided,
5179            missing_action,
5180            sparse_lines,
5181            skip_trees_for_type_filter,
5182            omit_object_paths,
5183            packed_set,
5184            collect_tree_omits,
5185            &mut top_tree_omit,
5186            &mut combine_states,
5187        )?;
5188    }
5189
5190    Ok((result, omitted, missing, counts))
5191}
5192
5193/// Collect OIDs of all objects in packs that have a `.keep` file.
5194fn kept_object_ids(repo: &Repository) -> Result<HashSet<ObjectId>> {
5195    let pack_dir = repo.git_dir.join("objects/pack");
5196    let mut kept = HashSet::new();
5197    if !pack_dir.is_dir() {
5198        return Ok(kept);
5199    }
5200    for entry in std::fs::read_dir(&pack_dir)? {
5201        let entry = entry?;
5202        let path = entry.path();
5203        if path.extension().is_some_and(|ext| ext == "keep") {
5204            // Find the corresponding .idx file
5205            let idx_path = path.with_extension("idx");
5206            if idx_path.exists() {
5207                if let Ok(oids) = crate::pack::read_idx_object_ids(&idx_path) {
5208                    kept.extend(oids);
5209                }
5210            }
5211        }
5212    }
5213    Ok(kept)
5214}
5215
5216fn flatten_tree(
5217    repo: &Repository,
5218    tree_oid: ObjectId,
5219    prefix: &str,
5220) -> Result<Vec<(String, ObjectId)>> {
5221    let mut result = Vec::new();
5222    let object = match repo.odb.read(&tree_oid) {
5223        Ok(o) => o,
5224        Err(_) => return Ok(result),
5225    };
5226    if object.kind != ObjectKind::Tree {
5227        return Ok(result);
5228    }
5229    let entries = parse_tree(&object.data)?;
5230    for entry in entries {
5231        let name = String::from_utf8_lossy(&entry.name).to_string();
5232        let path = if prefix.is_empty() {
5233            name
5234        } else {
5235            format!("{prefix}/{name}")
5236        };
5237        let child = match repo.odb.read(&entry.oid) {
5238            Ok(o) => o,
5239            Err(Error::ObjectNotFound(_)) => continue,
5240            Err(err) => return Err(err),
5241        };
5242        if child.kind == ObjectKind::Tree {
5243            result.extend(flatten_tree(repo, entry.oid, &path)?);
5244        } else {
5245            result.push((path, entry.oid));
5246        }
5247    }
5248    Ok(result)
5249}
5250
5251/// Compute merge bases between two commits.
5252pub fn merge_bases(
5253    repo: &Repository,
5254    a: ObjectId,
5255    b: ObjectId,
5256    first_parent_only: bool,
5257) -> Result<Vec<ObjectId>> {
5258    let mut graph = CommitGraph::new(repo, first_parent_only);
5259    let ancestors_a = walk_closure(&mut graph, &[a])?;
5260    let ancestors_b = walk_closure(&mut graph, &[b])?;
5261    let common: HashSet<ObjectId> = ancestors_a.intersection(&ancestors_b).copied().collect();
5262    if common.is_empty() {
5263        return Ok(Vec::new());
5264    }
5265    // Merge bases: common ancestors not dominated by other common ancestors
5266    let mut bases = Vec::new();
5267    for &c in &common {
5268        let is_dominated = common.iter().any(|&other| {
5269            if other == c {
5270                return false;
5271            }
5272            let other_anc = walk_closure(&mut graph, &[other]).unwrap_or_default();
5273            other_anc.contains(&c)
5274        });
5275        if !is_dominated {
5276            bases.push(c);
5277        }
5278    }
5279    if bases.is_empty() {
5280        let mut sorted: Vec<_> = common.into_iter().collect();
5281        sorted.sort_by_key(|b| std::cmp::Reverse(graph.committer_time(*b)));
5282        bases.push(sorted[0]);
5283    }
5284    Ok(bases)
5285}