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::path::Path;
12
13use crate::diff::zero_oid;
14use crate::error::{Error, Result};
15use crate::ident::{committer_unix_seconds_for_ordering, parse_signature_times};
16use crate::ignore::{parse_sparse_patterns_from_blob, path_matches_sparse_pattern_list};
17use crate::index::Index;
18use crate::objects::{parse_commit, parse_tag, parse_tree, ObjectId, ObjectKind};
19use crate::pack;
20use crate::patch_ids::compute_patch_id;
21use crate::reflog::{list_reflog_refs, read_reflog};
22use crate::refs;
23use crate::repo::Repository;
24use crate::rev_parse::{resolve_revision_for_range_end, resolve_treeish_path, split_treeish_spec};
25
26/// User-facing output mode for `rev-list`.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum OutputMode {
29    /// Print only object IDs.
30    OidOnly,
31    /// Print object ID followed by all parent IDs.
32    Parents,
33    /// Print a custom `%` placeholder format.
34    Format(String),
35}
36
37/// Behavior when reachable objects are missing.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum MissingAction {
40    /// Fail traversal when a referenced object is missing.
41    Error,
42    /// Continue traversal and report each missing object.
43    Print,
44    /// Continue traversal and silently ignore missing objects.
45    Allow,
46}
47
48/// Kind selector for `object:type=<kind>` filters.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum FilterObjectKind {
51    Blob,
52    Tree,
53    Commit,
54    Tag,
55}
56
57/// Object filter specification for `--filter=`.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum ObjectFilter {
60    /// `blob:none` — omit all blobs.
61    BlobNone,
62    /// `blob:limit=<n>` — omit blobs larger than `n` bytes.
63    BlobLimit(u64),
64    /// `tree:<depth>` — omit trees deeper than `depth`.
65    TreeDepth(u64),
66    /// `sparse:oid=<hex>` — sparse-checkout style path filter from a blob.
67    SparseOid(ObjectId),
68    /// `object:type=(blob|tree|commit|tag)` — keep only objects of that type.
69    ObjectType(FilterObjectKind),
70    /// `combine:<filter>+<filter>+…` — apply multiple filters.
71    Combine(Vec<ObjectFilter>),
72}
73
74impl ObjectFilter {
75    /// Parse a `--filter=<spec>` value.
76    pub fn parse(spec: &str) -> std::result::Result<Self, String> {
77        if spec == "blob:none" {
78            return Ok(ObjectFilter::BlobNone);
79        }
80        if let Some(rest) = spec.strip_prefix("blob:limit=") {
81            let bytes = parse_size_suffix(rest)
82                .ok_or_else(|| format!("invalid blob:limit value: {rest}"))?;
83            return Ok(ObjectFilter::BlobLimit(bytes));
84        }
85        if let Some(rest) = spec.strip_prefix("tree:") {
86            let depth: u64 = rest
87                .parse()
88                .map_err(|_| format!("invalid tree depth: {rest}"))?;
89            return Ok(ObjectFilter::TreeDepth(depth));
90        }
91        if let Some(rest) = spec.strip_prefix("object:type=") {
92            let kind = match rest {
93                "blob" => FilterObjectKind::Blob,
94                "tree" => FilterObjectKind::Tree,
95                "commit" => FilterObjectKind::Commit,
96                "tag" => FilterObjectKind::Tag,
97                "" => return Err("invalid object type".to_owned()),
98                _ => return Err(format!("invalid object type: {rest}")),
99            };
100            return Ok(ObjectFilter::ObjectType(kind));
101        }
102        if let Some(rest) = spec.strip_prefix("sparse:oid=") {
103            let oid = rest
104                .parse::<ObjectId>()
105                .map_err(|_| format!("invalid sparse:oid value: {rest}"))?;
106            return Ok(ObjectFilter::SparseOid(oid));
107        }
108        if let Some(rest) = spec.strip_prefix("combine:") {
109            let parts = split_combine(rest);
110            let mut filters = Vec::new();
111            for part in parts {
112                filters.push(ObjectFilter::parse(&part)?);
113            }
114            return Ok(ObjectFilter::Combine(filters));
115        }
116        Err(format!("unsupported filter spec: {spec}"))
117    }
118
119    /// Merge another `--filter` argument (Git joins multiple filters with AND).
120    #[must_use]
121    pub fn merge_with(self, other: Self) -> Self {
122        match (self, other) {
123            (ObjectFilter::Combine(mut a), ObjectFilter::Combine(mut b)) => {
124                a.append(&mut b);
125                ObjectFilter::Combine(a)
126            }
127            (ObjectFilter::Combine(mut a), b) => {
128                a.push(b);
129                ObjectFilter::Combine(a)
130            }
131            (a, ObjectFilter::Combine(mut b)) => {
132                let mut v = vec![a];
133                v.append(&mut b);
134                ObjectFilter::Combine(v)
135            }
136            (a, b) => ObjectFilter::Combine(vec![a, b]),
137        }
138    }
139
140    /// Check if a blob should be included given its size.
141    pub fn includes_blob(&self, size: u64) -> bool {
142        match self {
143            ObjectFilter::BlobNone => false,
144            ObjectFilter::BlobLimit(limit) => size <= *limit,
145            // Depth is applied via [`ObjectFilter::includes_blob_under_tree`]; this stays permissive
146            // for callers that only have a size (e.g. loose-object scans).
147            ObjectFilter::TreeDepth(_) => true,
148            ObjectFilter::SparseOid(_) => true,
149            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Blob,
150            ObjectFilter::Combine(filters) => filters.iter().all(|f| f.includes_blob(size)),
151        }
152    }
153
154    /// Whether a blob that lives directly under a tree at `parent_tree_depth` passes this filter.
155    ///
156    /// For `tree:<n>` filters, Git assigns blobs the traversal depth after entering the parent tree,
157    /// which matches `parent_tree_depth + 1` in our walk where the commit root tree is depth `0`.
158    #[must_use]
159    pub fn includes_blob_under_tree(&self, size: u64, parent_tree_depth: u64) -> bool {
160        match self {
161            ObjectFilter::BlobNone => false,
162            ObjectFilter::BlobLimit(limit) => size <= *limit,
163            ObjectFilter::TreeDepth(max_depth) => parent_tree_depth.saturating_add(1) < *max_depth,
164            ObjectFilter::SparseOid(_) => true,
165            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Blob,
166            ObjectFilter::Combine(filters) => filters
167                .iter()
168                .all(|f| f.includes_blob_under_tree(size, parent_tree_depth)),
169        }
170    }
171
172    /// Check if a tree at given depth should be included.
173    pub fn includes_tree(&self, depth: u64) -> bool {
174        match self {
175            ObjectFilter::BlobNone => true,
176            ObjectFilter::BlobLimit(_) => true,
177            ObjectFilter::TreeDepth(max_depth) => depth < *max_depth,
178            ObjectFilter::SparseOid(_) => true,
179            ObjectFilter::ObjectType(kind) => *kind == FilterObjectKind::Tree,
180            ObjectFilter::Combine(filters) => filters.iter().all(|f| f.includes_tree(depth)),
181        }
182    }
183
184    /// Whether a commit or tag object should appear in a flat object scan (e.g. `cat-file --batch-all-objects`).
185    pub fn includes_commit_or_tag_object(&self, kind: ObjectKind) -> bool {
186        let expected = match kind {
187            ObjectKind::Commit => Some(FilterObjectKind::Commit),
188            ObjectKind::Tag => Some(FilterObjectKind::Tag),
189            _ => None,
190        };
191        match self {
192            ObjectFilter::BlobNone | ObjectFilter::BlobLimit(_) => true,
193            ObjectFilter::TreeDepth(_) => true,
194            ObjectFilter::SparseOid(_) => true,
195            ObjectFilter::ObjectType(t) => expected == Some(*t),
196            ObjectFilter::Combine(filters) => filters
197                .iter()
198                .all(|f| f.includes_commit_or_tag_object(kind)),
199        }
200    }
201
202    /// True if `kind` / `size` pass this filter when enumerating a single object (no tree path).
203    pub fn includes_loose_object(&self, kind: ObjectKind, size: u64) -> bool {
204        match kind {
205            ObjectKind::Blob => self.includes_blob(size),
206            ObjectKind::Tree => self.includes_tree(0),
207            ObjectKind::Commit | ObjectKind::Tag => self.includes_commit_or_tag_object(kind),
208        }
209    }
210
211    /// Whether an object passes this filter for direct OID lookup (`git cat-file --filter`).
212    #[must_use]
213    pub fn passes_for_object(&self, kind: ObjectKind, size: usize) -> bool {
214        self.includes_loose_object(kind, size as u64)
215    }
216}
217
218/// Reachable object IDs enumerated the same way as `git rev-list --objects --no-object-names --all`,
219/// optionally with `--filter` and `--filter-provided-objects` (used by `git cat-file --batch-all-objects`).
220#[must_use]
221pub fn reachable_object_ids_for_cat_file(
222    repo: &Repository,
223    filter: Option<&ObjectFilter>,
224    filter_provided_objects: bool,
225) -> Result<Vec<ObjectId>> {
226    let opts = RevListOptions {
227        all_refs: true,
228        objects: true,
229        no_object_names: true,
230        quiet: true,
231        filter: filter.cloned(),
232        filter_provided_objects,
233        ..Default::default()
234    };
235    let result = rev_list(repo, &[], &[], &opts)?;
236    let mut set = BTreeSet::new();
237    for (i, oid) in result.commits.iter().enumerate() {
238        if result.objects_print_commit.get(i).copied().unwrap_or(true) {
239            set.insert(*oid);
240        }
241    }
242    for (oid, _) in &result.objects {
243        set.insert(*oid);
244    }
245    Ok(set.into_iter().collect())
246}
247
248/// Objects matching `filter`, for `cat-file --batch-all-objects --filter` (same set as
249/// `rev-list --objects --all --filter --filter-provided-objects`).
250#[must_use]
251pub fn object_ids_for_cat_file_filtered(
252    repo: &Repository,
253    filter: &ObjectFilter,
254) -> Result<Vec<ObjectId>> {
255    reachable_object_ids_for_cat_file(repo, Some(filter), true)
256}
257
258/// Parse a size with optional k/m/g suffix.
259fn parse_size_suffix(s: &str) -> Option<u64> {
260    let s = s.trim();
261    if s.is_empty() {
262        return None;
263    }
264    let (num_str, multiplier) = match s.as_bytes().last()? {
265        b'k' | b'K' => (&s[..s.len() - 1], 1024u64),
266        b'm' | b'M' => (&s[..s.len() - 1], 1024 * 1024),
267        b'g' | b'G' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
268        _ => (s, 1u64),
269    };
270    let num: u64 = num_str.parse().ok()?;
271    Some(num * multiplier)
272}
273
274/// Split a combine filter spec on `+`, handling URL encoding.
275fn split_combine(spec: &str) -> Vec<String> {
276    let mut parts = Vec::new();
277    let mut current = String::new();
278    let chars = spec.chars().peekable();
279    for ch in chars {
280        if ch == '+' {
281            if !current.is_empty() {
282                parts.push(url_decode(&current));
283                current.clear();
284            }
285        } else {
286            current.push(ch);
287        }
288    }
289    if !current.is_empty() {
290        parts.push(url_decode(&current));
291    }
292    parts
293}
294
295/// Simple URL percent-decoding.
296fn url_decode(s: &str) -> String {
297    let mut result = String::new();
298    let mut chars = s.chars();
299    while let Some(ch) = chars.next() {
300        if ch == '%' {
301            let hi = chars.next().unwrap_or('0');
302            let lo = chars.next().unwrap_or('0');
303            let byte = u8::from_str_radix(&format!("{hi}{lo}"), 16).unwrap_or(b'?');
304            result.push(byte as char);
305        } else {
306            result.push(ch);
307        }
308    }
309    result
310}
311
312/// Ordering mode for commit output.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum OrderingMode {
315    /// Reverse-chronological by commit date.
316    Default,
317    /// Topological ordering with date tie-breaks.
318    Topo,
319    /// Date-order variant (same constraints as topo for this subset).
320    Date,
321}
322
323/// Parsed and normalized options for rev-list traversal.
324#[derive(Debug, Clone)]
325pub struct RevListOptions {
326    /// Include all refs (`--all`) as positive tips.
327    pub all_refs: bool,
328    /// Follow only first parent when walking merges.
329    pub first_parent: bool,
330    /// Enable ancestry-path filtering.
331    pub ancestry_path: bool,
332    /// Optional explicit ancestry-path pivot commits.
333    pub ancestry_path_bottoms: Vec<ObjectId>,
334    /// Keep only decorated commits after traversal.
335    pub simplify_by_decoration: bool,
336    /// Commit output mode.
337    pub output_mode: OutputMode,
338    /// Suppress commit output.
339    pub quiet: bool,
340    /// Print only final count.
341    pub count: bool,
342    /// Skip N commits from selected list.
343    pub skip: usize,
344    /// Optional maximum selected commits.
345    pub max_count: Option<usize>,
346    /// Ordering strategy.
347    pub ordering: OrderingMode,
348    /// Reverse selected output order.
349    pub reverse: bool,
350    /// List reachable objects (trees, blobs) in addition to commits.
351    pub objects: bool,
352    /// Suppress object path names in --objects output.
353    pub no_object_names: bool,
354    /// Show boundary commits with `-` prefix.
355    pub boundary: bool,
356    /// Show left/right markers for symmetric diff.
357    pub left_right: bool,
358    /// Filter to left-only commits in symmetric diff.
359    pub left_only: bool,
360    /// Filter to right-only commits in symmetric diff.
361    pub right_only: bool,
362    /// Cherry-mark equivalent commits with `=` instead of `+`.
363    pub cherry_mark: bool,
364    /// Cherry-pick: omit equivalent commits from output.
365    pub cherry_pick: bool,
366    /// Minimum number of parents a commit must have to be included.
367    pub min_parents: Option<usize>,
368    /// Maximum number of parents a commit may have to be included.
369    pub max_parents: Option<usize>,
370    /// Symmetric-diff left OID (set by caller when A...B is used).
371    pub symmetric_left: Option<ObjectId>,
372    /// Symmetric-diff right OID (set by caller when A...B is used).
373    pub symmetric_right: Option<ObjectId>,
374    /// Path filters (files after `--`).
375    pub paths: Vec<String>,
376    /// Show full history (don't simplify) for path-limited walks.
377    pub full_history: bool,
378    /// Sparse mode: don't prune non-matching commits.
379    pub sparse: bool,
380    /// Object filter for `--filter=<spec>`.
381    pub filter: Option<ObjectFilter>,
382    /// When set with `--filter`, explicitly given revision objects are filtered too.
383    pub filter_provided_objects: bool,
384    /// Print omitted objects prefixed with `~`.
385    pub filter_print_omitted: bool,
386    /// Emit objects interleaved with their introducing commit.
387    pub in_commit_order: bool,
388    /// Exclude objects in `.keep` pack files.
389    pub no_kept_objects: bool,
390    /// Behavior when referenced objects are missing.
391    pub missing_action: MissingAction,
392    /// When set with `--objects`, omit path names from non-commit object lines (bitmap-style output).
393    pub use_bitmap_index: bool,
394    /// When set with `--objects`, list only objects not present in any pack file.
395    pub unpacked_only: bool,
396    /// With `--use-bitmap-index`, emit OID-only object lines (no paths / trailing space) for filters
397    /// that match Git's bitmap object formatting.
398    pub bitmap_oid_only_objects: bool,
399    /// Exclude commits with committer date strictly after this Unix timestamp (`--until` / `--before`).
400    pub until_cutoff: Option<i64>,
401    /// Exclude commits with committer date strictly before this Unix timestamp (`--since` / `--after`).
402    pub since_cutoff: Option<i64>,
403    /// Include OIDs from all reflogs as extra commit tips (`git pack-objects --reflog`).
404    pub include_reflog_entries: bool,
405    /// Include blob OIDs from the index as object roots (`git pack-objects --indexed-objects`).
406    pub include_indexed_objects: bool,
407}
408
409impl Default for RevListOptions {
410    fn default() -> Self {
411        Self {
412            all_refs: false,
413            first_parent: false,
414            ancestry_path: false,
415            ancestry_path_bottoms: Vec::new(),
416            simplify_by_decoration: false,
417            output_mode: OutputMode::OidOnly,
418            quiet: false,
419            count: false,
420            skip: 0,
421            max_count: None,
422            ordering: OrderingMode::Default,
423            reverse: false,
424            objects: false,
425            no_object_names: false,
426            boundary: false,
427            left_right: false,
428            left_only: false,
429            right_only: false,
430            cherry_mark: false,
431            cherry_pick: false,
432            min_parents: None,
433            max_parents: None,
434            symmetric_left: None,
435            symmetric_right: None,
436            paths: Vec::new(),
437            full_history: false,
438            sparse: false,
439            filter: None,
440            filter_provided_objects: false,
441            filter_print_omitted: false,
442            in_commit_order: false,
443            no_kept_objects: false,
444            missing_action: MissingAction::Error,
445            use_bitmap_index: false,
446            unpacked_only: false,
447            bitmap_oid_only_objects: false,
448            until_cutoff: None,
449            since_cutoff: None,
450            include_reflog_entries: false,
451            include_indexed_objects: false,
452        }
453    }
454}
455
456/// Final commit selection result.
457#[derive(Debug, Clone)]
458pub struct RevListResult {
459    /// Selected commits in final output order, after skip/max/reverse.
460    pub commits: Vec<ObjectId>,
461    /// Reachable non-commit objects when `--objects` is active.
462    /// Each entry is `(oid, optional_path)`.
463    pub objects: Vec<(ObjectId, String)>,
464    /// Objects omitted by `--filter` (for `--filter-print-omitted`).
465    pub omitted_objects: Vec<ObjectId>,
466    /// Referenced objects missing from the object database.
467    pub missing_objects: Vec<ObjectId>,
468    /// Boundary commits (excluded parents shown with `-` prefix).
469    pub boundary_commits: Vec<ObjectId>,
470    /// For `--left-right`: mapping commit OID -> true=left, false=right.
471    pub left_right_map: HashMap<ObjectId, bool>,
472    /// For `--cherry-mark`: set of commits that are equivalent (patch-id match).
473    pub cherry_equivalent: HashSet<ObjectId>,
474    /// Per-commit object counts (parallel to `commits`) for `--in-commit-order`.
475    /// When non-empty, `objects[sum(counts[..i])..sum(counts[..=i])]` are the objects
476    /// introduced by `commits[i]`.
477    pub per_commit_object_counts: Vec<usize>,
478    /// Commit OIDs given as positive revision tips (for Git `USER_GIVEN` / filter edge cases).
479    pub object_walk_tips: Vec<ObjectId>,
480    /// When `--objects` is active, whether to print the commit line before that commit's objects.
481    /// Aligns with Git marking user-given tips vs `NOT_USER_GIVEN` commits in list-objects.
482    pub objects_print_commit: Vec<bool>,
483    /// When `--objects` is active and not `--in-commit-order`, objects grouped per commit walk plus
484    /// a final segment for explicit `object_roots` (length `commits.len() + 1`).
485    pub object_segments: Vec<Vec<(ObjectId, String)>>,
486    /// True when `--use-bitmap-index --objects` should format trees/blobs as bare OIDs (no paths).
487    pub bitmap_object_format: bool,
488}
489
490/// Resolve and walk revisions for the requested options.
491///
492/// # Parameters
493///
494/// - `repo` - repository used for ref/object lookup.
495/// - `positive_specs` - positive revision tokens (e.g. `HEAD`, `A..B` rhs).
496/// - `negative_specs` - negative revision tokens (`^A`, `A..B` lhs).
497/// - `options` - traversal and output selection options.
498///
499/// # Errors
500///
501/// Returns [`Error::ObjectNotFound`] / [`Error::InvalidRef`] for bad revision
502/// specs and [`Error::CorruptObject`] for non-commit or malformed commit data.
503pub fn rev_list(
504    repo: &Repository,
505    positive_specs: &[String],
506    negative_specs: &[String],
507    options: &RevListOptions,
508) -> Result<RevListResult> {
509    let mut graph = CommitGraph::new(repo, options.first_parent);
510
511    let (mut include, object_roots) = if options.objects {
512        resolve_specs_for_objects(repo, positive_specs)?
513    } else {
514        (resolve_specs(repo, positive_specs)?, Vec::new())
515    };
516    let exclude = resolve_specs(repo, negative_specs)?;
517
518    if options.all_refs {
519        include.extend(all_ref_tips(repo)?);
520    }
521
522    if options.objects && options.include_reflog_entries {
523        include.extend(reflog_commit_tips(repo)?);
524    }
525
526    let mut index_blob_roots: Vec<RootObject> = Vec::new();
527    if options.objects && options.include_indexed_objects
528        && repo.work_tree.is_some() {
529            let index_path = repo.git_dir.join("index");
530            if index_path.is_file() {
531                let idx = Index::load(&index_path)?;
532                for e in &idx.entries {
533                    if e.stage() != 0 {
534                        continue;
535                    }
536                    let path_str = String::from_utf8_lossy(&e.path).into_owned();
537                    index_blob_roots.push(RootObject {
538                        oid: e.oid,
539                        input: format!(":{path_str}"),
540                        expected_kind: Some(ExpectedObjectKind::Blob),
541                        root_path: Some(path_str),
542                    });
543                }
544            }
545        }
546
547    let object_roots = if index_blob_roots.is_empty() {
548        object_roots
549    } else {
550        let mut merged = object_roots;
551        merged.extend(index_blob_roots);
552        merged
553    };
554
555    if include.is_empty() && object_roots.is_empty() {
556        return Err(Error::InvalidRef("no revisions specified".to_owned()));
557    }
558
559    let object_walk_tip_commits: Vec<ObjectId> = if options.objects {
560        include.clone()
561    } else {
562        Vec::new()
563    };
564
565    let (mut included, _discovery_order) = if include.is_empty() {
566        (HashSet::new(), Vec::new())
567    } else {
568        walk_closure_ordered(&mut graph, &include)?
569    };
570    let excluded = if exclude.is_empty() {
571        HashSet::new()
572    } else {
573        walk_closure(&mut graph, &exclude)?
574    };
575    included.retain(|oid| !excluded.contains(oid));
576
577    if options.simplify_by_decoration {
578        let decorated = all_ref_tips(repo)?;
579        included.retain(|oid| decorated.contains(oid));
580    }
581
582    if options.ancestry_path {
583        let mut bottoms = options.ancestry_path_bottoms.clone();
584        if bottoms.is_empty() {
585            bottoms.extend(exclude.iter().copied());
586        }
587        if bottoms.is_empty() {
588            return Err(Error::InvalidRef(
589                "--ancestry-path requires a range with excluded tips".to_owned(),
590            ));
591        }
592        limit_to_ancestry(&mut graph, &mut included, &bottoms)?;
593    }
594
595    // Filter by parent count (--merges, --no-merges, --min-parents, --max-parents)
596    if options.min_parents.is_some() || options.max_parents.is_some() {
597        let min_p = options.min_parents.unwrap_or(0);
598        let max_p = options.max_parents.unwrap_or(usize::MAX);
599        included.retain(|oid| {
600            let count = graph.parents_of(*oid).map(|p| p.len()).unwrap_or(0);
601            count >= min_p && count <= max_p
602        });
603    }
604
605    let mut ordered = match options.ordering {
606        OrderingMode::Default => {
607            let tips: Vec<ObjectId> = include
608                .iter()
609                .copied()
610                .filter(|oid| included.contains(oid))
611                .collect();
612            date_order_walk(&mut graph, &tips, &included)?
613        }
614        OrderingMode::Topo | OrderingMode::Date => topo_sort(&mut graph, &included)?,
615    };
616
617    // Path filtering: keep only commits that modify given paths
618    if !options.paths.is_empty() {
619        let paths = &options.paths;
620        ordered.retain(|oid| {
621            commit_touches_paths(
622                repo,
623                &mut graph,
624                *oid,
625                paths,
626                options.full_history,
627                options.sparse,
628            )
629            .unwrap_or(false)
630        });
631    }
632
633    // Left-right classification for symmetric diffs
634    let mut left_right_map = HashMap::new();
635    if options.left_right
636        || options.left_only
637        || options.right_only
638        || options.cherry_mark
639        || options.cherry_pick
640    {
641        if let (Some(left_oid), Some(right_oid)) = (options.symmetric_left, options.symmetric_right)
642        {
643            // Match Git's `SYMMETRIC_LEFT` / right-only classification (`revision.c`): a commit is
644            // "left" iff it is reachable from the left tip but not from the right tip, and vice
645            // versa.  Using plain set intersection incorrectly labels the shared spine as "right"
646            // only, which breaks `--cherry-pick` on `A...B` (t3419-rebase-patch-id).
647            let left_reach = walk_closure(&mut graph, &[left_oid])?;
648            let right_reach = walk_closure(&mut graph, &[right_oid])?;
649            for &oid in &ordered {
650                let from_left = left_reach.contains(&oid);
651                let from_right = right_reach.contains(&oid);
652                if from_left && !from_right {
653                    left_right_map.insert(oid, true);
654                } else if from_right && !from_left {
655                    left_right_map.insert(oid, false);
656                } else {
657                    left_right_map.insert(oid, false);
658                }
659            }
660        }
661    }
662
663    // Cherry-pick / cherry-mark: match commits by Git-compatible patch-id (see `git revision.c`
664    // `cherry_pick_list`, used by `git rebase` todo generation).
665    let mut cherry_equivalent = HashSet::new();
666    if options.cherry_pick || options.cherry_mark {
667        let left_commits: Vec<_> = ordered
668            .iter()
669            .filter(|o| left_right_map.get(o) == Some(&true))
670            .copied()
671            .collect();
672        let right_commits: Vec<_> = ordered
673            .iter()
674            .filter(|o| left_right_map.get(o) == Some(&false))
675            .copied()
676            .collect();
677        let left_first = !left_commits.is_empty()
678            && !right_commits.is_empty()
679            && left_commits.len() < right_commits.len();
680
681        let mut by_patch: HashMap<ObjectId, ObjectId> = HashMap::new();
682        if left_first {
683            for oid in &left_commits {
684                if let Ok(Some(pid)) = compute_patch_id(&repo.odb, oid) {
685                    by_patch.entry(pid).or_insert(*oid);
686                }
687            }
688            for oid in &right_commits {
689                if let Ok(Some(pid)) = compute_patch_id(&repo.odb, oid) {
690                    if let Some(&other) = by_patch.get(&pid) {
691                        cherry_equivalent.insert(*oid);
692                        cherry_equivalent.insert(other);
693                    }
694                }
695            }
696        } else {
697            for oid in &right_commits {
698                if let Ok(Some(pid)) = compute_patch_id(&repo.odb, oid) {
699                    by_patch.entry(pid).or_insert(*oid);
700                }
701            }
702            for oid in &left_commits {
703                if let Ok(Some(pid)) = compute_patch_id(&repo.odb, oid) {
704                    if let Some(&other) = by_patch.get(&pid) {
705                        cherry_equivalent.insert(*oid);
706                        cherry_equivalent.insert(other);
707                    }
708                }
709            }
710        }
711    }
712
713    // Filter left-only / right-only
714    if options.left_only {
715        ordered.retain(|oid| left_right_map.get(oid) == Some(&true));
716    }
717    if options.right_only {
718        ordered.retain(|oid| left_right_map.get(oid) == Some(&false));
719    }
720
721    // Cherry-pick: remove equivalent commits
722    if options.cherry_pick {
723        ordered.retain(|oid| !cherry_equivalent.contains(oid));
724    }
725
726    if options.until_cutoff.is_some() || options.since_cutoff.is_some() {
727        let until = options.until_cutoff;
728        let since = options.since_cutoff;
729        ordered.retain(|oid| {
730            let ts = graph.committer_time(*oid);
731            if let Some(u) = until {
732                if ts > u {
733                    return false;
734                }
735            }
736            if let Some(s) = since {
737                if ts < s {
738                    return false;
739                }
740            }
741            true
742        });
743    }
744
745    if options.skip > 0 {
746        ordered = ordered.into_iter().skip(options.skip).collect();
747    }
748    if let Some(max_count) = options.max_count {
749        ordered.truncate(max_count);
750    }
751    if options.reverse {
752        ordered.reverse();
753    }
754
755    // Collect boundary commits: parents of included commits that are in the excluded set
756    let boundary_commits = if options.boundary {
757        let included_set: HashSet<ObjectId> = ordered.iter().copied().collect();
758        let mut boundary = Vec::new();
759        let mut boundary_seen = HashSet::new();
760        for &oid in &ordered {
761            if let Ok(parents) = graph.parents_of(oid).map(|p| p.to_vec()) {
762                for parent in parents {
763                    if !included_set.contains(&parent) && boundary_seen.insert(parent) {
764                        boundary.push(parent);
765                    }
766                }
767            }
768        }
769        boundary
770    } else {
771        Vec::new()
772    };
773
774    // Filter kept objects when --no-kept-objects is set
775    let kept_set = if options.no_kept_objects {
776        kept_object_ids(repo).unwrap_or_default()
777    } else {
778        HashSet::new()
779    };
780
781    if options.no_kept_objects {
782        ordered.retain(|oid| !kept_set.contains(oid));
783    }
784
785    if options.unpacked_only {
786        let packed = packed_object_set(repo);
787        ordered.retain(|oid| !packed.contains(oid));
788    }
789
790    let commit_tips_set: HashSet<ObjectId> = object_walk_tip_commits.iter().copied().collect();
791    let objects_print_commit: Vec<bool> = if options.objects {
792        ordered
793            .iter()
794            .map(|&c| {
795                let user_given = !options.filter_provided_objects && commit_tips_set.contains(&c);
796                user_given || filter_shows_commit_line_when_not_user_given(options.filter.as_ref())
797            })
798            .collect()
799    } else {
800        Vec::new()
801    };
802
803    let sparse_lines = sparse_oid_lines_from_filter(repo, options.filter.as_ref());
804    let skip_trees = skip_tree_descent_for_object_type_filter(options.filter.as_ref());
805    let bitmap_object_format = options.objects
806        && options.use_bitmap_index
807        && (options.bitmap_oid_only_objects || !object_roots.is_empty() || options.unpacked_only);
808    let omit_object_paths = bitmap_object_format;
809    let packed_set = if options.objects && options.unpacked_only {
810        Some(packed_object_set(repo))
811    } else {
812        None
813    };
814
815    // Collect reachable objects if --objects
816    let (objects, omitted_objects, missing_objects, per_commit_object_counts, object_segments) =
817        if options.objects {
818            let filter_provided = options.filter_provided_objects;
819            let (mut objs, omit, miss, counts, segments) = if options.in_commit_order {
820                let (o, om, mi, c) = collect_reachable_objects_in_commit_order(
821                    repo,
822                    &mut graph,
823                    &ordered,
824                    &object_roots,
825                    options.filter.as_ref(),
826                    filter_provided,
827                    options.missing_action,
828                    sparse_lines.as_deref(),
829                    skip_trees,
830                    omit_object_paths,
831                    packed_set.as_ref(),
832                )?;
833                (o, om, mi, c, Vec::new())
834            } else {
835                let (o, om, mi, seg) = collect_reachable_objects_segmented(
836                    repo,
837                    &mut graph,
838                    &ordered,
839                    &object_roots,
840                    options.filter.as_ref(),
841                    filter_provided,
842                    options.missing_action,
843                    sparse_lines.as_deref(),
844                    skip_trees,
845                    omit_object_paths,
846                    packed_set.as_ref(),
847                )?;
848                (o, om, mi, Vec::new(), seg)
849            };
850            if options.no_kept_objects {
851                objs.retain(|(oid, _)| !kept_set.contains(oid));
852            }
853            (objs, omit, miss, counts, segments)
854        } else {
855            (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new())
856        };
857
858    Ok(RevListResult {
859        commits: ordered,
860        objects,
861        omitted_objects,
862        missing_objects,
863        boundary_commits,
864        left_right_map,
865        cherry_equivalent,
866        per_commit_object_counts,
867        object_walk_tips: object_walk_tip_commits,
868        objects_print_commit,
869        object_segments,
870        bitmap_object_format,
871    })
872}
873
874/// Parse a raw revision token into positive and negative specs.
875///
876/// Supports:
877/// - `<a>..<b>` => negative `<a>`, positive `<b>`
878/// - `^<rev>` => negative `<rev>`
879/// - `<rev>` => positive `<rev>`
880#[must_use]
881pub fn split_revision_token(token: &str) -> (Vec<String>, Vec<String>) {
882    if let Some((lhs, rhs)) = crate::rev_parse::split_double_dot_range(token) {
883        let positive = if rhs.is_empty() {
884            "HEAD".to_owned()
885        } else {
886            rhs.to_owned()
887        };
888        let negative = if lhs.is_empty() {
889            "HEAD".to_owned()
890        } else {
891            lhs.to_owned()
892        };
893        return (vec![positive], vec![negative]);
894    }
895    if let Some(rest) = token.strip_prefix('^') {
896        return (Vec::new(), vec![rest.to_owned()]);
897    }
898    (vec![token.to_owned()], Vec::new())
899}
900
901fn ansi_color_from_name(name: &str) -> String {
902    match name {
903        "red" => "\x1b[31m".to_owned(),
904        "green" => "\x1b[32m".to_owned(),
905        "yellow" => "\x1b[33m".to_owned(),
906        "blue" => "\x1b[34m".to_owned(),
907        "magenta" => "\x1b[35m".to_owned(),
908        "cyan" => "\x1b[36m".to_owned(),
909        "white" => "\x1b[37m".to_owned(),
910        "bold" => "\x1b[1m".to_owned(),
911        "dim" => "\x1b[2m".to_owned(),
912        "ul" | "underline" => "\x1b[4m".to_owned(),
913        "blink" => "\x1b[5m".to_owned(),
914        "reverse" => "\x1b[7m".to_owned(),
915        "reset" => "\x1b[m".to_owned(),
916        _ => String::new(),
917    }
918}
919
920fn color_name_to_code(name: &str) -> Option<u8> {
921    match name {
922        "black" => Some(0),
923        "red" => Some(1),
924        "green" => Some(2),
925        "yellow" => Some(3),
926        "blue" => Some(4),
927        "magenta" => Some(5),
928        "cyan" => Some(6),
929        "white" => Some(7),
930        "default" => Some(9),
931        _ => None,
932    }
933}
934
935fn ansi_color_from_spec(spec: &str) -> String {
936    if spec == "reset" {
937        return "\x1b[m".to_owned();
938    }
939    let mut codes = Vec::new();
940    let mut fg_set = false;
941    for part in spec.split_whitespace() {
942        match part {
943            "bold" => codes.push("1".to_owned()),
944            "dim" => codes.push("2".to_owned()),
945            "italic" => codes.push("3".to_owned()),
946            "ul" | "underline" => codes.push("4".to_owned()),
947            "blink" => codes.push("5".to_owned()),
948            "reverse" => codes.push("7".to_owned()),
949            "strike" => codes.push("9".to_owned()),
950            "nobold" | "nodim" => codes.push("22".to_owned()),
951            "noitalic" => codes.push("23".to_owned()),
952            "noul" | "nounderline" => codes.push("24".to_owned()),
953            "noblink" => codes.push("25".to_owned()),
954            "noreverse" => codes.push("27".to_owned()),
955            "nostrike" => codes.push("29".to_owned()),
956            _ => {
957                if let Some(code) = color_name_to_code(part) {
958                    if !fg_set {
959                        codes.push(format!("{}", 30 + code));
960                        fg_set = true;
961                    } else {
962                        codes.push(format!("{}", 40 + code));
963                    }
964                }
965            }
966        }
967    }
968    if codes.is_empty() {
969        String::new()
970    } else {
971        format!("\x1b[{}m", codes.join(";"))
972    }
973}
974
975fn format_relative_date(diff: i64) -> String {
976    if diff < 0 {
977        "in the future".to_owned()
978    } else if diff < 60 {
979        format!("{} seconds ago", diff)
980    } else if diff < 3600 {
981        let m = diff / 60;
982        if m == 1 {
983            "1 minute ago".to_owned()
984        } else {
985            format!("{m} minutes ago")
986        }
987    } else if diff < 86400 {
988        let h = diff / 3600;
989        if h == 1 {
990            "1 hour ago".to_owned()
991        } else {
992            format!("{h} hours ago")
993        }
994    } else if diff < 86400 * 30 {
995        let d = diff / 86400;
996        if d == 1 {
997            "1 day ago".to_owned()
998        } else {
999            format!("{d} days ago")
1000        }
1001    } else if diff < 86400 * 365 {
1002        let months = diff / (86400 * 30);
1003        if months == 1 {
1004            "1 month ago".to_owned()
1005        } else {
1006            format!("{months} months ago")
1007        }
1008    } else {
1009        let years = diff / (86400 * 365);
1010        if years == 1 {
1011            "1 year ago".to_owned()
1012        } else {
1013            format!("{years} years ago")
1014        }
1015    }
1016}
1017
1018/// Render one commit according to the selected output mode.
1019///
1020/// # Errors
1021///
1022/// Returns object decode errors when commit metadata is required.
1023pub fn render_commit(
1024    repo: &Repository,
1025    oid: ObjectId,
1026    mode: &OutputMode,
1027    abbrev_len: usize,
1028) -> Result<String> {
1029    render_commit_with_color(repo, oid, mode, abbrev_len, false)
1030}
1031
1032/// Render one commit, optionally with ANSI color for `%C` placeholders.
1033pub fn render_commit_with_color(
1034    repo: &Repository,
1035    oid: ObjectId,
1036    mode: &OutputMode,
1037    abbrev_len: usize,
1038    use_color: bool,
1039) -> Result<String> {
1040    match mode {
1041        OutputMode::OidOnly => Ok(format!("{oid}")),
1042        OutputMode::Parents => {
1043            let mut out = format!("{oid}");
1044            let commit = load_commit(repo, oid)?;
1045            for parent in commit.parents {
1046                out.push(' ');
1047                out.push_str(&parent.to_hex());
1048            }
1049            Ok(out)
1050        }
1051        OutputMode::Format(fmt) => {
1052            let commit = load_commit(repo, oid)?;
1053            let subject = commit.message.lines().next().unwrap_or_default();
1054            let hex = oid.to_hex();
1055
1056            // Handle named pretty formats
1057            match fmt.as_str() {
1058                "oneline" => {
1059                    return Ok(format!("{} {}", hex, subject));
1060                }
1061                "short" => {
1062                    fn fmt_ident(ident: &str) -> String {
1063                        let name = if let Some(bracket) = ident.find('<') {
1064                            ident[..bracket].trim()
1065                        } else {
1066                            ident.trim()
1067                        };
1068                        let email = if let Some(start) = ident.find('<') {
1069                            if let Some(end) = ident.find('>') {
1070                                &ident[start..=end]
1071                            } else {
1072                                ""
1073                            }
1074                        } else {
1075                            ""
1076                        };
1077                        format!("{} {}", name, email)
1078                    }
1079                    let mut out = String::new();
1080                    out.push_str(&format!("Author: {}\n", fmt_ident(&commit.author)));
1081                    out.push('\n');
1082                    out.push_str(&format!("    {}\n", subject));
1083                    out.push('\n');
1084                    return Ok(out);
1085                }
1086                "medium" => {
1087                    fn extract_ident_display(ident: &str) -> String {
1088                        let name = if let Some(bracket) = ident.find('<') {
1089                            ident[..bracket].trim()
1090                        } else {
1091                            ident.trim()
1092                        };
1093                        let email = if let Some(start) = ident.find('<') {
1094                            if let Some(end) = ident.find('>') {
1095                                &ident[start..=end]
1096                            } else {
1097                                ""
1098                            }
1099                        } else {
1100                            ""
1101                        };
1102                        format!("{} {}", name, email)
1103                    }
1104                    fn format_default_date(ident: &str) -> String {
1105                        let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
1106                        if parts.len() < 2 {
1107                            return String::new();
1108                        }
1109                        let ts_str = parts[1];
1110                        let offset_str = parts[0];
1111                        let ts: i64 = match ts_str.parse() {
1112                            Ok(v) => v,
1113                            Err(_) => return format!("{ts_str} {offset_str}"),
1114                        };
1115                        let tz_bytes = offset_str.as_bytes();
1116                        let tz_secs: i64 = if tz_bytes.len() >= 5 {
1117                            let sign = if tz_bytes[0] == b'-' { -1i64 } else { 1i64 };
1118                            let h: i64 = offset_str[1..3].parse().unwrap_or(0);
1119                            let m: i64 = offset_str[3..5].parse().unwrap_or(0);
1120                            sign * (h * 3600 + m * 60)
1121                        } else {
1122                            0
1123                        };
1124                        let adjusted = ts + tz_secs;
1125                        let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1126                            .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1127                        let weekday = match dt.weekday() {
1128                            time::Weekday::Monday => "Mon",
1129                            time::Weekday::Tuesday => "Tue",
1130                            time::Weekday::Wednesday => "Wed",
1131                            time::Weekday::Thursday => "Thu",
1132                            time::Weekday::Friday => "Fri",
1133                            time::Weekday::Saturday => "Sat",
1134                            time::Weekday::Sunday => "Sun",
1135                        };
1136                        let month = match dt.month() {
1137                            time::Month::January => "Jan",
1138                            time::Month::February => "Feb",
1139                            time::Month::March => "Mar",
1140                            time::Month::April => "Apr",
1141                            time::Month::May => "May",
1142                            time::Month::June => "Jun",
1143                            time::Month::July => "Jul",
1144                            time::Month::August => "Aug",
1145                            time::Month::September => "Sep",
1146                            time::Month::October => "Oct",
1147                            time::Month::November => "Nov",
1148                            time::Month::December => "Dec",
1149                        };
1150                        format!(
1151                            "{} {} {} {:02}:{:02}:{:02} {} {}",
1152                            weekday,
1153                            month,
1154                            dt.day(),
1155                            dt.hour(),
1156                            dt.minute(),
1157                            dt.second(),
1158                            dt.year(),
1159                            offset_str
1160                        )
1161                    }
1162                    let mut out = String::new();
1163                    out.push_str(&format!(
1164                        "Author: {}\n",
1165                        extract_ident_display(&commit.author)
1166                    ));
1167                    out.push_str(&format!(
1168                        "Date:   {}\n",
1169                        format_default_date(&commit.author)
1170                    ));
1171                    out.push('\n');
1172                    for line in commit.message.lines() {
1173                        out.push_str(&format!("    {}\n", line));
1174                    }
1175                    return Ok(out);
1176                }
1177                _ => {}
1178            }
1179
1180            let raw_fmt = if let Some(t) = fmt.strip_prefix("format:") {
1181                t
1182            } else if let Some(t) = fmt.strip_prefix("tformat:") {
1183                t
1184            } else {
1185                fmt.as_str()
1186            };
1187            // Body: everything after the first line (skip blank separator line)
1188            let body = {
1189                let mut lines = commit.message.lines();
1190                lines.next(); // skip subject
1191                              // Skip optional blank line after subject
1192                if let Some(blank) = lines.next() {
1193                    if blank.is_empty() {
1194                        lines.collect::<Vec<_>>().join("\n")
1195                    } else {
1196                        std::iter::once(blank)
1197                            .chain(lines)
1198                            .collect::<Vec<_>>()
1199                            .join("\n")
1200                    }
1201                } else {
1202                    String::new()
1203                }
1204            };
1205            let tree_hex = commit.tree.to_hex();
1206            let parent_hexes: Vec<String> = commit.parents.iter().map(|p| p.to_hex()).collect();
1207            let parent_abbrevs: Vec<String> = commit
1208                .parents
1209                .iter()
1210                .map(|p| {
1211                    let hex = p.to_hex();
1212                    let n = abbrev_len.clamp(4, 40).min(hex.len());
1213                    hex[..n].to_string()
1214                })
1215                .collect();
1216
1217            // Extract name/email components from ident strings
1218            fn extract_name(ident: &str) -> &str {
1219                if let Some(bracket) = ident.find('<') {
1220                    ident[..bracket].trim()
1221                } else {
1222                    ident.trim()
1223                }
1224            }
1225            fn extract_email(ident: &str) -> &str {
1226                if let Some(start) = ident.find('<') {
1227                    if let Some(end) = ident.find('>') {
1228                        return &ident[start + 1..end];
1229                    }
1230                }
1231                ""
1232            }
1233            fn extract_timestamp(ident: &str) -> String {
1234                match parse_signature_times(ident) {
1235                    Some(p) => p.unix_seconds.to_string(),
1236                    None => String::new(),
1237                }
1238            }
1239            fn weekday_str(dt: &time::OffsetDateTime) -> &'static str {
1240                match dt.weekday() {
1241                    time::Weekday::Monday => "Mon",
1242                    time::Weekday::Tuesday => "Tue",
1243                    time::Weekday::Wednesday => "Wed",
1244                    time::Weekday::Thursday => "Thu",
1245                    time::Weekday::Friday => "Fri",
1246                    time::Weekday::Saturday => "Sat",
1247                    time::Weekday::Sunday => "Sun",
1248                }
1249            }
1250            fn month_str(dt: &time::OffsetDateTime) -> &'static str {
1251                match dt.month() {
1252                    time::Month::January => "Jan",
1253                    time::Month::February => "Feb",
1254                    time::Month::March => "Mar",
1255                    time::Month::April => "Apr",
1256                    time::Month::May => "May",
1257                    time::Month::June => "Jun",
1258                    time::Month::July => "Jul",
1259                    time::Month::August => "Aug",
1260                    time::Month::September => "Sep",
1261                    time::Month::October => "Oct",
1262                    time::Month::November => "Nov",
1263                    time::Month::December => "Dec",
1264                }
1265            }
1266            fn extract_email_local(ident: &str) -> &str {
1267                let email = extract_email(ident);
1268                if let Some(at) = email.find('@') {
1269                    &email[..at]
1270                } else {
1271                    email
1272                }
1273            }
1274            fn extract_date_default(ident: &str) -> String {
1275                let Some(p) = parse_signature_times(ident) else {
1276                    return String::new();
1277                };
1278                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
1279                let adjusted = p.unix_seconds + p.tz_offset_secs;
1280                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1281                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1282                format!(
1283                    "{} {} {} {:02}:{:02}:{:02} {} {}",
1284                    weekday_str(&dt),
1285                    month_str(&dt),
1286                    dt.day(),
1287                    dt.hour(),
1288                    dt.minute(),
1289                    dt.second(),
1290                    dt.year(),
1291                    offset_str
1292                )
1293            }
1294            fn extract_date_rfc2822(ident: &str) -> String {
1295                let Some(p) = parse_signature_times(ident) else {
1296                    return String::new();
1297                };
1298                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
1299                let adjusted = p.unix_seconds + p.tz_offset_secs;
1300                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1301                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1302                format!(
1303                    "{}, {} {} {} {:02}:{:02}:{:02} {}",
1304                    weekday_str(&dt),
1305                    dt.day(),
1306                    month_str(&dt),
1307                    dt.year(),
1308                    dt.hour(),
1309                    dt.minute(),
1310                    dt.second(),
1311                    offset_str
1312                )
1313            }
1314            fn extract_date_short(ident: &str) -> String {
1315                let Some(p) = parse_signature_times(ident) else {
1316                    return String::new();
1317                };
1318                let adjusted = p.unix_seconds + p.tz_offset_secs;
1319                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1320                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1321                format!("{:04}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day())
1322            }
1323            fn extract_date_iso(ident: &str) -> String {
1324                let Some(p) = parse_signature_times(ident) else {
1325                    return String::new();
1326                };
1327                let offset_str = ident.get(p.tz_hhmm_range.clone()).unwrap_or("+0000");
1328                let adjusted = p.unix_seconds + p.tz_offset_secs;
1329                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1330                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1331                format!(
1332                    "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {}",
1333                    dt.year(),
1334                    dt.month() as u8,
1335                    dt.day(),
1336                    dt.hour(),
1337                    dt.minute(),
1338                    dt.second(),
1339                    offset_str
1340                )
1341            }
1342
1343            // Alignment/truncation state for %<(N), %>(N), %><(N) directives
1344            #[derive(Clone, Copy)]
1345            enum Align {
1346                Left,
1347                Right,
1348                Center,
1349            }
1350            #[derive(Clone, Copy)]
1351            enum Trunc {
1352                None,
1353                Trunc,
1354                LTrunc,
1355                MTrunc,
1356            }
1357            struct ColSpec {
1358                width: usize,
1359                align: Align,
1360                trunc: Trunc,
1361            }
1362            fn apply_col(spec: &ColSpec, s: &str) -> String {
1363                let char_len = s.chars().count();
1364                if char_len > spec.width {
1365                    match spec.trunc {
1366                        Trunc::None => s.to_owned(),
1367                        Trunc::Trunc => {
1368                            let mut out: String =
1369                                s.chars().take(spec.width.saturating_sub(2)).collect();
1370                            out.push_str("..");
1371                            out
1372                        }
1373                        Trunc::LTrunc => {
1374                            let skip = char_len - spec.width + 2;
1375                            let mut out = String::from("..");
1376                            out.extend(s.chars().skip(skip));
1377                            out
1378                        }
1379                        Trunc::MTrunc => {
1380                            let keep = spec.width.saturating_sub(2);
1381                            let left_half = keep / 2;
1382                            let right_half = keep - left_half;
1383                            let mut out: String = s.chars().take(left_half).collect();
1384                            out.push_str("..");
1385                            out.extend(s.chars().skip(char_len - right_half));
1386                            out
1387                        }
1388                    }
1389                } else {
1390                    let pad = spec.width - char_len;
1391                    match spec.align {
1392                        Align::Left => {
1393                            let mut out = s.to_owned();
1394                            for _ in 0..pad {
1395                                out.push(' ');
1396                            }
1397                            out
1398                        }
1399                        Align::Right => {
1400                            let mut out = String::new();
1401                            for _ in 0..pad {
1402                                out.push(' ');
1403                            }
1404                            out.push_str(s);
1405                            out
1406                        }
1407                        Align::Center => {
1408                            let left = pad / 2;
1409                            let right = pad - left;
1410                            let mut out = String::new();
1411                            for _ in 0..left {
1412                                out.push(' ');
1413                            }
1414                            out.push_str(s);
1415                            for _ in 0..right {
1416                                out.push(' ');
1417                            }
1418                            out
1419                        }
1420                    }
1421                }
1422            }
1423            fn parse_col_spec(
1424                chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
1425                align: Align,
1426            ) -> Option<ColSpec> {
1427                // Consume '('
1428                if chars.peek() != Some(&'(') {
1429                    return None;
1430                }
1431                chars.next();
1432                let mut num_str = String::new();
1433                while let Some(&c) = chars.peek() {
1434                    if c.is_ascii_digit() {
1435                        num_str.push(c);
1436                        chars.next();
1437                    } else {
1438                        break;
1439                    }
1440                }
1441                let width: usize = num_str.parse().ok()?;
1442                let trunc = if chars.peek() == Some(&',') {
1443                    chars.next(); // consume comma
1444                    let mut mode = String::new();
1445                    while let Some(&c) = chars.peek() {
1446                        if c == ')' {
1447                            break;
1448                        }
1449                        mode.push(c);
1450                        chars.next();
1451                    }
1452                    match mode.as_str() {
1453                        "trunc" => Trunc::Trunc,
1454                        "ltrunc" => Trunc::LTrunc,
1455                        "mtrunc" => Trunc::MTrunc,
1456                        _ => Trunc::None,
1457                    }
1458                } else {
1459                    Trunc::None
1460                };
1461                // Consume ')'
1462                if chars.peek() == Some(&')') {
1463                    chars.next();
1464                }
1465                Some(ColSpec {
1466                    width,
1467                    align,
1468                    trunc,
1469                })
1470            }
1471
1472            let mut pending_col: Option<ColSpec> = None;
1473            let mut rendered = String::new();
1474            let mut chars = raw_fmt.chars().peekable();
1475            while let Some(ch) = chars.next() {
1476                if ch != '%' {
1477                    rendered.push(ch);
1478                    continue;
1479                }
1480                // Check for alignment directives: %<(...), %>(...), %><(...)
1481                if chars.peek() == Some(&'<') {
1482                    chars.next();
1483                    if let Some(spec) = parse_col_spec(&mut chars, Align::Left) {
1484                        pending_col = Some(spec);
1485                    }
1486                    continue;
1487                }
1488                if chars.peek() == Some(&'>') {
1489                    chars.next();
1490                    if chars.peek() == Some(&'<') {
1491                        chars.next(); // %><(...)
1492                        if let Some(spec) = parse_col_spec(&mut chars, Align::Center) {
1493                            pending_col = Some(spec);
1494                        }
1495                    } else if chars.peek() == Some(&'>') {
1496                        chars.next(); // %>>(...)
1497                        if let Some(spec) = parse_col_spec(&mut chars, Align::Right) {
1498                            pending_col = Some(spec);
1499                        }
1500                    } else if let Some(spec) = parse_col_spec(&mut chars, Align::Right) {
1501                        pending_col = Some(spec);
1502                    }
1503                    continue;
1504                }
1505
1506                // Helper macro-like: expand the placeholder, then apply pending_col
1507                let mut expanded = String::new();
1508                let target = if pending_col.is_some() {
1509                    &mut expanded
1510                } else {
1511                    &mut rendered
1512                };
1513                match chars.peek() {
1514                    Some('%') => {
1515                        chars.next();
1516                        target.push('%');
1517                    }
1518                    Some('H') => {
1519                        chars.next();
1520                        target.push_str(&oid.to_hex());
1521                    }
1522                    Some('h') => {
1523                        chars.next();
1524                        let hex = oid.to_hex();
1525                        let n = abbrev_len.clamp(4, 40).min(hex.len());
1526                        target.push_str(&hex[..n]);
1527                    }
1528                    Some('T') => {
1529                        chars.next();
1530                        target.push_str(&tree_hex);
1531                    }
1532                    Some('t') => {
1533                        chars.next();
1534                        let n = abbrev_len.clamp(4, 40).min(tree_hex.len());
1535                        target.push_str(&tree_hex[..n]);
1536                    }
1537                    Some('P') => {
1538                        chars.next();
1539                        target.push_str(&parent_hexes.join(" "));
1540                    }
1541                    Some('p') => {
1542                        chars.next();
1543                        target.push_str(&parent_abbrevs.join(" "));
1544                    }
1545                    Some('n') => {
1546                        chars.next();
1547                        target.push('\n');
1548                    }
1549                    Some('s') => {
1550                        chars.next();
1551                        target.push_str(subject);
1552                    }
1553                    Some('b') => {
1554                        chars.next();
1555                        target.push_str(&body);
1556                        if !body.is_empty() {
1557                            target.push('\n');
1558                        }
1559                    }
1560                    Some('B') => {
1561                        chars.next();
1562                        target.push_str(&commit.message);
1563                    }
1564                    Some('a') => {
1565                        chars.next();
1566                        match chars.next() {
1567                            Some('n') => target.push_str(extract_name(&commit.author)),
1568                            Some('N') => target.push_str(extract_name(&commit.author)),
1569                            Some('e') => target.push_str(extract_email(&commit.author)),
1570                            Some('E') => target.push_str(extract_email(&commit.author)),
1571                            Some('l') => target.push_str(extract_email_local(&commit.author)),
1572                            Some('d') => target.push_str(&extract_date_default(&commit.author)),
1573                            Some('D') => target.push_str(&extract_date_rfc2822(&commit.author)),
1574                            Some('t') => target.push_str(&extract_timestamp(&commit.author)),
1575                            Some('s') => target.push_str(&extract_date_short(&commit.author)),
1576                            Some('i') => target.push_str(&extract_date_iso(&commit.author)),
1577                            Some('I') => {
1578                                let Some(p) = parse_signature_times(&commit.author) else {
1579                                    break;
1580                                };
1581                                let adjusted = p.unix_seconds + p.tz_offset_secs;
1582                                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1583                                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1584                                let sign_ch = if p.tz_offset_secs >= 0 { '+' } else { '-' };
1585                                let abs_off = p.tz_offset_secs.unsigned_abs();
1586                                target.push_str(&format!(
1587                                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
1588                                    dt.year(),
1589                                    dt.month() as u8,
1590                                    dt.day(),
1591                                    dt.hour(),
1592                                    dt.minute(),
1593                                    dt.second(),
1594                                    sign_ch,
1595                                    abs_off / 3600,
1596                                    (abs_off % 3600) / 60
1597                                ));
1598                            }
1599                            Some('r') => {
1600                                let Some(p) = parse_signature_times(&commit.author) else {
1601                                    break;
1602                                };
1603                                let now = std::time::SystemTime::now()
1604                                    .duration_since(std::time::UNIX_EPOCH)
1605                                    .unwrap_or_default()
1606                                    .as_secs() as i64;
1607                                target.push_str(&format_relative_date(now - p.unix_seconds));
1608                            }
1609                            Some(other) => {
1610                                target.push('%');
1611                                target.push('a');
1612                                target.push(other);
1613                            }
1614                            None => {
1615                                target.push('%');
1616                                target.push('a');
1617                            }
1618                        }
1619                    }
1620                    Some('c') => {
1621                        chars.next();
1622                        match chars.next() {
1623                            Some('n') => target.push_str(extract_name(&commit.committer)),
1624                            Some('N') => target.push_str(extract_name(&commit.committer)),
1625                            Some('e') => target.push_str(extract_email(&commit.committer)),
1626                            Some('E') => target.push_str(extract_email(&commit.committer)),
1627                            Some('l') => target.push_str(extract_email_local(&commit.committer)),
1628                            Some('d') => target.push_str(&extract_date_default(&commit.committer)),
1629                            Some('D') => target.push_str(&extract_date_rfc2822(&commit.committer)),
1630                            Some('t') => target.push_str(&extract_timestamp(&commit.committer)),
1631                            Some('s') => target.push_str(&extract_date_short(&commit.committer)),
1632                            Some('i') => target.push_str(&extract_date_iso(&commit.committer)),
1633                            Some('I') => {
1634                                let Some(p) = parse_signature_times(&commit.committer) else {
1635                                    break;
1636                                };
1637                                let adjusted = p.unix_seconds + p.tz_offset_secs;
1638                                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1639                                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1640                                let sign_ch = if p.tz_offset_secs >= 0 { '+' } else { '-' };
1641                                let abs_off = p.tz_offset_secs.unsigned_abs();
1642                                target.push_str(&format!(
1643                                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
1644                                    dt.year(),
1645                                    dt.month() as u8,
1646                                    dt.day(),
1647                                    dt.hour(),
1648                                    dt.minute(),
1649                                    dt.second(),
1650                                    sign_ch,
1651                                    abs_off / 3600,
1652                                    (abs_off % 3600) / 60
1653                                ));
1654                            }
1655                            Some('r') => {
1656                                let Some(p) = parse_signature_times(&commit.committer) else {
1657                                    break;
1658                                };
1659                                let now = std::time::SystemTime::now()
1660                                    .duration_since(std::time::UNIX_EPOCH)
1661                                    .unwrap_or_default()
1662                                    .as_secs() as i64;
1663                                target.push_str(&format_relative_date(now - p.unix_seconds));
1664                            }
1665                            Some(other) => {
1666                                target.push('%');
1667                                target.push('c');
1668                                target.push(other);
1669                            }
1670                            None => {
1671                                target.push('%');
1672                                target.push('c');
1673                            }
1674                        }
1675                    }
1676                    Some('x') => {
1677                        // Hex escape: %xNN
1678                        chars.next();
1679                        let mut hex_str = String::new();
1680                        if let Some(&c1) = chars.peek() {
1681                            if c1.is_ascii_hexdigit() {
1682                                hex_str.push(c1);
1683                                chars.next();
1684                            }
1685                        }
1686                        if let Some(&c2) = chars.peek() {
1687                            if c2.is_ascii_hexdigit() {
1688                                hex_str.push(c2);
1689                                chars.next();
1690                            }
1691                        }
1692                        if let Ok(byte) = u8::from_str_radix(&hex_str, 16) {
1693                            target.push(byte as char);
1694                        }
1695                    }
1696                    Some('C') => {
1697                        chars.next();
1698                        if chars.peek() == Some(&'(') {
1699                            chars.next();
1700                            let mut spec = String::new();
1701                            for c in chars.by_ref() {
1702                                if c == ')' {
1703                                    break;
1704                                }
1705                                spec.push(c);
1706                            }
1707                            let (force, color_spec) =
1708                                if let Some(rest) = spec.strip_prefix("always,") {
1709                                    (true, rest)
1710                                } else if let Some(rest) = spec.strip_prefix("auto,") {
1711                                    (false, rest)
1712                                } else if spec == "auto" {
1713                                    if use_color {
1714                                        target.push_str("\x1b[m");
1715                                    }
1716                                    continue;
1717                                } else {
1718                                    (false, spec.as_str())
1719                                };
1720                            if use_color || force {
1721                                target.push_str(&ansi_color_from_spec(color_spec));
1722                            }
1723                        } else {
1724                            // Named colors: %Cred, %Cgreen, %Cblue, %Creset, %Cbold
1725                            // Must match known names only, not consume trailing text
1726                            let remaining: String = chars.clone().collect();
1727                            let known = [
1728                                "reset", "red", "green", "blue", "yellow", "magenta", "cyan",
1729                                "white", "bold", "dim", "ul",
1730                            ];
1731                            let mut matched = false;
1732                            for name in &known {
1733                                if remaining.starts_with(name) {
1734                                    for _ in 0..name.len() {
1735                                        chars.next();
1736                                    }
1737                                    if use_color {
1738                                        target.push_str(&ansi_color_from_name(name));
1739                                    }
1740                                    matched = true;
1741                                    break;
1742                                }
1743                            }
1744                            if !matched {
1745                                // Unknown color name — consume alphanumerics
1746                                while let Some(&c) = chars.peek() {
1747                                    if c.is_alphanumeric() {
1748                                        chars.next();
1749                                    } else {
1750                                        break;
1751                                    }
1752                                }
1753                            }
1754                        }
1755                    }
1756                    Some('w') => {
1757                        // %w(...) — wrapping directive, consume and ignore for now
1758                        chars.next();
1759                        if chars.peek() == Some(&'(') {
1760                            chars.next();
1761                            for c in chars.by_ref() {
1762                                if c == ')' {
1763                                    break;
1764                                }
1765                            }
1766                        }
1767                    }
1768                    Some('+') => {
1769                        // %+x — conditional newline: if next placeholder is non-empty, prepend newline
1770                        chars.next();
1771                        // Expand the following placeholder
1772                        if chars.peek() == Some(&'%') {
1773                            // The %+ applies to the NEXT expanded value
1774                            // For simplicity, treat %+x as: if %x is non-empty, emit '\n' + value
1775                            // This needs the *next* placeholder's value
1776                        }
1777                        // Simple: consume the next char as a format code; prepend \n if non-empty
1778                        let mut sub = String::new();
1779                        if let Some(&nc) = chars.peek() {
1780                            match nc {
1781                                'b' => {
1782                                    chars.next();
1783                                    sub.push_str(&body);
1784                                    if !body.is_empty() {
1785                                        sub.push('\n');
1786                                    }
1787                                }
1788                                's' => {
1789                                    chars.next();
1790                                    sub.push_str(subject);
1791                                }
1792                                _ => {
1793                                    chars.next();
1794                                    sub.push('%');
1795                                    sub.push('+');
1796                                    sub.push(nc);
1797                                }
1798                            }
1799                        }
1800                        if !sub.is_empty() {
1801                            target.push('\n');
1802                            target.push_str(&sub);
1803                        }
1804                    }
1805                    Some('-') => {
1806                        // %-x — conditional: suppress newline before placeholder if empty
1807                        chars.next();
1808                        // Consume the next format code
1809                        if let Some(&nc) = chars.peek() {
1810                            match nc {
1811                                'b' => {
1812                                    chars.next();
1813                                    if !body.is_empty() {
1814                                        target.push_str(&body);
1815                                        target.push('\n');
1816                                    }
1817                                }
1818                                's' => {
1819                                    chars.next();
1820                                    target.push_str(subject);
1821                                }
1822                                _ => {
1823                                    chars.next();
1824                                    target.push('%');
1825                                    target.push('-');
1826                                    target.push(nc);
1827                                }
1828                            }
1829                        }
1830                    }
1831                    Some('d') => {
1832                        // Decorations — output empty for now
1833                        chars.next();
1834                    }
1835                    Some('D') => {
1836                        // Decorations without parens — output empty for now
1837                        chars.next();
1838                    }
1839                    Some('e') => {
1840                        // Encoding
1841                        chars.next();
1842                    }
1843                    Some('g') => {
1844                        // Reflog placeholders: %gD, %gd, %gs, %gn, %ge, etc.
1845                        chars.next();
1846                        if let Some(&_nc) = chars.peek() {
1847                            chars.next(); // consume the sub-specifier
1848                                          // For non-reflog commits, these expand to empty
1849                        }
1850                    }
1851                    Some(&other) => {
1852                        chars.next();
1853                        target.push('%');
1854                        target.push(other);
1855                    }
1856                    None => target.push('%'),
1857                }
1858                // Apply pending column formatting
1859                if let Some(spec) = pending_col.take() {
1860                    let formatted = apply_col(&spec, &expanded);
1861                    rendered.push_str(&formatted);
1862                }
1863            }
1864            Ok(rendered)
1865        }
1866    }
1867}
1868
1869#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1870enum ExpectedObjectKind {
1871    Commit,
1872    Tree,
1873    Blob,
1874}
1875
1876impl ExpectedObjectKind {
1877    fn from_tag_type(kind: &str) -> Option<Self> {
1878        match kind {
1879            "commit" => Some(Self::Commit),
1880            "tree" => Some(Self::Tree),
1881            "blob" => Some(Self::Blob),
1882            _ => None,
1883        }
1884    }
1885
1886    fn matches(self, kind: ObjectKind) -> bool {
1887        matches!(
1888            (self, kind),
1889            (Self::Commit, ObjectKind::Commit)
1890                | (Self::Tree, ObjectKind::Tree)
1891                | (Self::Blob, ObjectKind::Blob)
1892        )
1893    }
1894
1895    fn as_str(self) -> &'static str {
1896        match self {
1897            Self::Commit => "commit",
1898            Self::Tree => "tree",
1899            Self::Blob => "blob",
1900        }
1901    }
1902}
1903
1904#[derive(Clone, Debug)]
1905struct RootObject {
1906    oid: ObjectId,
1907    input: String,
1908    expected_kind: Option<ExpectedObjectKind>,
1909    /// Path within the tree for `rev:path` blob roots (for correct `--objects` names).
1910    root_path: Option<String>,
1911}
1912
1913/// Whether `LOFS_COMMIT` would receive `LOFR_DO_SHOW` when the commit is `NOT_USER_GIVEN` (Git list-objects-filter).
1914fn filter_shows_commit_line_when_not_user_given(filter: Option<&ObjectFilter>) -> bool {
1915    match filter {
1916        None => true,
1917        Some(ObjectFilter::BlobNone)
1918        | Some(ObjectFilter::BlobLimit(_))
1919        | Some(ObjectFilter::TreeDepth(_))
1920        | Some(ObjectFilter::SparseOid(_)) => true,
1921        Some(ObjectFilter::ObjectType(FilterObjectKind::Commit)) => true,
1922        Some(ObjectFilter::ObjectType(
1923            FilterObjectKind::Blob | FilterObjectKind::Tree | FilterObjectKind::Tag,
1924        )) => false,
1925        Some(ObjectFilter::Combine(parts)) => parts
1926            .iter()
1927            .all(|p| filter_shows_commit_line_when_not_user_given(Some(p))),
1928    }
1929}
1930
1931fn skip_tree_descent_for_object_type_filter(filter: Option<&ObjectFilter>) -> bool {
1932    match filter {
1933        Some(ObjectFilter::ObjectType(FilterObjectKind::Commit | FilterObjectKind::Tag)) => true,
1934        Some(ObjectFilter::Combine(parts)) => parts
1935            .iter()
1936            .any(|p| skip_tree_descent_for_object_type_filter(Some(p))),
1937        _ => false,
1938    }
1939}
1940
1941fn sparse_oid_lines_from_filter(
1942    repo: &Repository,
1943    filter: Option<&ObjectFilter>,
1944) -> Option<Vec<String>> {
1945    let f = filter?;
1946    match f {
1947        ObjectFilter::SparseOid(oid) => {
1948            let obj = repo.odb.read(oid).ok()?;
1949            if obj.kind != ObjectKind::Blob {
1950                return None;
1951            }
1952            let text = std::str::from_utf8(&obj.data).ok()?;
1953            Some(parse_sparse_patterns_from_blob(text))
1954        }
1955        ObjectFilter::Combine(parts) => {
1956            for p in parts {
1957                if let Some(lines) = sparse_oid_lines_from_filter(repo, Some(p)) {
1958                    return Some(lines);
1959                }
1960            }
1961            None
1962        }
1963        _ => None,
1964    }
1965}
1966
1967fn packed_object_set(repo: &Repository) -> HashSet<ObjectId> {
1968    let mut out = HashSet::new();
1969    let objects_dir = repo.odb.objects_dir();
1970    if let Ok(indexes) = pack::read_local_pack_indexes(objects_dir) {
1971        for idx in indexes {
1972            for e in idx.entries {
1973                out.insert(e.oid);
1974            }
1975        }
1976    }
1977    out
1978}
1979
1980fn resolve_specs(repo: &Repository, specs: &[String]) -> Result<Vec<ObjectId>> {
1981    let mut out = Vec::with_capacity(specs.len());
1982    for spec in specs {
1983        let oid = resolve_revision_for_range_end(repo, spec)?;
1984        let commit_oid = peel_to_commit(repo, oid)?;
1985        out.push(commit_oid);
1986    }
1987    Ok(out)
1988}
1989
1990fn resolve_specs_for_objects(
1991    repo: &Repository,
1992    specs: &[String],
1993) -> Result<(Vec<ObjectId>, Vec<RootObject>)> {
1994    let mut commits = Vec::new();
1995    let mut roots = Vec::new();
1996
1997    for spec in specs {
1998        if let Ok(raw_oid) = spec.parse::<ObjectId>() {
1999            let raw_object = repo.odb.read(&raw_oid)?;
2000            match raw_object.kind {
2001                ObjectKind::Commit => {
2002                    commits.push(raw_oid);
2003                }
2004                ObjectKind::Tag => {
2005                    let tag = parse_tag(&raw_object.data)?;
2006                    let expected_kind = ExpectedObjectKind::from_tag_type(&tag.object_type)
2007                        .ok_or_else(|| {
2008                            Error::CorruptObject(format!(
2009                                "object {spec} has unsupported tag type '{}'",
2010                                tag.object_type
2011                            ))
2012                        })?;
2013                    roots.push(RootObject {
2014                        oid: tag.object,
2015                        input: spec.clone(),
2016                        expected_kind: Some(expected_kind),
2017                        root_path: None,
2018                    });
2019                }
2020                ObjectKind::Tree | ObjectKind::Blob => roots.push(RootObject {
2021                    oid: raw_oid,
2022                    input: spec.clone(),
2023                    expected_kind: None,
2024                    root_path: None,
2025                }),
2026            }
2027            continue;
2028        }
2029
2030        if let Some((treeish, path)) = split_treeish_spec(spec) {
2031            if !path.is_empty() {
2032                let treeish_oid = resolve_revision_for_range_end(repo, treeish)?;
2033                let blob_oid = resolve_treeish_path(repo, treeish_oid, path)?;
2034                roots.push(RootObject {
2035                    oid: blob_oid,
2036                    input: spec.clone(),
2037                    expected_kind: Some(ExpectedObjectKind::Blob),
2038                    root_path: Some(path.to_owned()),
2039                });
2040                continue;
2041            }
2042        }
2043
2044        let oid = resolve_revision_for_range_end(repo, spec)?;
2045        match peel_to_commit(repo, oid) {
2046            Ok(commit_oid) => commits.push(commit_oid),
2047            Err(Error::CorruptObject(_)) | Err(Error::ObjectNotFound(_)) => {
2048                roots.push(RootObject {
2049                    oid,
2050                    input: spec.clone(),
2051                    expected_kind: None,
2052                    root_path: None,
2053                })
2054            }
2055            Err(err) => return Err(err),
2056        }
2057    }
2058
2059    Ok((commits, roots))
2060}
2061
2062/// Peel an object (possibly a tag) to the underlying commit.
2063fn peel_to_commit(repo: &Repository, mut oid: ObjectId) -> Result<ObjectId> {
2064    loop {
2065        let object = repo.odb.read(&oid)?;
2066        match object.kind {
2067            ObjectKind::Commit => return Ok(oid),
2068            ObjectKind::Tag => {
2069                let tag = parse_tag(&object.data)?;
2070                oid = tag.object;
2071            }
2072            other => {
2073                return Err(Error::CorruptObject(format!(
2074                    "object {oid} is a {other:?}, not a commit"
2075                )));
2076            }
2077        }
2078    }
2079}
2080
2081fn reflog_commit_tips(repo: &Repository) -> Result<Vec<ObjectId>> {
2082    let z = zero_oid();
2083    let mut out = Vec::new();
2084    let mut seen = HashSet::new();
2085    for refname in list_reflog_refs(&repo.git_dir)? {
2086        let entries = read_reflog(&repo.git_dir, &refname)?;
2087        for e in entries {
2088            for oid in [e.old_oid, e.new_oid] {
2089                if oid == z {
2090                    continue;
2091                }
2092                match peel_to_commit(repo, oid) {
2093                    Ok(c) if seen.insert(c) => out.push(c),
2094                    Err(_) => {}
2095                    _ => {}
2096                }
2097            }
2098        }
2099    }
2100    Ok(out)
2101}
2102
2103fn all_ref_tips(repo: &Repository) -> Result<Vec<ObjectId>> {
2104    let mut raw = Vec::new();
2105    if let Ok(head) = refs::resolve_ref(&repo.git_dir, "HEAD") {
2106        raw.push(head);
2107    }
2108    raw.extend(
2109        refs::list_refs(&repo.git_dir, "refs/")?
2110            .into_iter()
2111            .map(|(_, oid)| oid),
2112    );
2113    // Peel tags to commits; skip non-commit objects (e.g. tags of blobs/trees)
2114    let mut out = Vec::new();
2115    let mut seen = HashSet::new();
2116    for oid in raw {
2117        match peel_to_commit(repo, oid) {
2118            Ok(commit_oid) if seen.insert(commit_oid) => out.push(commit_oid),
2119            Err(_) => {} // skip non-commit refs
2120            _ => {}
2121        }
2122    }
2123    out.sort();
2124    Ok(out)
2125}
2126
2127fn walk_closure(graph: &mut CommitGraph<'_>, starts: &[ObjectId]) -> Result<HashSet<ObjectId>> {
2128    let (seen, _) = walk_closure_ordered(graph, starts)?;
2129    Ok(seen)
2130}
2131
2132/// BFS walk that returns both the set and the discovery order.
2133fn walk_closure_ordered(
2134    graph: &mut CommitGraph<'_>,
2135    starts: &[ObjectId],
2136) -> Result<(HashSet<ObjectId>, Vec<ObjectId>)> {
2137    let mut seen = HashSet::new();
2138    let mut order = Vec::new();
2139    let mut queue = VecDeque::new();
2140    for &start in starts {
2141        queue.push_back(start);
2142    }
2143    while let Some(oid) = queue.pop_front() {
2144        if !seen.insert(oid) {
2145            continue;
2146        }
2147        order.push(oid);
2148        for parent in graph.parents_of(oid)? {
2149            queue.push_back(parent);
2150        }
2151    }
2152    Ok((seen, order))
2153}
2154
2155/// Git-style default ordering: among commits ready to print, pick the one with the
2156/// greatest committer timestamp; a parent becomes ready only after all of its
2157/// children that remain in the walk have been emitted.
2158///
2159/// This matches `git rev-list` behavior (and differs from sorting the whole set by
2160/// date, which can surface ancestors before descendants when dates are skewed).
2161fn date_order_walk(
2162    graph: &mut CommitGraph<'_>,
2163    tips: &[ObjectId],
2164    selected: &HashSet<ObjectId>,
2165) -> Result<Vec<ObjectId>> {
2166    let mut unfinished_children: HashMap<ObjectId, usize> =
2167        selected.iter().map(|&oid| (oid, 0usize)).collect();
2168    for &child in selected {
2169        for parent in graph.parents_of(child)? {
2170            if selected.contains(&parent) {
2171                if let Some(count) = unfinished_children.get_mut(&parent) {
2172                    *count += 1;
2173                }
2174            }
2175        }
2176    }
2177
2178    let mut heap = BinaryHeap::new();
2179    for &tip in tips {
2180        if selected.contains(&tip) {
2181            heap.push(CommitDateKey {
2182                oid: tip,
2183                date: graph.committer_time(tip),
2184            });
2185        }
2186    }
2187
2188    let mut emitted = HashSet::new();
2189    let mut out = Vec::with_capacity(selected.len());
2190    while let Some(item) = heap.pop() {
2191        if !emitted.insert(item.oid) {
2192            continue;
2193        }
2194        out.push(item.oid);
2195        for parent in graph.parents_of(item.oid)? {
2196            if !selected.contains(&parent) {
2197                continue;
2198            }
2199            let Some(count) = unfinished_children.get_mut(&parent) else {
2200                continue;
2201            };
2202            *count = count.saturating_sub(1);
2203            if *count == 0 {
2204                heap.push(CommitDateKey {
2205                    oid: parent,
2206                    date: graph.committer_time(parent),
2207                });
2208            }
2209        }
2210    }
2211
2212    Ok(out)
2213}
2214
2215fn topo_sort(graph: &mut CommitGraph<'_>, selected: &HashSet<ObjectId>) -> Result<Vec<ObjectId>> {
2216    let mut child_count: HashMap<ObjectId, usize> = selected.iter().map(|&oid| (oid, 0)).collect();
2217
2218    for &oid in selected {
2219        for parent in graph.parents_of(oid)? {
2220            if !selected.contains(&parent) {
2221                continue;
2222            }
2223            if let Some(count) = child_count.get_mut(&parent) {
2224                *count += 1;
2225            }
2226        }
2227    }
2228
2229    // Git's `--topo-order`: among commits whose children have all been emitted, take the one
2230    // with the smallest committer date first (Kahn + min-heap). A max-heap on `CommitDateKey`
2231    // inverts this and breaks `rev-list --reverse --topo-order` vs upstream (t3425).
2232    let mut ready: BinaryHeap<Reverse<CommitDateKey>> = BinaryHeap::new();
2233    for (&oid, &count) in &child_count {
2234        if count == 0 {
2235            ready.push(Reverse(CommitDateKey {
2236                oid,
2237                date: graph.committer_time(oid),
2238            }));
2239        }
2240    }
2241
2242    let mut out = Vec::with_capacity(selected.len());
2243    while let Some(Reverse(item)) = ready.pop() {
2244        let oid = item.oid;
2245        out.push(oid);
2246        for parent in graph.parents_of(oid)? {
2247            if !selected.contains(&parent) {
2248                continue;
2249            }
2250            if let Some(count) = child_count.get_mut(&parent) {
2251                *count = count.saturating_sub(1);
2252                if *count == 0 {
2253                    ready.push(Reverse(CommitDateKey {
2254                        oid: parent,
2255                        date: graph.committer_time(parent),
2256                    }));
2257                }
2258            }
2259        }
2260    }
2261
2262    Ok(out)
2263}
2264
2265fn limit_to_ancestry(
2266    graph: &mut CommitGraph<'_>,
2267    selected: &mut HashSet<ObjectId>,
2268    bottoms: &[ObjectId],
2269) -> Result<()> {
2270    let mut keep = HashSet::new();
2271    for &bottom in bottoms {
2272        let ancestors = walk_closure(graph, &[bottom])?;
2273        keep.extend(
2274            ancestors
2275                .iter()
2276                .copied()
2277                .filter(|oid| selected.contains(oid)),
2278        );
2279
2280        for &candidate in selected.iter() {
2281            if candidate == bottom {
2282                keep.insert(candidate);
2283                continue;
2284            }
2285            let closure = walk_closure(graph, &[candidate])?;
2286            if closure.contains(&bottom) {
2287                keep.insert(candidate);
2288            }
2289        }
2290    }
2291    selected.retain(|oid| keep.contains(oid));
2292    Ok(())
2293}
2294
2295/// Check if a commit modifies any of the given paths compared to its first parent.
2296fn commit_touches_paths(
2297    repo: &Repository,
2298    graph: &mut CommitGraph<'_>,
2299    oid: ObjectId,
2300    paths: &[String],
2301    full_history: bool,
2302    sparse: bool,
2303) -> Result<bool> {
2304    let commit = load_commit(repo, oid)?;
2305    let parents = graph.parents_of(oid)?;
2306    let commit_entries = flatten_tree(repo, commit.tree, "")?;
2307    let commit_map: HashMap<String, ObjectId> = commit_entries.into_iter().collect();
2308
2309    // Root commit: include only when any requested pathspec exists.
2310    if parents.is_empty() {
2311        if sparse {
2312            return Ok(true);
2313        }
2314        return Ok(commit_map.keys().any(|path| {
2315            paths.iter().any(|spec| {
2316                crate::pathspec::matches_pathspec_with_context(
2317                    spec,
2318                    path,
2319                    crate::pathspec::PathspecMatchContext {
2320                        is_directory: false,
2321                        is_git_submodule: false,
2322                    },
2323                )
2324            })
2325        }));
2326    }
2327
2328    // Single-parent commit: include only when requested paths changed.
2329    if parents.len() == 1 {
2330        let parent = load_commit(repo, parents[0])?;
2331        let parent_map: HashMap<String, ObjectId> =
2332            flatten_tree(repo, parent.tree, "")?.into_iter().collect();
2333        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
2334        if differs {
2335            return Ok(true);
2336        }
2337        if sparse {
2338            return Ok(true);
2339        }
2340        return Ok(false);
2341    }
2342
2343    // Merge commit simplification for default dense history:
2344    // if exactly one parent is TREESAME for the requested paths, omit this
2345    // merge commit and let traversal effectively follow that parent.
2346    let mut treesame_parents = 0usize;
2347    let mut differs_any = false;
2348    for parent_oid in &parents {
2349        let parent = load_commit(repo, *parent_oid)?;
2350        let parent_map: HashMap<String, ObjectId> =
2351            flatten_tree(repo, parent.tree, "")?.into_iter().collect();
2352        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
2353        if differs {
2354            differs_any = true;
2355        } else {
2356            treesame_parents += 1;
2357        }
2358    }
2359
2360    if !full_history && treesame_parents == 1 {
2361        return Ok(false);
2362    }
2363
2364    if differs_any {
2365        return Ok(true);
2366    }
2367
2368    Ok(sparse)
2369}
2370
2371fn path_differs_for_specs(
2372    current: &HashMap<String, ObjectId>,
2373    parent: &HashMap<String, ObjectId>,
2374    specs: &[String],
2375) -> bool {
2376    let mut paths = std::collections::BTreeSet::new();
2377    paths.extend(current.keys().cloned());
2378    paths.extend(parent.keys().cloned());
2379
2380    for path in &paths {
2381        if !specs
2382            .iter()
2383            .any(|spec| crate::pathspec::matches_pathspec(spec, path))
2384        {
2385            continue;
2386        }
2387        if current.get(path) != parent.get(path) {
2388            return true;
2389        }
2390    }
2391    false
2392}
2393
2394fn load_commit(repo: &Repository, oid: ObjectId) -> Result<crate::objects::CommitData> {
2395    let object = repo.odb.read(&oid)?;
2396    if object.kind != ObjectKind::Commit {
2397        return Err(Error::CorruptObject(format!(
2398            "object {oid} is not a commit"
2399        )));
2400    }
2401    parse_commit(&object.data)
2402}
2403
2404/// Merge command-line arguments and `--stdin` input lines for this subset.
2405///
2406/// Returns `(positive_specs, negative_specs)`.
2407///
2408/// # Errors
2409///
2410/// Returns [`Error::InvalidRef`] when stdin provides invalid pseudo-options.
2411pub fn collect_revision_specs_with_stdin(
2412    args_specs: &[String],
2413    read_stdin: bool,
2414) -> Result<(Vec<String>, Vec<String>, bool)> {
2415    let mut positive = Vec::new();
2416    let mut negative = Vec::new();
2417    let mut not_mode = false;
2418
2419    for spec in args_specs {
2420        let (pos, neg) = split_revision_token(spec);
2421        if not_mode {
2422            positive.extend(neg);
2423            negative.extend(pos);
2424        } else {
2425            positive.extend(pos);
2426            negative.extend(neg);
2427        }
2428    }
2429
2430    if !read_stdin {
2431        return Ok((positive, negative, false));
2432    }
2433
2434    let mut in_paths = false;
2435    let mut stdin_all_refs = false;
2436    let stdin = std::io::read_to_string(std::io::stdin()).map_err(Error::Io)?;
2437    for raw_line in stdin.lines() {
2438        let line = raw_line.trim();
2439        if line.is_empty() {
2440            continue;
2441        }
2442        if in_paths {
2443            continue;
2444        }
2445        if line == "--" {
2446            in_paths = true;
2447            continue;
2448        }
2449        if line == "--not" {
2450            not_mode = !not_mode;
2451            continue;
2452        }
2453        if line == "--all" {
2454            stdin_all_refs = true;
2455            continue;
2456        }
2457        if line.starts_with("--") {
2458            return Err(Error::InvalidRef(format!(
2459                "invalid option '{line}' in --stdin mode"
2460            )));
2461        }
2462        if line.starts_with('-') {
2463            return Err(Error::InvalidRef(format!(
2464                "invalid option '{line}' in --stdin mode"
2465            )));
2466        }
2467        let (pos, neg) = split_revision_token(line);
2468        if not_mode {
2469            positive.extend(neg);
2470            negative.extend(pos);
2471        } else {
2472            positive.extend(pos);
2473            negative.extend(neg);
2474        }
2475    }
2476
2477    Ok((positive, negative, stdin_all_refs))
2478}
2479
2480/// Resolve every local tag object ID.
2481pub fn tag_targets(git_dir: &Path) -> Result<HashSet<ObjectId>> {
2482    Ok(refs::list_refs(git_dir, "refs/tags/")?
2483        .into_iter()
2484        .map(|(_, oid)| oid)
2485        .collect())
2486}
2487
2488struct CommitGraph<'r> {
2489    repo: &'r Repository,
2490    first_parent_only: bool,
2491    parents: HashMap<ObjectId, Vec<ObjectId>>,
2492    committer_time: HashMap<ObjectId, i64>,
2493    shallow_boundaries: HashSet<ObjectId>,
2494    graft_parents: HashMap<ObjectId, Vec<ObjectId>>,
2495}
2496
2497impl<'r> CommitGraph<'r> {
2498    fn new(repo: &'r Repository, first_parent_only: bool) -> Self {
2499        let shallow_boundaries = load_shallow_boundaries(&repo.git_dir);
2500        let graft_parents = load_graft_parents(&repo.git_dir);
2501        Self {
2502            repo,
2503            first_parent_only,
2504            parents: HashMap::new(),
2505            committer_time: HashMap::new(),
2506            shallow_boundaries,
2507            graft_parents,
2508        }
2509    }
2510
2511    fn parents_of(&mut self, oid: ObjectId) -> Result<Vec<ObjectId>> {
2512        self.populate(oid)?;
2513        Ok(self.parents.get(&oid).cloned().unwrap_or_default())
2514    }
2515
2516    fn committer_time(&mut self, oid: ObjectId) -> i64 {
2517        if self.populate(oid).is_err() {
2518            return 0;
2519        }
2520        self.committer_time.get(&oid).copied().unwrap_or(0)
2521    }
2522
2523    fn populate(&mut self, oid: ObjectId) -> Result<()> {
2524        if self.parents.contains_key(&oid) {
2525            return Ok(());
2526        }
2527        let commit = load_commit(self.repo, oid)?;
2528        // Shallow boundaries: treat commit as having no parents
2529        let mut parents = if self.shallow_boundaries.contains(&oid) {
2530            Vec::new()
2531        } else {
2532            commit.parents
2533        };
2534        if let Some(graft_parents) = self.graft_parents.get(&oid) {
2535            parents = graft_parents.clone();
2536        }
2537        if self.first_parent_only && parents.len() > 1 {
2538            parents.truncate(1);
2539        }
2540        self.committer_time
2541            .insert(oid, committer_unix_seconds_for_ordering(&commit.committer));
2542        self.parents.insert(oid, parents);
2543        Ok(())
2544    }
2545}
2546
2547/// Load shallow boundary commit OIDs from `.git/shallow`.
2548fn load_shallow_boundaries(git_dir: &Path) -> HashSet<ObjectId> {
2549    let shallow_path = git_dir.join("shallow");
2550    let mut set = HashSet::new();
2551    if let Ok(contents) = fs::read_to_string(&shallow_path) {
2552        for line in contents.lines() {
2553            let line = line.trim();
2554            if !line.is_empty() {
2555                if let Ok(oid) = line.parse::<ObjectId>() {
2556                    set.insert(oid);
2557                }
2558            }
2559        }
2560    }
2561    set
2562}
2563
2564/// Load commit parent overrides from `.git/info/grafts`.
2565fn load_graft_parents(git_dir: &Path) -> HashMap<ObjectId, Vec<ObjectId>> {
2566    let graft_path = git_dir.join("info/grafts");
2567    let mut grafts = HashMap::new();
2568    let Ok(contents) = fs::read_to_string(&graft_path) else {
2569        return grafts;
2570    };
2571    for raw_line in contents.lines() {
2572        let line = raw_line.trim();
2573        if line.is_empty() || line.starts_with('#') {
2574            continue;
2575        }
2576        let mut fields = line.split_whitespace();
2577        let Some(commit_hex) = fields.next() else {
2578            continue;
2579        };
2580        let Ok(commit_oid) = commit_hex.parse::<ObjectId>() else {
2581            continue;
2582        };
2583        let mut parents = Vec::new();
2584        let mut valid = true;
2585        for parent_hex in fields {
2586            match parent_hex.parse::<ObjectId>() {
2587                Ok(parent_oid) => parents.push(parent_oid),
2588                Err(_) => {
2589                    valid = false;
2590                    break;
2591                }
2592            }
2593        }
2594        if valid {
2595            grafts.insert(commit_oid, parents);
2596        }
2597    }
2598    grafts
2599}
2600
2601#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2602struct CommitDateKey {
2603    oid: ObjectId,
2604    date: i64,
2605}
2606
2607impl Ord for CommitDateKey {
2608    fn cmp(&self, other: &Self) -> Ordering {
2609        match self.date.cmp(&other.date) {
2610            Ordering::Equal => self.oid.cmp(&other.oid),
2611            ord => ord,
2612        }
2613    }
2614}
2615
2616impl PartialOrd for CommitDateKey {
2617    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2618        Some(self.cmp(other))
2619    }
2620}
2621
2622/// Read every line from a newline-delimited file.
2623///
2624/// # Errors
2625///
2626/// Returns [`Error::Io`] when the file cannot be read.
2627pub fn read_lines(path: &Path) -> Result<Vec<String>> {
2628    let content = fs::read_to_string(path)?;
2629    Ok(content.lines().map(|line| line.to_owned()).collect())
2630}
2631
2632/// Check if a token uses the symmetric diff `...` notation.
2633#[must_use]
2634pub fn is_symmetric_diff(token: &str) -> bool {
2635    token.contains("...") && !token.contains("....")
2636}
2637
2638/// Split a symmetric diff token into (lhs, rhs).
2639#[must_use]
2640pub fn split_symmetric_diff(token: &str) -> Option<(String, String)> {
2641    token
2642        .split_once("...")
2643        .map(|(l, r)| (l.to_owned(), r.to_owned()))
2644}
2645
2646/// Tracks which tree OIDs have been traversed, matching Git list-objects behavior.
2647///
2648/// For `tree:<n>` filters, the same tree OID may be revisited from a shallower path; otherwise
2649/// each tree is walked at most once per top-level walk.
2650#[derive(Debug)]
2651enum TreeWalkState {
2652    Simple(HashSet<ObjectId>),
2653    TreeDepth(HashMap<ObjectId, u64>),
2654}
2655
2656impl TreeWalkState {
2657    fn new(filter: Option<&ObjectFilter>) -> Self {
2658        if filter_uses_tree_depth(filter) {
2659            Self::TreeDepth(HashMap::new())
2660        } else {
2661            Self::Simple(HashSet::new())
2662        }
2663    }
2664
2665    /// Returns `true` if this tree at `depth` should be skipped (already handled sufficiently).
2666    fn should_skip_tree(&mut self, oid: ObjectId, depth: u64) -> bool {
2667        match self {
2668            TreeWalkState::Simple(set) => !set.insert(oid),
2669            TreeWalkState::TreeDepth(map) => match map.get(&oid).copied() {
2670                None => {
2671                    map.insert(oid, depth);
2672                    false
2673                }
2674                Some(prev) if depth >= prev => true,
2675                Some(_) => {
2676                    map.insert(oid, depth);
2677                    false
2678                }
2679            },
2680        }
2681    }
2682}
2683
2684fn filter_uses_tree_depth(filter: Option<&ObjectFilter>) -> bool {
2685    match filter {
2686        Some(ObjectFilter::TreeDepth(_)) => true,
2687        Some(ObjectFilter::Combine(parts)) => parts.iter().any(|p| filter_uses_tree_depth(Some(p))),
2688        _ => false,
2689    }
2690}
2691
2692/// All tree and blob OIDs reachable from `tree_oid` (including `tree_oid` itself).
2693fn collect_tree_closure_objects(
2694    repo: &Repository,
2695    tree_oid: ObjectId,
2696    into: &mut HashSet<ObjectId>,
2697    missing_action: MissingAction,
2698    missing: &mut Vec<ObjectId>,
2699    missing_seen: &mut HashSet<ObjectId>,
2700) -> Result<()> {
2701    let mut stack = vec![tree_oid];
2702    let mut expanded_trees = HashSet::new();
2703    while let Some(t) = stack.pop() {
2704        if !expanded_trees.insert(t) {
2705            continue;
2706        }
2707        into.insert(t);
2708        let object = match repo.odb.read(&t) {
2709            Ok(o) => o,
2710            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2711                if missing_action == MissingAction::Print && missing_seen.insert(t) {
2712                    missing.push(t);
2713                }
2714                continue;
2715            }
2716            Err(e) => return Err(e),
2717        };
2718        if object.kind != ObjectKind::Tree {
2719            continue;
2720        }
2721        let entries = parse_tree(&object.data)?;
2722        for entry in entries {
2723            if entry.mode == 0o160000 {
2724                continue;
2725            }
2726            into.insert(entry.oid);
2727            if entry.mode == 0o040000 {
2728                stack.push(entry.oid);
2729            }
2730        }
2731    }
2732    Ok(())
2733}
2734
2735fn union_parent_reachable_objects(
2736    repo: &Repository,
2737    parents: &[ObjectId],
2738    missing_action: MissingAction,
2739    missing: &mut Vec<ObjectId>,
2740    missing_seen: &mut HashSet<ObjectId>,
2741) -> Result<HashSet<ObjectId>> {
2742    let mut out = HashSet::new();
2743    for &p in parents {
2744        let commit = match load_commit(repo, p) {
2745            Ok(c) => c,
2746            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2747                if missing_action == MissingAction::Print && missing_seen.insert(p) {
2748                    missing.push(p);
2749                }
2750                continue;
2751            }
2752            Err(e) => return Err(e),
2753        };
2754        collect_tree_closure_objects(
2755            repo,
2756            commit.tree,
2757            &mut out,
2758            missing_action,
2759            missing,
2760            missing_seen,
2761        )?;
2762    }
2763    Ok(out)
2764}
2765
2766/// Collect all reachable non-commit objects (trees and blobs) from a set of commits.
2767/// Returns (included, omitted) object lists.
2768#[allow(dead_code)]
2769fn collect_reachable_objects(
2770    repo: &Repository,
2771    graph: &mut CommitGraph<'_>,
2772    commits: &[ObjectId],
2773    object_roots: &[RootObject],
2774    filter: Option<&ObjectFilter>,
2775    filter_provided: bool,
2776    missing_action: MissingAction,
2777    sparse_lines: Option<&[String]>,
2778    skip_trees_for_type_filter: bool,
2779    omit_object_paths: bool,
2780    packed_set: Option<&HashSet<ObjectId>>,
2781) -> Result<(Vec<(ObjectId, String)>, Vec<ObjectId>, Vec<ObjectId>)> {
2782    let mut tree_state = TreeWalkState::new(filter);
2783    let mut emitted = HashSet::new();
2784    let mut result = Vec::new();
2785    let mut omitted = Vec::new();
2786    let mut missing = Vec::new();
2787    let mut missing_seen = HashSet::new();
2788    for &commit_oid in commits {
2789        let commit = match load_commit(repo, commit_oid) {
2790            Ok(commit) => commit,
2791            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2792                if missing_seen.insert(commit_oid) && missing_action == MissingAction::Print {
2793                    missing.push(commit_oid);
2794                }
2795                continue;
2796            }
2797            Err(err) => return Err(err),
2798        };
2799        let parents = graph.parents_of(commit_oid)?;
2800        let parent_union = union_parent_reachable_objects(
2801            repo,
2802            &parents,
2803            missing_action,
2804            &mut missing,
2805            &mut missing_seen,
2806        )?;
2807        collect_tree_objects_filtered(
2808            repo,
2809            commit.tree,
2810            "",
2811            0,
2812            false,
2813            Some(&parent_union),
2814            &mut tree_state,
2815            &mut emitted,
2816            &mut result,
2817            &mut omitted,
2818            &mut missing,
2819            &mut missing_seen,
2820            filter,
2821            filter_provided,
2822            missing_action,
2823            sparse_lines,
2824            skip_trees_for_type_filter,
2825            omit_object_paths,
2826            packed_set,
2827        )?;
2828    }
2829
2830    for root in object_roots {
2831        collect_root_object(
2832            repo,
2833            root,
2834            &mut tree_state,
2835            &mut emitted,
2836            &mut result,
2837            &mut omitted,
2838            &mut missing,
2839            &mut missing_seen,
2840            filter,
2841            filter_provided,
2842            missing_action,
2843            sparse_lines,
2844            skip_trees_for_type_filter,
2845            omit_object_paths,
2846            packed_set,
2847        )?;
2848    }
2849
2850    Ok((result, omitted, missing))
2851}
2852
2853/// Like [`collect_reachable_objects`], but also returns objects newly discovered per commit walk
2854/// plus one trailing segment for `object_roots`.
2855///
2856/// Matches Git `traverse_commit_list_filtered`: each commit's tree is processed before moving to
2857/// the next commit, with global de-duplication of emitted object OIDs across the full walk.
2858fn collect_reachable_objects_segmented(
2859    repo: &Repository,
2860    graph: &mut CommitGraph<'_>,
2861    commits: &[ObjectId],
2862    object_roots: &[RootObject],
2863    filter: Option<&ObjectFilter>,
2864    filter_provided: bool,
2865    missing_action: MissingAction,
2866    sparse_lines: Option<&[String]>,
2867    skip_trees_for_type_filter: bool,
2868    omit_object_paths: bool,
2869    packed_set: Option<&HashSet<ObjectId>>,
2870) -> Result<(
2871    Vec<(ObjectId, String)>,
2872    Vec<ObjectId>,
2873    Vec<ObjectId>,
2874    Vec<Vec<(ObjectId, String)>>,
2875)> {
2876    let mut emitted = HashSet::new();
2877    let mut result = Vec::new();
2878    let mut omitted = Vec::new();
2879    let mut missing = Vec::new();
2880    let mut missing_seen = HashSet::new();
2881    let mut segments: Vec<Vec<(ObjectId, String)>> = Vec::with_capacity(commits.len() + 1);
2882
2883    for &commit_oid in commits {
2884        let start = result.len();
2885        let commit = match load_commit(repo, commit_oid) {
2886            Ok(commit) => commit,
2887            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2888                if missing_action == MissingAction::Print && missing_seen.insert(commit_oid) {
2889                    missing.push(commit_oid);
2890                }
2891                segments.push(Vec::new());
2892                continue;
2893            }
2894            Err(err) => return Err(err),
2895        };
2896        let mut tree_state = TreeWalkState::new(filter);
2897        let parents = graph.parents_of(commit_oid)?;
2898        let parent_union = union_parent_reachable_objects(
2899            repo,
2900            &parents,
2901            missing_action,
2902            &mut missing,
2903            &mut missing_seen,
2904        )?;
2905        collect_tree_objects_filtered(
2906            repo,
2907            commit.tree,
2908            "",
2909            0,
2910            false,
2911            Some(&parent_union),
2912            &mut tree_state,
2913            &mut emitted,
2914            &mut result,
2915            &mut omitted,
2916            &mut missing,
2917            &mut missing_seen,
2918            filter,
2919            filter_provided,
2920            missing_action,
2921            sparse_lines,
2922            skip_trees_for_type_filter,
2923            omit_object_paths,
2924            packed_set,
2925        )?;
2926        segments.push(result[start..].to_vec());
2927    }
2928
2929    let roots_start = result.len();
2930    let mut root_tree_state = TreeWalkState::new(filter);
2931    for root in object_roots {
2932        collect_root_object(
2933            repo,
2934            root,
2935            &mut root_tree_state,
2936            &mut emitted,
2937            &mut result,
2938            &mut omitted,
2939            &mut missing,
2940            &mut missing_seen,
2941            filter,
2942            filter_provided,
2943            missing_action,
2944            sparse_lines,
2945            skip_trees_for_type_filter,
2946            omit_object_paths,
2947            packed_set,
2948        )?;
2949    }
2950    segments.push(result[roots_start..].to_vec());
2951
2952    Ok((result, omitted, missing, segments))
2953}
2954
2955fn collect_root_object(
2956    repo: &Repository,
2957    root: &RootObject,
2958    tree_state: &mut TreeWalkState,
2959    emitted: &mut HashSet<ObjectId>,
2960    result: &mut Vec<(ObjectId, String)>,
2961    omitted: &mut Vec<ObjectId>,
2962    missing: &mut Vec<ObjectId>,
2963    missing_seen: &mut HashSet<ObjectId>,
2964    filter: Option<&ObjectFilter>,
2965    filter_provided: bool,
2966    missing_action: MissingAction,
2967    sparse_lines: Option<&[String]>,
2968    skip_trees_for_type_filter: bool,
2969    omit_object_paths: bool,
2970    packed_set: Option<&HashSet<ObjectId>>,
2971) -> Result<()> {
2972    let object = match repo.odb.read(&root.oid) {
2973        Ok(object) => object,
2974        Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2975            if missing_action == MissingAction::Print && missing_seen.insert(root.oid) {
2976                missing.push(root.oid);
2977            }
2978            return Ok(());
2979        }
2980        Err(err) => return Err(err),
2981    };
2982
2983    if let Some(expected) = root.expected_kind {
2984        if !expected.matches(object.kind) {
2985            return Err(Error::CorruptObject(format!(
2986                "object {} is not a {}",
2987                root.input,
2988                expected.as_str()
2989            )));
2990        }
2991    }
2992
2993    match object.kind {
2994        ObjectKind::Commit => {
2995            let commit = parse_commit(&object.data)?;
2996            let parent_union = union_parent_reachable_objects(
2997                repo,
2998                &commit.parents,
2999                missing_action,
3000                missing,
3001                missing_seen,
3002            )?;
3003            collect_tree_objects_filtered(
3004                repo,
3005                commit.tree,
3006                "",
3007                0,
3008                false,
3009                Some(&parent_union),
3010                tree_state,
3011                emitted,
3012                result,
3013                omitted,
3014                missing,
3015                missing_seen,
3016                filter,
3017                filter_provided,
3018                missing_action,
3019                sparse_lines,
3020                skip_trees_for_type_filter,
3021                omit_object_paths,
3022                packed_set,
3023            )?;
3024        }
3025        ObjectKind::Tree => {
3026            collect_tree_objects_filtered(
3027                repo,
3028                root.oid,
3029                "",
3030                0,
3031                true,
3032                None,
3033                tree_state,
3034                emitted,
3035                result,
3036                omitted,
3037                missing,
3038                missing_seen,
3039                filter,
3040                filter_provided,
3041                missing_action,
3042                sparse_lines,
3043                skip_trees_for_type_filter,
3044                omit_object_paths,
3045                packed_set,
3046            )?;
3047        }
3048        ObjectKind::Blob => {
3049            let path_for_sparse = root.root_path.as_deref().unwrap_or("");
3050            let blob_included = match filter {
3051                None => true,
3052                Some(f) => {
3053                    if !filter_provided {
3054                        true
3055                    } else {
3056                        f.includes_blob_under_tree(object.data.len() as u64, 0)
3057                    }
3058                }
3059            };
3060            let blob_included = blob_included
3061                && sparse_lines.is_none_or(|lines| {
3062                    path_matches_sparse_pattern_list(path_for_sparse, lines) != Some(false)
3063                });
3064            if !blob_included {
3065                omitted.push(root.oid);
3066                return Ok(());
3067            }
3068            if packed_set.is_some_and(|p| p.contains(&root.oid)) {
3069                return Ok(());
3070            }
3071            if !emitted.insert(root.oid) {
3072                return Ok(());
3073            }
3074            let out_path = if omit_object_paths {
3075                String::new()
3076            } else {
3077                path_for_sparse.to_owned()
3078            };
3079            result.push((root.oid, out_path));
3080        }
3081        ObjectKind::Tag => {
3082            let tag = parse_tag(&object.data)?;
3083            let expected_kind =
3084                ExpectedObjectKind::from_tag_type(&tag.object_type).ok_or_else(|| {
3085                    Error::CorruptObject(format!(
3086                        "object {} has unsupported tag type '{}'",
3087                        root.input, tag.object_type
3088                    ))
3089                })?;
3090            let nested = RootObject {
3091                oid: tag.object,
3092                input: root.input.clone(),
3093                expected_kind: Some(expected_kind),
3094                root_path: None,
3095            };
3096            collect_root_object(
3097                repo,
3098                &nested,
3099                tree_state,
3100                emitted,
3101                result,
3102                omitted,
3103                missing,
3104                missing_seen,
3105                filter,
3106                filter_provided,
3107                missing_action,
3108                sparse_lines,
3109                skip_trees_for_type_filter,
3110                omit_object_paths,
3111                packed_set,
3112            )?;
3113        }
3114    }
3115
3116    Ok(())
3117}
3118
3119#[allow(dead_code)]
3120fn collect_tree_objects_filtered(
3121    repo: &Repository,
3122    tree_oid: ObjectId,
3123    prefix: &str,
3124    depth: u64,
3125    explicit_root: bool,
3126    parent_union: Option<&HashSet<ObjectId>>,
3127    tree_state: &mut TreeWalkState,
3128    emitted: &mut HashSet<ObjectId>,
3129    result: &mut Vec<(ObjectId, String)>,
3130    omitted: &mut Vec<ObjectId>,
3131    missing: &mut Vec<ObjectId>,
3132    missing_seen: &mut HashSet<ObjectId>,
3133    filter: Option<&ObjectFilter>,
3134    filter_provided: bool,
3135    missing_action: MissingAction,
3136    sparse_lines: Option<&[String]>,
3137    skip_trees_for_type_filter: bool,
3138    omit_object_paths: bool,
3139    packed_set: Option<&HashSet<ObjectId>>,
3140) -> Result<()> {
3141    if tree_state.should_skip_tree(tree_oid, depth) {
3142        return Ok(());
3143    }
3144    if !explicit_root {
3145        if let Some(pu) = parent_union {
3146            if pu.contains(&tree_oid) {
3147                return Ok(());
3148            }
3149        }
3150    }
3151    let object = match repo.odb.read(&tree_oid) {
3152        Ok(object) => object,
3153        Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
3154            if missing_action == MissingAction::Print && missing_seen.insert(tree_oid) {
3155                missing.push(tree_oid);
3156            }
3157            return Ok(());
3158        }
3159        Err(err) => return Err(err),
3160    };
3161    if object.kind != ObjectKind::Tree {
3162        return Err(Error::CorruptObject(format!(
3163            "object {tree_oid} is not a tree"
3164        )));
3165    }
3166    let tree_included = match filter {
3167        None => true,
3168        Some(f) => {
3169            if explicit_root && !filter_provided {
3170                true
3171            } else {
3172                f.includes_tree(depth)
3173            }
3174        }
3175    };
3176    let tree_included = tree_included
3177        && sparse_lines
3178            .is_none_or(|lines| path_matches_sparse_pattern_list(prefix, lines) != Some(false));
3179    if tree_included {
3180        if !packed_set.is_some_and(|p| p.contains(&tree_oid)) && emitted.insert(tree_oid) {
3181            let out_path = if omit_object_paths {
3182                String::new()
3183            } else {
3184                prefix.to_owned()
3185            };
3186            result.push((tree_oid, out_path));
3187        }
3188    } else {
3189        omitted.push(tree_oid);
3190    }
3191    if skip_trees_for_type_filter && depth == 0 && !explicit_root {
3192        return Ok(());
3193    }
3194    let entries = parse_tree(&object.data)?;
3195    for entry in entries {
3196        // Skip gitlink (submodule) entries — their OIDs reference commits
3197        // in the submodule's object store, not the parent repo.
3198        if entry.mode == 0o160000 {
3199            continue;
3200        }
3201        let name = String::from_utf8_lossy(&entry.name).to_string();
3202        let path = if prefix.is_empty() {
3203            name.clone()
3204        } else {
3205            format!("{prefix}/{name}")
3206        };
3207        let child_obj = match repo.odb.read(&entry.oid) {
3208            Ok(object) => object,
3209            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
3210                if missing_action == MissingAction::Print && missing_seen.insert(entry.oid) {
3211                    missing.push(entry.oid);
3212                }
3213                continue;
3214            }
3215            Err(err) => return Err(err),
3216        };
3217        if entry.mode == 0o040000 {
3218            if child_obj.kind != ObjectKind::Tree {
3219                return Err(Error::CorruptObject(format!(
3220                    "object {} is not a tree",
3221                    entry.oid
3222                )));
3223            }
3224            if let Some(pu) = parent_union {
3225                if pu.contains(&entry.oid) {
3226                    continue;
3227                }
3228            }
3229            let child_tree_depth = depth + 1;
3230            collect_tree_objects_filtered(
3231                repo,
3232                entry.oid,
3233                &path,
3234                child_tree_depth,
3235                false,
3236                parent_union,
3237                tree_state,
3238                emitted,
3239                result,
3240                omitted,
3241                missing,
3242                missing_seen,
3243                filter,
3244                filter_provided,
3245                missing_action,
3246                sparse_lines,
3247                skip_trees_for_type_filter,
3248                omit_object_paths,
3249                packed_set,
3250            )?;
3251        } else {
3252            if let Some(pu) = parent_union {
3253                if pu.contains(&entry.oid) {
3254                    continue;
3255                }
3256            }
3257            if child_obj.kind == ObjectKind::Blob {
3258                let blob_included = match filter {
3259                    None => true,
3260                    Some(f) => {
3261                        if explicit_root && !filter_provided {
3262                            true
3263                        } else {
3264                            f.includes_blob_under_tree(child_obj.data.len() as u64, depth)
3265                        }
3266                    }
3267                };
3268                let blob_included = blob_included
3269                    && sparse_lines.is_none_or(|lines| {
3270                        path_matches_sparse_pattern_list(&path, lines) != Some(false)
3271                    });
3272                if blob_included {
3273                    if !packed_set.is_some_and(|p| p.contains(&entry.oid))
3274                        && emitted.insert(entry.oid)
3275                    {
3276                        let out_path = if omit_object_paths {
3277                            String::new()
3278                        } else {
3279                            path.clone()
3280                        };
3281                        result.push((entry.oid, out_path));
3282                    }
3283                } else {
3284                    omitted.push(entry.oid);
3285                }
3286            } else {
3287                // Git historically tolerates lone non-blob entries in blob slots.
3288                if emitted.insert(entry.oid) {
3289                    result.push((entry.oid, path));
3290                }
3291            }
3292        }
3293    }
3294    Ok(())
3295}
3296
3297/// Collect reachable objects in commit order: objects for each commit are emitted
3298/// right after that commit, rather than all objects after all commits.
3299/// Returns (objects, omitted, per_commit_counts).
3300fn collect_reachable_objects_in_commit_order(
3301    repo: &Repository,
3302    graph: &mut CommitGraph<'_>,
3303    commits: &[ObjectId],
3304    object_roots: &[RootObject],
3305    filter: Option<&ObjectFilter>,
3306    filter_provided: bool,
3307    missing_action: MissingAction,
3308    sparse_lines: Option<&[String]>,
3309    skip_trees_for_type_filter: bool,
3310    omit_object_paths: bool,
3311    packed_set: Option<&HashSet<ObjectId>>,
3312) -> Result<(
3313    Vec<(ObjectId, String)>,
3314    Vec<ObjectId>,
3315    Vec<ObjectId>,
3316    Vec<usize>,
3317)> {
3318    let mut tree_state = TreeWalkState::new(filter);
3319    let mut emitted = HashSet::new();
3320    let mut result = Vec::new();
3321    let mut omitted = Vec::new();
3322    let mut missing = Vec::new();
3323    let mut missing_seen = HashSet::new();
3324    let mut counts = Vec::with_capacity(commits.len());
3325    for &commit_oid in commits {
3326        let commit = match load_commit(repo, commit_oid) {
3327            Ok(commit) => commit,
3328            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
3329                if missing_action == MissingAction::Print && missing_seen.insert(commit_oid) {
3330                    missing.push(commit_oid);
3331                }
3332                counts.push(0);
3333                continue;
3334            }
3335            Err(err) => return Err(err),
3336        };
3337        let before = result.len();
3338        let parents = graph.parents_of(commit_oid)?;
3339        let parent_union = union_parent_reachable_objects(
3340            repo,
3341            &parents,
3342            missing_action,
3343            &mut missing,
3344            &mut missing_seen,
3345        )?;
3346        collect_tree_objects_filtered(
3347            repo,
3348            commit.tree,
3349            "",
3350            0,
3351            false,
3352            Some(&parent_union),
3353            &mut tree_state,
3354            &mut emitted,
3355            &mut result,
3356            &mut omitted,
3357            &mut missing,
3358            &mut missing_seen,
3359            filter,
3360            filter_provided,
3361            missing_action,
3362            sparse_lines,
3363            skip_trees_for_type_filter,
3364            omit_object_paths,
3365            packed_set,
3366        )?;
3367        counts.push(result.len() - before);
3368    }
3369
3370    for root in object_roots {
3371        collect_root_object(
3372            repo,
3373            root,
3374            &mut tree_state,
3375            &mut emitted,
3376            &mut result,
3377            &mut omitted,
3378            &mut missing,
3379            &mut missing_seen,
3380            filter,
3381            filter_provided,
3382            missing_action,
3383            sparse_lines,
3384            skip_trees_for_type_filter,
3385            omit_object_paths,
3386            packed_set,
3387        )?;
3388    }
3389
3390    Ok((result, omitted, missing, counts))
3391}
3392
3393/// Collect OIDs of all objects in packs that have a `.keep` file.
3394fn kept_object_ids(repo: &Repository) -> Result<HashSet<ObjectId>> {
3395    let pack_dir = repo.git_dir.join("objects/pack");
3396    let mut kept = HashSet::new();
3397    if !pack_dir.is_dir() {
3398        return Ok(kept);
3399    }
3400    for entry in std::fs::read_dir(&pack_dir)? {
3401        let entry = entry?;
3402        let path = entry.path();
3403        if path.extension().is_some_and(|ext| ext == "keep") {
3404            // Find the corresponding .idx file
3405            let idx_path = path.with_extension("idx");
3406            if idx_path.exists() {
3407                if let Ok(oids) = crate::pack::read_idx_object_ids(&idx_path) {
3408                    kept.extend(oids);
3409                }
3410            }
3411        }
3412    }
3413    Ok(kept)
3414}
3415
3416fn flatten_tree(
3417    repo: &Repository,
3418    tree_oid: ObjectId,
3419    prefix: &str,
3420) -> Result<Vec<(String, ObjectId)>> {
3421    let mut result = Vec::new();
3422    let object = match repo.odb.read(&tree_oid) {
3423        Ok(o) => o,
3424        Err(_) => return Ok(result),
3425    };
3426    if object.kind != ObjectKind::Tree {
3427        return Ok(result);
3428    }
3429    let entries = parse_tree(&object.data)?;
3430    for entry in entries {
3431        let name = String::from_utf8_lossy(&entry.name).to_string();
3432        let path = if prefix.is_empty() {
3433            name
3434        } else {
3435            format!("{prefix}/{name}")
3436        };
3437        let child = match repo.odb.read(&entry.oid) {
3438            Ok(o) => o,
3439            Err(Error::ObjectNotFound(_)) => continue,
3440            Err(err) => return Err(err),
3441        };
3442        if child.kind == ObjectKind::Tree {
3443            result.extend(flatten_tree(repo, entry.oid, &path)?);
3444        } else {
3445            result.push((path, entry.oid));
3446        }
3447    }
3448    Ok(result)
3449}
3450
3451/// Compute merge bases between two commits.
3452pub fn merge_bases(
3453    repo: &Repository,
3454    a: ObjectId,
3455    b: ObjectId,
3456    first_parent_only: bool,
3457) -> Result<Vec<ObjectId>> {
3458    let mut graph = CommitGraph::new(repo, first_parent_only);
3459    let ancestors_a = walk_closure(&mut graph, &[a])?;
3460    let ancestors_b = walk_closure(&mut graph, &[b])?;
3461    let common: HashSet<ObjectId> = ancestors_a.intersection(&ancestors_b).copied().collect();
3462    if common.is_empty() {
3463        return Ok(Vec::new());
3464    }
3465    // Merge bases: common ancestors not dominated by other common ancestors
3466    let mut bases = Vec::new();
3467    for &c in &common {
3468        let is_dominated = common.iter().any(|&other| {
3469            if other == c {
3470                return false;
3471            }
3472            let other_anc = walk_closure(&mut graph, &[other]).unwrap_or_default();
3473            other_anc.contains(&c)
3474        });
3475        if !is_dominated {
3476            bases.push(c);
3477        }
3478    }
3479    if bases.is_empty() {
3480        let mut sorted: Vec<_> = common.into_iter().collect();
3481        sorted.sort_by_key(|b| std::cmp::Reverse(graph.committer_time(*b)));
3482        bases.push(sorted[0]);
3483    }
3484    Ok(bases)
3485}