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;
9use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
10use std::fs;
11use std::path::Path;
12
13use crate::error::{Error, Result};
14use crate::objects::{parse_commit, parse_tag, parse_tree, ObjectId, ObjectKind};
15use crate::refs;
16use crate::repo::Repository;
17use crate::rev_parse::resolve_revision;
18
19/// User-facing output mode for `rev-list`.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum OutputMode {
22    /// Print only object IDs.
23    OidOnly,
24    /// Print object ID followed by all parent IDs.
25    Parents,
26    /// Print a custom `%` placeholder format.
27    Format(String),
28}
29
30/// Behavior when reachable objects are missing.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MissingAction {
33    /// Fail traversal when a referenced object is missing.
34    Error,
35    /// Continue traversal and report each missing object.
36    Print,
37    /// Continue traversal and silently ignore missing objects.
38    Allow,
39}
40
41/// Object filter specification for `--filter=`.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ObjectFilter {
44    /// `blob:none` — omit all blobs.
45    BlobNone,
46    /// `blob:limit=<n>` — omit blobs larger than `n` bytes.
47    BlobLimit(u64),
48    /// `tree:<depth>` — omit trees deeper than `depth`.
49    TreeDepth(u64),
50    /// `object:type=<kind>` — only emit objects of that kind with `--objects`.
51    ObjectType(ObjectKind),
52    /// `combine:<filter>+<filter>+…` — apply multiple filters.
53    Combine(Vec<ObjectFilter>),
54}
55
56impl ObjectFilter {
57    /// Parse a `--filter=<spec>` value.
58    pub fn parse(spec: &str) -> std::result::Result<Self, String> {
59        if spec == "blob:none" {
60            return Ok(ObjectFilter::BlobNone);
61        }
62        if let Some(rest) = spec.strip_prefix("blob:limit=") {
63            let bytes = parse_size_suffix(rest)
64                .ok_or_else(|| format!("invalid blob:limit value: {rest}"))?;
65            return Ok(ObjectFilter::BlobLimit(bytes));
66        }
67        if let Some(rest) = spec.strip_prefix("tree:") {
68            let depth: u64 = rest
69                .parse()
70                .map_err(|_| format!("invalid tree depth: {rest}"))?;
71            return Ok(ObjectFilter::TreeDepth(depth));
72        }
73        if let Some(rest) = spec.strip_prefix("object:type=") {
74            let kind = match rest {
75                "blob" => ObjectKind::Blob,
76                "tree" => ObjectKind::Tree,
77                "commit" => ObjectKind::Commit,
78                "tag" => ObjectKind::Tag,
79                _ => return Err(format!("unsupported filter spec: {spec}")),
80            };
81            return Ok(ObjectFilter::ObjectType(kind));
82        }
83        if let Some(rest) = spec.strip_prefix("combine:") {
84            let parts = split_combine(rest);
85            let mut filters = Vec::new();
86            for part in parts {
87                filters.push(ObjectFilter::parse(&part)?);
88            }
89            return Ok(ObjectFilter::Combine(filters));
90        }
91        Err(format!("unsupported filter spec: {spec}"))
92    }
93
94    /// Whether a blob encountered during a tree walk should appear in `--objects` output.
95    #[must_use]
96    pub fn emit_blob(&self, size: u64) -> bool {
97        match self {
98            ObjectFilter::BlobNone => false,
99            ObjectFilter::BlobLimit(limit) => size <= *limit,
100            ObjectFilter::TreeDepth(_) => true,
101            ObjectFilter::ObjectType(kind) => *kind == ObjectKind::Blob,
102            ObjectFilter::Combine(filters) => filters.iter().all(|f| f.emit_blob(size)),
103        }
104    }
105
106    /// Whether a tree at `depth` should appear in `--objects` output.
107    #[must_use]
108    pub fn emit_tree(&self, depth: u64) -> bool {
109        match self {
110            ObjectFilter::BlobNone => true,
111            ObjectFilter::BlobLimit(_) => true,
112            ObjectFilter::TreeDepth(max_depth) => depth < *max_depth,
113            ObjectFilter::ObjectType(kind) => *kind == ObjectKind::Tree,
114            ObjectFilter::Combine(filters) => filters.iter().all(|f| f.emit_tree(depth)),
115        }
116    }
117
118    /// Check if a blob should be included given its size.
119    pub fn includes_blob(&self, size: u64) -> bool {
120        self.emit_blob(size)
121    }
122
123    /// Check if a tree at given depth should be included.
124    pub fn includes_tree(&self, depth: u64) -> bool {
125        self.emit_tree(depth)
126    }
127
128    /// Whether an object passes this filter for direct OID lookup (`git cat-file --filter`).
129    #[must_use]
130    pub fn passes_for_object(&self, kind: ObjectKind, size: usize) -> bool {
131        let sz = size as u64;
132        match self {
133            ObjectFilter::BlobNone => kind != ObjectKind::Blob,
134            ObjectFilter::BlobLimit(limit) => kind != ObjectKind::Blob || sz <= *limit,
135            ObjectFilter::TreeDepth(_) => true,
136            ObjectFilter::ObjectType(expected) => kind == *expected,
137            ObjectFilter::Combine(filters) => {
138                filters.iter().all(|f| f.passes_for_object(kind, size))
139            }
140        }
141    }
142}
143
144/// Parse a size with optional k/m/g suffix.
145fn parse_size_suffix(s: &str) -> Option<u64> {
146    let s = s.trim();
147    if s.is_empty() {
148        return None;
149    }
150    let (num_str, multiplier) = match s.as_bytes().last()? {
151        b'k' | b'K' => (&s[..s.len() - 1], 1024u64),
152        b'm' | b'M' => (&s[..s.len() - 1], 1024 * 1024),
153        b'g' | b'G' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
154        _ => (s, 1u64),
155    };
156    let num: u64 = num_str.parse().ok()?;
157    Some(num * multiplier)
158}
159
160/// Split a combine filter spec on `+`, handling URL encoding.
161fn split_combine(spec: &str) -> Vec<String> {
162    let mut parts = Vec::new();
163    let mut current = String::new();
164    let chars = spec.chars().peekable();
165    for ch in chars {
166        if ch == '+' {
167            if !current.is_empty() {
168                parts.push(url_decode(&current));
169                current.clear();
170            }
171        } else {
172            current.push(ch);
173        }
174    }
175    if !current.is_empty() {
176        parts.push(url_decode(&current));
177    }
178    parts
179}
180
181/// Simple URL percent-decoding.
182fn url_decode(s: &str) -> String {
183    let mut result = String::new();
184    let mut chars = s.chars();
185    while let Some(ch) = chars.next() {
186        if ch == '%' {
187            let hi = chars.next().unwrap_or('0');
188            let lo = chars.next().unwrap_or('0');
189            let byte = u8::from_str_radix(&format!("{hi}{lo}"), 16).unwrap_or(b'?');
190            result.push(byte as char);
191        } else {
192            result.push(ch);
193        }
194    }
195    result
196}
197
198/// Ordering mode for commit output.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum OrderingMode {
201    /// Reverse-chronological by commit date.
202    Default,
203    /// Topological ordering with date tie-breaks.
204    Topo,
205    /// Date-order variant (same constraints as topo for this subset).
206    Date,
207}
208
209/// Parsed and normalized options for rev-list traversal.
210#[derive(Debug, Clone)]
211pub struct RevListOptions {
212    /// Include all refs (`--all`) as positive tips.
213    pub all_refs: bool,
214    /// Follow only first parent when walking merges.
215    pub first_parent: bool,
216    /// Enable ancestry-path filtering.
217    pub ancestry_path: bool,
218    /// Optional explicit ancestry-path pivot commits.
219    pub ancestry_path_bottoms: Vec<ObjectId>,
220    /// Keep only decorated commits after traversal.
221    pub simplify_by_decoration: bool,
222    /// Commit output mode.
223    pub output_mode: OutputMode,
224    /// Suppress commit output.
225    pub quiet: bool,
226    /// Print only final count.
227    pub count: bool,
228    /// Skip N commits from selected list.
229    pub skip: usize,
230    /// Optional maximum selected commits.
231    pub max_count: Option<usize>,
232    /// Ordering strategy.
233    pub ordering: OrderingMode,
234    /// Reverse selected output order.
235    pub reverse: bool,
236    /// List reachable objects (trees, blobs) in addition to commits.
237    pub objects: bool,
238    /// Suppress object path names in --objects output.
239    pub no_object_names: bool,
240    /// Show boundary commits with `-` prefix.
241    pub boundary: bool,
242    /// Show left/right markers for symmetric diff.
243    pub left_right: bool,
244    /// Filter to left-only commits in symmetric diff.
245    pub left_only: bool,
246    /// Filter to right-only commits in symmetric diff.
247    pub right_only: bool,
248    /// Cherry-mark equivalent commits with `=` instead of `+`.
249    pub cherry_mark: bool,
250    /// Cherry-pick: omit equivalent commits from output.
251    pub cherry_pick: bool,
252    /// Minimum number of parents a commit must have to be included.
253    pub min_parents: Option<usize>,
254    /// Maximum number of parents a commit may have to be included.
255    pub max_parents: Option<usize>,
256    /// Symmetric-diff left OID (set by caller when A...B is used).
257    pub symmetric_left: Option<ObjectId>,
258    /// Symmetric-diff right OID (set by caller when A...B is used).
259    pub symmetric_right: Option<ObjectId>,
260    /// Path filters (files after `--`).
261    pub paths: Vec<String>,
262    /// Show full history (don't simplify) for path-limited walks.
263    pub full_history: bool,
264    /// Sparse mode: don't prune non-matching commits.
265    pub sparse: bool,
266    /// Object filter for `--filter=<spec>`.
267    pub filter: Option<ObjectFilter>,
268    /// Print omitted objects prefixed with `~`.
269    pub filter_print_omitted: bool,
270    /// Emit objects interleaved with their introducing commit.
271    pub in_commit_order: bool,
272    /// Exclude objects in `.keep` pack files.
273    pub no_kept_objects: bool,
274    /// Behavior when referenced objects are missing.
275    pub missing_action: MissingAction,
276}
277
278impl Default for RevListOptions {
279    fn default() -> Self {
280        Self {
281            all_refs: false,
282            first_parent: false,
283            ancestry_path: false,
284            ancestry_path_bottoms: Vec::new(),
285            simplify_by_decoration: false,
286            output_mode: OutputMode::OidOnly,
287            quiet: false,
288            count: false,
289            skip: 0,
290            max_count: None,
291            ordering: OrderingMode::Default,
292            reverse: false,
293            objects: false,
294            no_object_names: false,
295            boundary: false,
296            left_right: false,
297            left_only: false,
298            right_only: false,
299            cherry_mark: false,
300            cherry_pick: false,
301            min_parents: None,
302            max_parents: None,
303            symmetric_left: None,
304            symmetric_right: None,
305            paths: Vec::new(),
306            full_history: false,
307            sparse: false,
308            filter: None,
309            filter_print_omitted: false,
310            in_commit_order: false,
311            no_kept_objects: false,
312            missing_action: MissingAction::Error,
313        }
314    }
315}
316
317/// Final commit selection result.
318#[derive(Debug, Clone)]
319pub struct RevListResult {
320    /// Selected commits in final output order, after skip/max/reverse.
321    pub commits: Vec<ObjectId>,
322    /// Reachable non-commit objects when `--objects` is active.
323    /// Each entry is `(oid, optional_path)`.
324    pub objects: Vec<(ObjectId, String)>,
325    /// Objects omitted by `--filter` (for `--filter-print-omitted`).
326    pub omitted_objects: Vec<ObjectId>,
327    /// Referenced objects missing from the object database.
328    pub missing_objects: Vec<ObjectId>,
329    /// Boundary commits (excluded parents shown with `-` prefix).
330    pub boundary_commits: Vec<ObjectId>,
331    /// For `--left-right`: mapping commit OID -> true=left, false=right.
332    pub left_right_map: HashMap<ObjectId, bool>,
333    /// For `--cherry-mark`: set of commits that are equivalent (patch-id match).
334    pub cherry_equivalent: HashSet<ObjectId>,
335    /// Per-commit object counts (parallel to `commits`) for `--in-commit-order`.
336    /// When non-empty, `objects[sum(counts[..i])..sum(counts[..=i])]` are the objects
337    /// introduced by `commits[i]`.
338    pub per_commit_object_counts: Vec<usize>,
339}
340
341/// Resolve and walk revisions for the requested options.
342///
343/// # Parameters
344///
345/// - `repo` - repository used for ref/object lookup.
346/// - `positive_specs` - positive revision tokens (e.g. `HEAD`, `A..B` rhs).
347/// - `negative_specs` - negative revision tokens (`^A`, `A..B` lhs).
348/// - `options` - traversal and output selection options.
349///
350/// # Errors
351///
352/// Returns [`Error::ObjectNotFound`] / [`Error::InvalidRef`] for bad revision
353/// specs and [`Error::CorruptObject`] for non-commit or malformed commit data.
354pub fn rev_list(
355    repo: &Repository,
356    positive_specs: &[String],
357    negative_specs: &[String],
358    options: &RevListOptions,
359) -> Result<RevListResult> {
360    let mut graph = CommitGraph::new(repo, options.first_parent);
361
362    let (mut include, object_roots) = if options.objects {
363        let (commit_starts, roots) = resolve_specs_for_objects(repo, positive_specs)?;
364        (commit_starts, roots)
365    } else {
366        (resolve_specs(repo, positive_specs)?, Vec::new())
367    };
368    let exclude = resolve_specs(repo, negative_specs)?;
369
370    if options.all_refs {
371        include.extend(all_ref_tips(repo)?);
372    }
373
374    if include.is_empty() && object_roots.is_empty() {
375        return Err(Error::InvalidRef("no revisions specified".to_owned()));
376    }
377
378    let (mut included, discovery_order) = if include.is_empty() {
379        (HashSet::new(), Vec::new())
380    } else {
381        walk_closure_ordered(&mut graph, &include)?
382    };
383    let excluded = if exclude.is_empty() {
384        HashSet::new()
385    } else {
386        walk_closure(&mut graph, &exclude)?
387    };
388    included.retain(|oid| !excluded.contains(oid));
389
390    if options.simplify_by_decoration {
391        let decorated = all_ref_tips(repo)?;
392        included.retain(|oid| decorated.contains(oid));
393    }
394
395    if options.ancestry_path {
396        let mut bottoms = options.ancestry_path_bottoms.clone();
397        if bottoms.is_empty() {
398            bottoms.extend(exclude.iter().copied());
399        }
400        if bottoms.is_empty() {
401            return Err(Error::InvalidRef(
402                "--ancestry-path requires a range with excluded tips".to_owned(),
403            ));
404        }
405        limit_to_ancestry(&mut graph, &mut included, &bottoms)?;
406    }
407
408    // Filter by parent count (--merges, --no-merges, --min-parents, --max-parents)
409    if options.min_parents.is_some() || options.max_parents.is_some() {
410        let min_p = options.min_parents.unwrap_or(0);
411        let max_p = options.max_parents.unwrap_or(usize::MAX);
412        included.retain(|oid| {
413            let count = graph.parents_of(*oid).map(|p| p.len()).unwrap_or(0);
414            count >= min_p && count <= max_p
415        });
416    }
417
418    let mut ordered = match options.ordering {
419        OrderingMode::Default => sort_by_commit_date_desc(&mut graph, &included, &discovery_order)?,
420        OrderingMode::Topo | OrderingMode::Date => topo_sort(&mut graph, &included)?,
421    };
422
423    // Path filtering: keep only commits that modify given paths
424    if !options.paths.is_empty() {
425        let paths = &options.paths;
426        ordered.retain(|oid| {
427            commit_touches_paths(
428                repo,
429                &mut graph,
430                *oid,
431                paths,
432                options.full_history,
433                options.sparse,
434            )
435            .unwrap_or(false)
436        });
437    }
438
439    // Left-right classification for symmetric diffs
440    let mut left_right_map = HashMap::new();
441    if options.left_right
442        || options.left_only
443        || options.right_only
444        || options.cherry_mark
445        || options.cherry_pick
446    {
447        if let (Some(left_oid), Some(right_oid)) = (options.symmetric_left, options.symmetric_right)
448        {
449            let left_closure = walk_closure(&mut graph, &[left_oid])?;
450            let right_closure = walk_closure(&mut graph, &[right_oid])?;
451            for &oid in &ordered {
452                let in_left = left_closure.contains(&oid);
453                let in_right = right_closure.contains(&oid);
454                if in_left && !in_right {
455                    left_right_map.insert(oid, true);
456                } else if in_right && !in_left {
457                    left_right_map.insert(oid, false);
458                } else {
459                    left_right_map.insert(oid, false);
460                }
461            }
462        }
463    }
464
465    // Cherry-pick / cherry-mark: compute patch-ids and find equivalents
466    let mut cherry_equivalent = HashSet::new();
467    if options.cherry_pick || options.cherry_mark {
468        let patch_ids = compute_patch_ids(repo, &mut graph, &ordered)?;
469        let left_commits: Vec<_> = ordered
470            .iter()
471            .filter(|o| left_right_map.get(o) == Some(&true))
472            .copied()
473            .collect();
474        let right_commits: Vec<_> = ordered
475            .iter()
476            .filter(|o| left_right_map.get(o) == Some(&false))
477            .copied()
478            .collect();
479        let left_patches: HashMap<&str, ObjectId> = left_commits
480            .iter()
481            .filter_map(|o| patch_ids.get(o).map(|p| (p.as_str(), *o)))
482            .collect();
483        let right_patches: HashMap<&str, ObjectId> = right_commits
484            .iter()
485            .filter_map(|o| patch_ids.get(o).map(|p| (p.as_str(), *o)))
486            .collect();
487        for (&pid, &oid) in &left_patches {
488            if !pid.is_empty() && right_patches.contains_key(pid) {
489                cherry_equivalent.insert(oid);
490                cherry_equivalent.insert(right_patches[pid]);
491            }
492        }
493        for (&pid, &oid) in &right_patches {
494            if !pid.is_empty() && left_patches.contains_key(pid) {
495                cherry_equivalent.insert(oid);
496                cherry_equivalent.insert(left_patches[pid]);
497            }
498        }
499    }
500
501    // Filter left-only / right-only
502    if options.left_only {
503        ordered.retain(|oid| left_right_map.get(oid) == Some(&true));
504    }
505    if options.right_only {
506        ordered.retain(|oid| left_right_map.get(oid) == Some(&false));
507    }
508
509    // Cherry-pick: remove equivalent commits
510    if options.cherry_pick {
511        ordered.retain(|oid| !cherry_equivalent.contains(oid));
512    }
513
514    if options.skip > 0 {
515        ordered = ordered.into_iter().skip(options.skip).collect();
516    }
517    if let Some(max_count) = options.max_count {
518        ordered.truncate(max_count);
519    }
520    if options.reverse {
521        ordered.reverse();
522    }
523
524    // Collect boundary commits: parents of included commits that are in the excluded set
525    let boundary_commits = if options.boundary {
526        let included_set: HashSet<ObjectId> = ordered.iter().copied().collect();
527        let mut boundary = Vec::new();
528        let mut boundary_seen = HashSet::new();
529        for &oid in &ordered {
530            if let Ok(parents) = graph.parents_of(oid).map(|p| p.to_vec()) {
531                for parent in parents {
532                    if !included_set.contains(&parent) && boundary_seen.insert(parent) {
533                        boundary.push(parent);
534                    }
535                }
536            }
537        }
538        boundary
539    } else {
540        Vec::new()
541    };
542
543    // Filter kept objects when --no-kept-objects is set
544    let kept_set = if options.no_kept_objects {
545        kept_object_ids(repo).unwrap_or_default()
546    } else {
547        HashSet::new()
548    };
549
550    if options.no_kept_objects {
551        ordered.retain(|oid| !kept_set.contains(oid));
552    }
553
554    // Collect reachable objects if --objects
555    let (objects, omitted_objects, missing_objects, per_commit_object_counts) = if options.objects {
556        let (mut objs, omit, miss, counts) = if options.in_commit_order {
557            collect_reachable_objects_in_commit_order(
558                repo,
559                &mut graph,
560                &ordered,
561                &object_roots,
562                options.filter.as_ref(),
563                options.missing_action,
564            )?
565        } else {
566            let (objs, omit, miss) = collect_reachable_objects(
567                repo,
568                &mut graph,
569                &ordered,
570                &object_roots,
571                options.filter.as_ref(),
572                options.missing_action,
573            )?;
574            (objs, omit, miss, Vec::new())
575        };
576        if options.no_kept_objects {
577            objs.retain(|(oid, _)| !kept_set.contains(oid));
578        }
579        (objs, omit, miss, counts)
580    } else {
581        (Vec::new(), Vec::new(), Vec::new(), Vec::new())
582    };
583
584    Ok(RevListResult {
585        commits: ordered,
586        objects,
587        omitted_objects,
588        missing_objects,
589        boundary_commits,
590        left_right_map,
591        cherry_equivalent,
592        per_commit_object_counts,
593    })
594}
595
596/// Parse a raw revision token into positive and negative specs.
597///
598/// Supports:
599/// - `<a>..<b>` => negative `<a>`, positive `<b>`
600/// - `^<rev>` => negative `<rev>`
601/// - `<rev>` => positive `<rev>`
602#[must_use]
603pub fn split_revision_token(token: &str) -> (Vec<String>, Vec<String>) {
604    if let Some((lhs, rhs)) = token.split_once("..") {
605        let positive = if rhs.is_empty() {
606            "HEAD".to_owned()
607        } else {
608            rhs.to_owned()
609        };
610        let negative = if lhs.is_empty() {
611            "HEAD".to_owned()
612        } else {
613            lhs.to_owned()
614        };
615        return (vec![positive], vec![negative]);
616    }
617    if let Some(rest) = token.strip_prefix('^') {
618        return (Vec::new(), vec![rest.to_owned()]);
619    }
620    (vec![token.to_owned()], Vec::new())
621}
622
623fn ansi_color_from_name(name: &str) -> String {
624    match name {
625        "red" => "\x1b[31m".to_owned(),
626        "green" => "\x1b[32m".to_owned(),
627        "yellow" => "\x1b[33m".to_owned(),
628        "blue" => "\x1b[34m".to_owned(),
629        "magenta" => "\x1b[35m".to_owned(),
630        "cyan" => "\x1b[36m".to_owned(),
631        "white" => "\x1b[37m".to_owned(),
632        "bold" => "\x1b[1m".to_owned(),
633        "dim" => "\x1b[2m".to_owned(),
634        "ul" | "underline" => "\x1b[4m".to_owned(),
635        "blink" => "\x1b[5m".to_owned(),
636        "reverse" => "\x1b[7m".to_owned(),
637        "reset" => "\x1b[m".to_owned(),
638        _ => String::new(),
639    }
640}
641
642fn color_name_to_code(name: &str) -> Option<u8> {
643    match name {
644        "black" => Some(0),
645        "red" => Some(1),
646        "green" => Some(2),
647        "yellow" => Some(3),
648        "blue" => Some(4),
649        "magenta" => Some(5),
650        "cyan" => Some(6),
651        "white" => Some(7),
652        "default" => Some(9),
653        _ => None,
654    }
655}
656
657fn ansi_color_from_spec(spec: &str) -> String {
658    if spec == "reset" {
659        return "\x1b[m".to_owned();
660    }
661    let mut codes = Vec::new();
662    let mut fg_set = false;
663    for part in spec.split_whitespace() {
664        match part {
665            "bold" => codes.push("1".to_owned()),
666            "dim" => codes.push("2".to_owned()),
667            "italic" => codes.push("3".to_owned()),
668            "ul" | "underline" => codes.push("4".to_owned()),
669            "blink" => codes.push("5".to_owned()),
670            "reverse" => codes.push("7".to_owned()),
671            "strike" => codes.push("9".to_owned()),
672            "nobold" | "nodim" => codes.push("22".to_owned()),
673            "noitalic" => codes.push("23".to_owned()),
674            "noul" | "nounderline" => codes.push("24".to_owned()),
675            "noblink" => codes.push("25".to_owned()),
676            "noreverse" => codes.push("27".to_owned()),
677            "nostrike" => codes.push("29".to_owned()),
678            _ => {
679                if let Some(code) = color_name_to_code(part) {
680                    if !fg_set {
681                        codes.push(format!("{}", 30 + code));
682                        fg_set = true;
683                    } else {
684                        codes.push(format!("{}", 40 + code));
685                    }
686                }
687            }
688        }
689    }
690    if codes.is_empty() {
691        String::new()
692    } else {
693        format!("\x1b[{}m", codes.join(";"))
694    }
695}
696
697fn format_relative_date(diff: i64) -> String {
698    if diff < 0 {
699        "in the future".to_owned()
700    } else if diff < 60 {
701        format!("{} seconds ago", diff)
702    } else if diff < 3600 {
703        let m = diff / 60;
704        if m == 1 {
705            "1 minute ago".to_owned()
706        } else {
707            format!("{m} minutes ago")
708        }
709    } else if diff < 86400 {
710        let h = diff / 3600;
711        if h == 1 {
712            "1 hour ago".to_owned()
713        } else {
714            format!("{h} hours ago")
715        }
716    } else if diff < 86400 * 30 {
717        let d = diff / 86400;
718        if d == 1 {
719            "1 day ago".to_owned()
720        } else {
721            format!("{d} days ago")
722        }
723    } else if diff < 86400 * 365 {
724        let months = diff / (86400 * 30);
725        if months == 1 {
726            "1 month ago".to_owned()
727        } else {
728            format!("{months} months ago")
729        }
730    } else {
731        let years = diff / (86400 * 365);
732        if years == 1 {
733            "1 year ago".to_owned()
734        } else {
735            format!("{years} years ago")
736        }
737    }
738}
739
740/// Render one commit according to the selected output mode.
741///
742/// # Errors
743///
744/// Returns object decode errors when commit metadata is required.
745pub fn render_commit(
746    repo: &Repository,
747    oid: ObjectId,
748    mode: &OutputMode,
749    abbrev_len: usize,
750) -> Result<String> {
751    render_commit_with_color(repo, oid, mode, abbrev_len, false)
752}
753
754/// Render one commit, optionally with ANSI color for `%C` placeholders.
755pub fn render_commit_with_color(
756    repo: &Repository,
757    oid: ObjectId,
758    mode: &OutputMode,
759    abbrev_len: usize,
760    use_color: bool,
761) -> Result<String> {
762    match mode {
763        OutputMode::OidOnly => Ok(format!("{oid}")),
764        OutputMode::Parents => {
765            let mut out = format!("{oid}");
766            let commit = load_commit(repo, oid)?;
767            for parent in commit.parents {
768                out.push(' ');
769                out.push_str(&parent.to_hex());
770            }
771            Ok(out)
772        }
773        OutputMode::Format(fmt) => {
774            let commit = load_commit(repo, oid)?;
775            let subject = commit.message.lines().next().unwrap_or_default();
776            let hex = oid.to_hex();
777
778            // Handle named pretty formats
779            match fmt.as_str() {
780                "oneline" => {
781                    return Ok(format!("{} {}", hex, subject));
782                }
783                "short" => {
784                    fn fmt_ident(ident: &str) -> String {
785                        let name = if let Some(bracket) = ident.find('<') {
786                            ident[..bracket].trim()
787                        } else {
788                            ident.trim()
789                        };
790                        let email = if let Some(start) = ident.find('<') {
791                            if let Some(end) = ident.find('>') {
792                                &ident[start..=end]
793                            } else {
794                                ""
795                            }
796                        } else {
797                            ""
798                        };
799                        format!("{} {}", name, email)
800                    }
801                    let mut out = String::new();
802                    out.push_str(&format!("Author: {}\n", fmt_ident(&commit.author)));
803                    out.push('\n');
804                    out.push_str(&format!("    {}\n", subject));
805                    out.push('\n');
806                    return Ok(out);
807                }
808                "medium" => {
809                    fn extract_ident_display(ident: &str) -> String {
810                        let name = if let Some(bracket) = ident.find('<') {
811                            ident[..bracket].trim()
812                        } else {
813                            ident.trim()
814                        };
815                        let email = if let Some(start) = ident.find('<') {
816                            if let Some(end) = ident.find('>') {
817                                &ident[start..=end]
818                            } else {
819                                ""
820                            }
821                        } else {
822                            ""
823                        };
824                        format!("{} {}", name, email)
825                    }
826                    fn format_default_date(ident: &str) -> String {
827                        let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
828                        if parts.len() < 2 {
829                            return String::new();
830                        }
831                        let ts_str = parts[1];
832                        let offset_str = parts[0];
833                        let ts: i64 = match ts_str.parse() {
834                            Ok(v) => v,
835                            Err(_) => return format!("{ts_str} {offset_str}"),
836                        };
837                        let tz_bytes = offset_str.as_bytes();
838                        let tz_secs: i64 = if tz_bytes.len() >= 5 {
839                            let sign = if tz_bytes[0] == b'-' { -1i64 } else { 1i64 };
840                            let h: i64 = offset_str[1..3].parse().unwrap_or(0);
841                            let m: i64 = offset_str[3..5].parse().unwrap_or(0);
842                            sign * (h * 3600 + m * 60)
843                        } else {
844                            0
845                        };
846                        let adjusted = ts + tz_secs;
847                        let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
848                            .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
849                        let weekday = match dt.weekday() {
850                            time::Weekday::Monday => "Mon",
851                            time::Weekday::Tuesday => "Tue",
852                            time::Weekday::Wednesday => "Wed",
853                            time::Weekday::Thursday => "Thu",
854                            time::Weekday::Friday => "Fri",
855                            time::Weekday::Saturday => "Sat",
856                            time::Weekday::Sunday => "Sun",
857                        };
858                        let month = match dt.month() {
859                            time::Month::January => "Jan",
860                            time::Month::February => "Feb",
861                            time::Month::March => "Mar",
862                            time::Month::April => "Apr",
863                            time::Month::May => "May",
864                            time::Month::June => "Jun",
865                            time::Month::July => "Jul",
866                            time::Month::August => "Aug",
867                            time::Month::September => "Sep",
868                            time::Month::October => "Oct",
869                            time::Month::November => "Nov",
870                            time::Month::December => "Dec",
871                        };
872                        format!(
873                            "{} {} {} {:02}:{:02}:{:02} {} {}",
874                            weekday,
875                            month,
876                            dt.day(),
877                            dt.hour(),
878                            dt.minute(),
879                            dt.second(),
880                            dt.year(),
881                            offset_str
882                        )
883                    }
884                    let mut out = String::new();
885                    out.push_str(&format!(
886                        "Author: {}\n",
887                        extract_ident_display(&commit.author)
888                    ));
889                    out.push_str(&format!(
890                        "Date:   {}\n",
891                        format_default_date(&commit.author)
892                    ));
893                    out.push('\n');
894                    for line in commit.message.lines() {
895                        out.push_str(&format!("    {}\n", line));
896                    }
897                    return Ok(out);
898                }
899                _ => {}
900            }
901
902            let raw_fmt = if let Some(t) = fmt.strip_prefix("format:") {
903                t
904            } else if let Some(t) = fmt.strip_prefix("tformat:") {
905                t
906            } else {
907                fmt.as_str()
908            };
909            // Body: everything after the first line (skip blank separator line)
910            let body = {
911                let mut lines = commit.message.lines();
912                lines.next(); // skip subject
913                              // Skip optional blank line after subject
914                if let Some(blank) = lines.next() {
915                    if blank.is_empty() {
916                        lines.collect::<Vec<_>>().join("\n")
917                    } else {
918                        std::iter::once(blank)
919                            .chain(lines)
920                            .collect::<Vec<_>>()
921                            .join("\n")
922                    }
923                } else {
924                    String::new()
925                }
926            };
927            let tree_hex = commit.tree.to_hex();
928            let parent_hexes: Vec<String> = commit.parents.iter().map(|p| p.to_hex()).collect();
929            let parent_abbrevs: Vec<String> = commit
930                .parents
931                .iter()
932                .map(|p| {
933                    let hex = p.to_hex();
934                    let n = abbrev_len.clamp(4, 40).min(hex.len());
935                    hex[..n].to_string()
936                })
937                .collect();
938
939            // Extract name/email components from ident strings
940            fn extract_name(ident: &str) -> &str {
941                if let Some(bracket) = ident.find('<') {
942                    ident[..bracket].trim()
943                } else {
944                    ident.trim()
945                }
946            }
947            fn extract_email(ident: &str) -> &str {
948                if let Some(start) = ident.find('<') {
949                    if let Some(end) = ident.find('>') {
950                        return &ident[start + 1..end];
951                    }
952                }
953                ""
954            }
955            fn extract_timestamp(ident: &str) -> &str {
956                let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
957                if parts.len() >= 2 {
958                    parts[1]
959                } else {
960                    ""
961                }
962            }
963            fn parse_ident_date(ident: &str) -> Option<(i64, &str)> {
964                let parts: Vec<&str> = ident.rsplitn(3, ' ').collect();
965                if parts.len() < 2 {
966                    return None;
967                }
968                let ts: i64 = parts[1].parse().ok()?;
969                Some((ts, parts[0]))
970            }
971            fn parse_tz(offset_str: &str) -> i64 {
972                let tz_bytes = offset_str.as_bytes();
973                if tz_bytes.len() >= 5 {
974                    let sign = if tz_bytes[0] == b'-' { -1i64 } else { 1i64 };
975                    let h: i64 = offset_str[1..3].parse().unwrap_or(0);
976                    let m: i64 = offset_str[3..5].parse().unwrap_or(0);
977                    sign * (h * 3600 + m * 60)
978                } else {
979                    0
980                }
981            }
982            fn weekday_str(dt: &time::OffsetDateTime) -> &'static str {
983                match dt.weekday() {
984                    time::Weekday::Monday => "Mon",
985                    time::Weekday::Tuesday => "Tue",
986                    time::Weekday::Wednesday => "Wed",
987                    time::Weekday::Thursday => "Thu",
988                    time::Weekday::Friday => "Fri",
989                    time::Weekday::Saturday => "Sat",
990                    time::Weekday::Sunday => "Sun",
991                }
992            }
993            fn month_str(dt: &time::OffsetDateTime) -> &'static str {
994                match dt.month() {
995                    time::Month::January => "Jan",
996                    time::Month::February => "Feb",
997                    time::Month::March => "Mar",
998                    time::Month::April => "Apr",
999                    time::Month::May => "May",
1000                    time::Month::June => "Jun",
1001                    time::Month::July => "Jul",
1002                    time::Month::August => "Aug",
1003                    time::Month::September => "Sep",
1004                    time::Month::October => "Oct",
1005                    time::Month::November => "Nov",
1006                    time::Month::December => "Dec",
1007                }
1008            }
1009            fn extract_email_local(ident: &str) -> &str {
1010                let email = extract_email(ident);
1011                if let Some(at) = email.find('@') {
1012                    &email[..at]
1013                } else {
1014                    email
1015                }
1016            }
1017            fn extract_date_default(ident: &str) -> String {
1018                let Some((ts, offset_str)) = parse_ident_date(ident) else {
1019                    return String::new();
1020                };
1021                let adjusted = ts + parse_tz(offset_str);
1022                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1023                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1024                format!(
1025                    "{} {} {} {:02}:{:02}:{:02} {} {}",
1026                    weekday_str(&dt),
1027                    month_str(&dt),
1028                    dt.day(),
1029                    dt.hour(),
1030                    dt.minute(),
1031                    dt.second(),
1032                    dt.year(),
1033                    offset_str
1034                )
1035            }
1036            fn extract_date_rfc2822(ident: &str) -> String {
1037                let Some((ts, offset_str)) = parse_ident_date(ident) else {
1038                    return String::new();
1039                };
1040                let adjusted = ts + parse_tz(offset_str);
1041                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1042                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1043                format!(
1044                    "{}, {} {} {} {:02}:{:02}:{:02} {}",
1045                    weekday_str(&dt),
1046                    dt.day(),
1047                    month_str(&dt),
1048                    dt.year(),
1049                    dt.hour(),
1050                    dt.minute(),
1051                    dt.second(),
1052                    offset_str
1053                )
1054            }
1055            fn extract_date_short(ident: &str) -> String {
1056                let Some((ts, offset_str)) = parse_ident_date(ident) else {
1057                    return String::new();
1058                };
1059                let adjusted = ts + parse_tz(offset_str);
1060                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1061                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1062                format!("{:04}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day())
1063            }
1064            fn extract_date_iso(ident: &str) -> String {
1065                let Some((ts, offset_str)) = parse_ident_date(ident) else {
1066                    return String::new();
1067                };
1068                let adjusted = ts + parse_tz(offset_str);
1069                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1070                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1071                format!(
1072                    "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {}",
1073                    dt.year(),
1074                    dt.month() as u8,
1075                    dt.day(),
1076                    dt.hour(),
1077                    dt.minute(),
1078                    dt.second(),
1079                    offset_str
1080                )
1081            }
1082
1083            // Alignment/truncation state for %<(N), %>(N), %><(N) directives
1084            #[derive(Clone, Copy)]
1085            enum Align {
1086                Left,
1087                Right,
1088                Center,
1089            }
1090            #[derive(Clone, Copy)]
1091            enum Trunc {
1092                None,
1093                Trunc,
1094                LTrunc,
1095                MTrunc,
1096            }
1097            struct ColSpec {
1098                width: usize,
1099                align: Align,
1100                trunc: Trunc,
1101            }
1102            fn apply_col(spec: &ColSpec, s: &str) -> String {
1103                let char_len = s.chars().count();
1104                if char_len > spec.width {
1105                    match spec.trunc {
1106                        Trunc::None => s.to_owned(),
1107                        Trunc::Trunc => {
1108                            let mut out: String =
1109                                s.chars().take(spec.width.saturating_sub(2)).collect();
1110                            out.push_str("..");
1111                            out
1112                        }
1113                        Trunc::LTrunc => {
1114                            let skip = char_len - spec.width + 2;
1115                            let mut out = String::from("..");
1116                            out.extend(s.chars().skip(skip));
1117                            out
1118                        }
1119                        Trunc::MTrunc => {
1120                            let keep = spec.width.saturating_sub(2);
1121                            let left_half = keep / 2;
1122                            let right_half = keep - left_half;
1123                            let mut out: String = s.chars().take(left_half).collect();
1124                            out.push_str("..");
1125                            out.extend(s.chars().skip(char_len - right_half));
1126                            out
1127                        }
1128                    }
1129                } else {
1130                    let pad = spec.width - char_len;
1131                    match spec.align {
1132                        Align::Left => {
1133                            let mut out = s.to_owned();
1134                            for _ in 0..pad {
1135                                out.push(' ');
1136                            }
1137                            out
1138                        }
1139                        Align::Right => {
1140                            let mut out = String::new();
1141                            for _ in 0..pad {
1142                                out.push(' ');
1143                            }
1144                            out.push_str(s);
1145                            out
1146                        }
1147                        Align::Center => {
1148                            let left = pad / 2;
1149                            let right = pad - left;
1150                            let mut out = String::new();
1151                            for _ in 0..left {
1152                                out.push(' ');
1153                            }
1154                            out.push_str(s);
1155                            for _ in 0..right {
1156                                out.push(' ');
1157                            }
1158                            out
1159                        }
1160                    }
1161                }
1162            }
1163            fn parse_col_spec(
1164                chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
1165                align: Align,
1166            ) -> Option<ColSpec> {
1167                // Consume '('
1168                if chars.peek() != Some(&'(') {
1169                    return None;
1170                }
1171                chars.next();
1172                let mut num_str = String::new();
1173                while let Some(&c) = chars.peek() {
1174                    if c.is_ascii_digit() {
1175                        num_str.push(c);
1176                        chars.next();
1177                    } else {
1178                        break;
1179                    }
1180                }
1181                let width: usize = num_str.parse().ok()?;
1182                let trunc = if chars.peek() == Some(&',') {
1183                    chars.next(); // consume comma
1184                    let mut mode = String::new();
1185                    while let Some(&c) = chars.peek() {
1186                        if c == ')' {
1187                            break;
1188                        }
1189                        mode.push(c);
1190                        chars.next();
1191                    }
1192                    match mode.as_str() {
1193                        "trunc" => Trunc::Trunc,
1194                        "ltrunc" => Trunc::LTrunc,
1195                        "mtrunc" => Trunc::MTrunc,
1196                        _ => Trunc::None,
1197                    }
1198                } else {
1199                    Trunc::None
1200                };
1201                // Consume ')'
1202                if chars.peek() == Some(&')') {
1203                    chars.next();
1204                }
1205                Some(ColSpec {
1206                    width,
1207                    align,
1208                    trunc,
1209                })
1210            }
1211
1212            let mut pending_col: Option<ColSpec> = None;
1213            let mut rendered = String::new();
1214            let mut chars = raw_fmt.chars().peekable();
1215            while let Some(ch) = chars.next() {
1216                if ch != '%' {
1217                    rendered.push(ch);
1218                    continue;
1219                }
1220                // Check for alignment directives: %<(...), %>(...), %><(...)
1221                if chars.peek() == Some(&'<') {
1222                    chars.next();
1223                    if let Some(spec) = parse_col_spec(&mut chars, Align::Left) {
1224                        pending_col = Some(spec);
1225                    }
1226                    continue;
1227                }
1228                if chars.peek() == Some(&'>') {
1229                    chars.next();
1230                    if chars.peek() == Some(&'<') {
1231                        chars.next(); // %><(...)
1232                        if let Some(spec) = parse_col_spec(&mut chars, Align::Center) {
1233                            pending_col = Some(spec);
1234                        }
1235                    } else if chars.peek() == Some(&'>') {
1236                        chars.next(); // %>>(...)
1237                        if let Some(spec) = parse_col_spec(&mut chars, Align::Right) {
1238                            pending_col = Some(spec);
1239                        }
1240                    } else if let Some(spec) = parse_col_spec(&mut chars, Align::Right) {
1241                        pending_col = Some(spec);
1242                    }
1243                    continue;
1244                }
1245
1246                // Helper macro-like: expand the placeholder, then apply pending_col
1247                let mut expanded = String::new();
1248                let target = if pending_col.is_some() {
1249                    &mut expanded
1250                } else {
1251                    &mut rendered
1252                };
1253                match chars.peek() {
1254                    Some('%') => {
1255                        chars.next();
1256                        target.push('%');
1257                    }
1258                    Some('H') => {
1259                        chars.next();
1260                        target.push_str(&oid.to_hex());
1261                    }
1262                    Some('h') => {
1263                        chars.next();
1264                        let hex = oid.to_hex();
1265                        let n = abbrev_len.clamp(4, 40).min(hex.len());
1266                        target.push_str(&hex[..n]);
1267                    }
1268                    Some('T') => {
1269                        chars.next();
1270                        target.push_str(&tree_hex);
1271                    }
1272                    Some('t') => {
1273                        chars.next();
1274                        let n = abbrev_len.clamp(4, 40).min(tree_hex.len());
1275                        target.push_str(&tree_hex[..n]);
1276                    }
1277                    Some('P') => {
1278                        chars.next();
1279                        target.push_str(&parent_hexes.join(" "));
1280                    }
1281                    Some('p') => {
1282                        chars.next();
1283                        target.push_str(&parent_abbrevs.join(" "));
1284                    }
1285                    Some('n') => {
1286                        chars.next();
1287                        target.push('\n');
1288                    }
1289                    Some('s') => {
1290                        chars.next();
1291                        target.push_str(subject);
1292                    }
1293                    Some('b') => {
1294                        chars.next();
1295                        target.push_str(&body);
1296                        if !body.is_empty() {
1297                            target.push('\n');
1298                        }
1299                    }
1300                    Some('B') => {
1301                        chars.next();
1302                        target.push_str(&commit.message);
1303                    }
1304                    Some('a') => {
1305                        chars.next();
1306                        match chars.next() {
1307                            Some('n') => target.push_str(extract_name(&commit.author)),
1308                            Some('N') => target.push_str(extract_name(&commit.author)),
1309                            Some('e') => target.push_str(extract_email(&commit.author)),
1310                            Some('E') => target.push_str(extract_email(&commit.author)),
1311                            Some('l') => target.push_str(extract_email_local(&commit.author)),
1312                            Some('d') => target.push_str(&extract_date_default(&commit.author)),
1313                            Some('D') => target.push_str(&extract_date_rfc2822(&commit.author)),
1314                            Some('t') => target.push_str(extract_timestamp(&commit.author)),
1315                            Some('s') => target.push_str(&extract_date_short(&commit.author)),
1316                            Some('i') => target.push_str(&extract_date_iso(&commit.author)),
1317                            Some('I') => {
1318                                let Some((ts, offset_str)) = parse_ident_date(&commit.author)
1319                                else {
1320                                    break;
1321                                };
1322                                let adjusted = ts + parse_tz(offset_str);
1323                                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1324                                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1325                                let sign_ch = if parse_tz(offset_str) >= 0 { '+' } else { '-' };
1326                                let abs_off = parse_tz(offset_str).unsigned_abs();
1327                                target.push_str(&format!(
1328                                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
1329                                    dt.year(),
1330                                    dt.month() as u8,
1331                                    dt.day(),
1332                                    dt.hour(),
1333                                    dt.minute(),
1334                                    dt.second(),
1335                                    sign_ch,
1336                                    abs_off / 3600,
1337                                    (abs_off % 3600) / 60
1338                                ));
1339                            }
1340                            Some('r') => {
1341                                let Some((ts, _)) = parse_ident_date(&commit.author) else {
1342                                    break;
1343                                };
1344                                let now = std::time::SystemTime::now()
1345                                    .duration_since(std::time::UNIX_EPOCH)
1346                                    .unwrap_or_default()
1347                                    .as_secs() as i64;
1348                                target.push_str(&format_relative_date(now - ts));
1349                            }
1350                            Some(other) => {
1351                                target.push('%');
1352                                target.push('a');
1353                                target.push(other);
1354                            }
1355                            None => {
1356                                target.push('%');
1357                                target.push('a');
1358                            }
1359                        }
1360                    }
1361                    Some('c') => {
1362                        chars.next();
1363                        match chars.next() {
1364                            Some('n') => target.push_str(extract_name(&commit.committer)),
1365                            Some('N') => target.push_str(extract_name(&commit.committer)),
1366                            Some('e') => target.push_str(extract_email(&commit.committer)),
1367                            Some('E') => target.push_str(extract_email(&commit.committer)),
1368                            Some('l') => target.push_str(extract_email_local(&commit.committer)),
1369                            Some('d') => target.push_str(&extract_date_default(&commit.committer)),
1370                            Some('D') => target.push_str(&extract_date_rfc2822(&commit.committer)),
1371                            Some('t') => target.push_str(extract_timestamp(&commit.committer)),
1372                            Some('s') => target.push_str(&extract_date_short(&commit.committer)),
1373                            Some('i') => target.push_str(&extract_date_iso(&commit.committer)),
1374                            Some('I') => {
1375                                let Some((ts, offset_str)) = parse_ident_date(&commit.committer)
1376                                else {
1377                                    break;
1378                                };
1379                                let adjusted = ts + parse_tz(offset_str);
1380                                let dt = time::OffsetDateTime::from_unix_timestamp(adjusted)
1381                                    .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1382                                let sign_ch = if parse_tz(offset_str) >= 0 { '+' } else { '-' };
1383                                let abs_off = parse_tz(offset_str).unsigned_abs();
1384                                target.push_str(&format!(
1385                                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
1386                                    dt.year(),
1387                                    dt.month() as u8,
1388                                    dt.day(),
1389                                    dt.hour(),
1390                                    dt.minute(),
1391                                    dt.second(),
1392                                    sign_ch,
1393                                    abs_off / 3600,
1394                                    (abs_off % 3600) / 60
1395                                ));
1396                            }
1397                            Some('r') => {
1398                                let Some((ts, _)) = parse_ident_date(&commit.committer) else {
1399                                    break;
1400                                };
1401                                let now = std::time::SystemTime::now()
1402                                    .duration_since(std::time::UNIX_EPOCH)
1403                                    .unwrap_or_default()
1404                                    .as_secs() as i64;
1405                                target.push_str(&format_relative_date(now - ts));
1406                            }
1407                            Some(other) => {
1408                                target.push('%');
1409                                target.push('c');
1410                                target.push(other);
1411                            }
1412                            None => {
1413                                target.push('%');
1414                                target.push('c');
1415                            }
1416                        }
1417                    }
1418                    Some('x') => {
1419                        // Hex escape: %xNN
1420                        chars.next();
1421                        let mut hex_str = String::new();
1422                        if let Some(&c1) = chars.peek() {
1423                            if c1.is_ascii_hexdigit() {
1424                                hex_str.push(c1);
1425                                chars.next();
1426                            }
1427                        }
1428                        if let Some(&c2) = chars.peek() {
1429                            if c2.is_ascii_hexdigit() {
1430                                hex_str.push(c2);
1431                                chars.next();
1432                            }
1433                        }
1434                        if let Ok(byte) = u8::from_str_radix(&hex_str, 16) {
1435                            target.push(byte as char);
1436                        }
1437                    }
1438                    Some('C') => {
1439                        chars.next();
1440                        if chars.peek() == Some(&'(') {
1441                            chars.next();
1442                            let mut spec = String::new();
1443                            for c in chars.by_ref() {
1444                                if c == ')' {
1445                                    break;
1446                                }
1447                                spec.push(c);
1448                            }
1449                            let (force, color_spec) =
1450                                if let Some(rest) = spec.strip_prefix("always,") {
1451                                    (true, rest)
1452                                } else if let Some(rest) = spec.strip_prefix("auto,") {
1453                                    (false, rest)
1454                                } else if spec == "auto" {
1455                                    if use_color {
1456                                        target.push_str("\x1b[m");
1457                                    }
1458                                    continue;
1459                                } else {
1460                                    (false, spec.as_str())
1461                                };
1462                            if use_color || force {
1463                                target.push_str(&ansi_color_from_spec(color_spec));
1464                            }
1465                        } else {
1466                            // Named colors: %Cred, %Cgreen, %Cblue, %Creset, %Cbold
1467                            // Must match known names only, not consume trailing text
1468                            let remaining: String = chars.clone().collect();
1469                            let known = [
1470                                "reset", "red", "green", "blue", "yellow", "magenta", "cyan",
1471                                "white", "bold", "dim", "ul",
1472                            ];
1473                            let mut matched = false;
1474                            for name in &known {
1475                                if remaining.starts_with(name) {
1476                                    for _ in 0..name.len() {
1477                                        chars.next();
1478                                    }
1479                                    if use_color {
1480                                        target.push_str(&ansi_color_from_name(name));
1481                                    }
1482                                    matched = true;
1483                                    break;
1484                                }
1485                            }
1486                            if !matched {
1487                                // Unknown color name — consume alphanumerics
1488                                while let Some(&c) = chars.peek() {
1489                                    if c.is_alphanumeric() {
1490                                        chars.next();
1491                                    } else {
1492                                        break;
1493                                    }
1494                                }
1495                            }
1496                        }
1497                    }
1498                    Some('w') => {
1499                        // %w(...) — wrapping directive, consume and ignore for now
1500                        chars.next();
1501                        if chars.peek() == Some(&'(') {
1502                            chars.next();
1503                            for c in chars.by_ref() {
1504                                if c == ')' {
1505                                    break;
1506                                }
1507                            }
1508                        }
1509                    }
1510                    Some('+') => {
1511                        // %+x — conditional newline: if next placeholder is non-empty, prepend newline
1512                        chars.next();
1513                        // Expand the following placeholder
1514                        if chars.peek() == Some(&'%') {
1515                            // The %+ applies to the NEXT expanded value
1516                            // For simplicity, treat %+x as: if %x is non-empty, emit '\n' + value
1517                            // This needs the *next* placeholder's value
1518                        }
1519                        // Simple: consume the next char as a format code; prepend \n if non-empty
1520                        let mut sub = String::new();
1521                        if let Some(&nc) = chars.peek() {
1522                            match nc {
1523                                'b' => {
1524                                    chars.next();
1525                                    sub.push_str(&body);
1526                                    if !body.is_empty() {
1527                                        sub.push('\n');
1528                                    }
1529                                }
1530                                's' => {
1531                                    chars.next();
1532                                    sub.push_str(subject);
1533                                }
1534                                _ => {
1535                                    chars.next();
1536                                    sub.push('%');
1537                                    sub.push('+');
1538                                    sub.push(nc);
1539                                }
1540                            }
1541                        }
1542                        if !sub.is_empty() {
1543                            target.push('\n');
1544                            target.push_str(&sub);
1545                        }
1546                    }
1547                    Some('-') => {
1548                        // %-x — conditional: suppress newline before placeholder if empty
1549                        chars.next();
1550                        // Consume the next format code
1551                        if let Some(&nc) = chars.peek() {
1552                            match nc {
1553                                'b' => {
1554                                    chars.next();
1555                                    if !body.is_empty() {
1556                                        target.push_str(&body);
1557                                        target.push('\n');
1558                                    }
1559                                }
1560                                's' => {
1561                                    chars.next();
1562                                    target.push_str(subject);
1563                                }
1564                                _ => {
1565                                    chars.next();
1566                                    target.push('%');
1567                                    target.push('-');
1568                                    target.push(nc);
1569                                }
1570                            }
1571                        }
1572                    }
1573                    Some('d') => {
1574                        // Decorations — output empty for now
1575                        chars.next();
1576                    }
1577                    Some('D') => {
1578                        // Decorations without parens — output empty for now
1579                        chars.next();
1580                    }
1581                    Some('e') => {
1582                        // Encoding
1583                        chars.next();
1584                    }
1585                    Some('g') => {
1586                        // Reflog placeholders: %gD, %gd, %gs, %gn, %ge, etc.
1587                        chars.next();
1588                        if let Some(&_nc) = chars.peek() {
1589                            chars.next(); // consume the sub-specifier
1590                                          // For non-reflog commits, these expand to empty
1591                        }
1592                    }
1593                    Some(&other) => {
1594                        chars.next();
1595                        target.push('%');
1596                        target.push(other);
1597                    }
1598                    None => target.push('%'),
1599                }
1600                // Apply pending column formatting
1601                if let Some(spec) = pending_col.take() {
1602                    let formatted = apply_col(&spec, &expanded);
1603                    rendered.push_str(&formatted);
1604                }
1605            }
1606            Ok(rendered)
1607        }
1608    }
1609}
1610
1611#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1612enum ExpectedObjectKind {
1613    Commit,
1614    Tree,
1615    Blob,
1616}
1617
1618impl ExpectedObjectKind {
1619    fn from_tag_type(kind: &str) -> Option<Self> {
1620        match kind {
1621            "commit" => Some(Self::Commit),
1622            "tree" => Some(Self::Tree),
1623            "blob" => Some(Self::Blob),
1624            _ => None,
1625        }
1626    }
1627
1628    fn matches(self, kind: ObjectKind) -> bool {
1629        matches!(
1630            (self, kind),
1631            (Self::Commit, ObjectKind::Commit)
1632                | (Self::Tree, ObjectKind::Tree)
1633                | (Self::Blob, ObjectKind::Blob)
1634        )
1635    }
1636
1637    fn as_str(self) -> &'static str {
1638        match self {
1639            Self::Commit => "commit",
1640            Self::Tree => "tree",
1641            Self::Blob => "blob",
1642        }
1643    }
1644}
1645
1646#[derive(Clone, Debug)]
1647struct RootObject {
1648    oid: ObjectId,
1649    input: String,
1650    expected_kind: Option<ExpectedObjectKind>,
1651}
1652
1653fn resolve_specs(repo: &Repository, specs: &[String]) -> Result<Vec<ObjectId>> {
1654    let mut out = Vec::with_capacity(specs.len());
1655    for spec in specs {
1656        let oid = resolve_revision(repo, spec)?;
1657        let commit_oid = peel_to_commit(repo, oid)?;
1658        out.push(commit_oid);
1659    }
1660    Ok(out)
1661}
1662
1663fn resolve_specs_for_objects(
1664    repo: &Repository,
1665    specs: &[String],
1666) -> Result<(Vec<ObjectId>, Vec<RootObject>)> {
1667    let mut commits = Vec::new();
1668    let mut roots = Vec::new();
1669
1670    for spec in specs {
1671        if let Ok(raw_oid) = spec.parse::<ObjectId>() {
1672            let raw_object = repo.odb.read(&raw_oid)?;
1673            match raw_object.kind {
1674                ObjectKind::Commit => {
1675                    commits.push(raw_oid);
1676                }
1677                ObjectKind::Tag => {
1678                    let tag = parse_tag(&raw_object.data)?;
1679                    let expected_kind = ExpectedObjectKind::from_tag_type(&tag.object_type)
1680                        .ok_or_else(|| {
1681                            Error::CorruptObject(format!(
1682                                "object {spec} has unsupported tag type '{}'",
1683                                tag.object_type
1684                            ))
1685                        })?;
1686                    roots.push(RootObject {
1687                        oid: tag.object,
1688                        input: spec.clone(),
1689                        expected_kind: Some(expected_kind),
1690                    });
1691                }
1692                ObjectKind::Tree | ObjectKind::Blob => roots.push(RootObject {
1693                    oid: raw_oid,
1694                    input: spec.clone(),
1695                    expected_kind: None,
1696                }),
1697            }
1698            continue;
1699        }
1700
1701        let oid = resolve_revision(repo, spec)?;
1702        match peel_to_commit(repo, oid) {
1703            Ok(commit_oid) => commits.push(commit_oid),
1704            Err(Error::CorruptObject(_)) | Err(Error::ObjectNotFound(_)) => {
1705                roots.push(RootObject {
1706                    oid,
1707                    input: spec.clone(),
1708                    expected_kind: None,
1709                })
1710            }
1711            Err(err) => return Err(err),
1712        }
1713    }
1714
1715    Ok((commits, roots))
1716}
1717
1718/// Peel an object (possibly a tag) to the underlying commit.
1719fn peel_to_commit(repo: &Repository, mut oid: ObjectId) -> Result<ObjectId> {
1720    loop {
1721        let object = repo.odb.read(&oid)?;
1722        match object.kind {
1723            ObjectKind::Commit => return Ok(oid),
1724            ObjectKind::Tag => {
1725                let tag = parse_tag(&object.data)?;
1726                oid = tag.object;
1727            }
1728            other => {
1729                return Err(Error::CorruptObject(format!(
1730                    "object {oid} is a {other:?}, not a commit"
1731                )));
1732            }
1733        }
1734    }
1735}
1736
1737fn all_ref_tips(repo: &Repository) -> Result<Vec<ObjectId>> {
1738    let mut raw = Vec::new();
1739    if let Ok(head) = refs::resolve_ref(&repo.git_dir, "HEAD") {
1740        raw.push(head);
1741    }
1742    raw.extend(
1743        refs::list_refs(&repo.git_dir, "refs/")?
1744            .into_iter()
1745            .map(|(_, oid)| oid),
1746    );
1747    // Peel tags to commits; skip non-commit objects (e.g. tags of blobs/trees)
1748    let mut out = Vec::new();
1749    let mut seen = HashSet::new();
1750    for oid in raw {
1751        match peel_to_commit(repo, oid) {
1752            Ok(commit_oid) if seen.insert(commit_oid) => out.push(commit_oid),
1753            Err(_) => {} // skip non-commit refs
1754            _ => {}
1755        }
1756    }
1757    out.sort();
1758    Ok(out)
1759}
1760
1761fn walk_closure(graph: &mut CommitGraph<'_>, starts: &[ObjectId]) -> Result<HashSet<ObjectId>> {
1762    let (seen, _) = walk_closure_ordered(graph, starts)?;
1763    Ok(seen)
1764}
1765
1766/// BFS walk that returns both the set and the discovery order.
1767fn walk_closure_ordered(
1768    graph: &mut CommitGraph<'_>,
1769    starts: &[ObjectId],
1770) -> Result<(HashSet<ObjectId>, Vec<ObjectId>)> {
1771    let mut seen = HashSet::new();
1772    let mut order = Vec::new();
1773    let mut queue = VecDeque::new();
1774    for &start in starts {
1775        queue.push_back(start);
1776    }
1777    while let Some(oid) = queue.pop_front() {
1778        if !seen.insert(oid) {
1779            continue;
1780        }
1781        order.push(oid);
1782        for parent in graph.parents_of(oid)? {
1783            queue.push_back(parent);
1784        }
1785    }
1786    Ok((seen, order))
1787}
1788
1789fn sort_by_commit_date_desc(
1790    graph: &mut CommitGraph<'_>,
1791    selected: &HashSet<ObjectId>,
1792    discovery_order: &[ObjectId],
1793) -> Result<Vec<ObjectId>> {
1794    // Build a map from OID to BFS discovery index for stable tiebreaking
1795    let disc_idx: HashMap<ObjectId, usize> = discovery_order
1796        .iter()
1797        .enumerate()
1798        .map(|(i, oid)| (*oid, i))
1799        .collect();
1800    let mut out: Vec<ObjectId> = selected.iter().copied().collect();
1801    out.sort_by(|a, b| {
1802        match graph.committer_time(*b).cmp(&graph.committer_time(*a)) {
1803            Ordering::Equal => {
1804                // Preserve BFS discovery order as tiebreaker
1805                let da = disc_idx.get(a).copied().unwrap_or(usize::MAX);
1806                let db = disc_idx.get(b).copied().unwrap_or(usize::MAX);
1807                da.cmp(&db)
1808            }
1809            other => other,
1810        }
1811    });
1812    Ok(out)
1813}
1814
1815fn topo_sort(graph: &mut CommitGraph<'_>, selected: &HashSet<ObjectId>) -> Result<Vec<ObjectId>> {
1816    let mut child_count: HashMap<ObjectId, usize> = selected.iter().map(|&oid| (oid, 0)).collect();
1817
1818    for &oid in selected {
1819        for parent in graph.parents_of(oid)? {
1820            if !selected.contains(&parent) {
1821                continue;
1822            }
1823            if let Some(count) = child_count.get_mut(&parent) {
1824                *count += 1;
1825            }
1826        }
1827    }
1828
1829    let mut ready = BinaryHeap::new();
1830    for (&oid, &count) in &child_count {
1831        if count == 0 {
1832            ready.push(CommitDateKey {
1833                oid,
1834                date: graph.committer_time(oid),
1835            });
1836        }
1837    }
1838
1839    let mut out = Vec::with_capacity(selected.len());
1840    while let Some(item) = ready.pop() {
1841        let oid = item.oid;
1842        out.push(oid);
1843        for parent in graph.parents_of(oid)? {
1844            if !selected.contains(&parent) {
1845                continue;
1846            }
1847            if let Some(count) = child_count.get_mut(&parent) {
1848                *count = count.saturating_sub(1);
1849                if *count == 0 {
1850                    ready.push(CommitDateKey {
1851                        oid: parent,
1852                        date: graph.committer_time(parent),
1853                    });
1854                }
1855            }
1856        }
1857    }
1858
1859    Ok(out)
1860}
1861
1862fn limit_to_ancestry(
1863    graph: &mut CommitGraph<'_>,
1864    selected: &mut HashSet<ObjectId>,
1865    bottoms: &[ObjectId],
1866) -> Result<()> {
1867    let mut keep = HashSet::new();
1868    for &bottom in bottoms {
1869        let ancestors = walk_closure(graph, &[bottom])?;
1870        keep.extend(
1871            ancestors
1872                .iter()
1873                .copied()
1874                .filter(|oid| selected.contains(oid)),
1875        );
1876
1877        for &candidate in selected.iter() {
1878            if candidate == bottom {
1879                keep.insert(candidate);
1880                continue;
1881            }
1882            let closure = walk_closure(graph, &[candidate])?;
1883            if closure.contains(&bottom) {
1884                keep.insert(candidate);
1885            }
1886        }
1887    }
1888    selected.retain(|oid| keep.contains(oid));
1889    Ok(())
1890}
1891
1892/// Check if a commit modifies any of the given paths compared to its first parent.
1893fn commit_touches_paths(
1894    repo: &Repository,
1895    graph: &mut CommitGraph<'_>,
1896    oid: ObjectId,
1897    paths: &[String],
1898    full_history: bool,
1899    sparse: bool,
1900) -> Result<bool> {
1901    let commit = load_commit(repo, oid)?;
1902    let parents = graph.parents_of(oid)?;
1903    let commit_entries = flatten_tree(repo, commit.tree, "")?;
1904    let commit_map: HashMap<String, ObjectId> = commit_entries.into_iter().collect();
1905
1906    // Root commit: include only when any requested pathspec exists.
1907    if parents.is_empty() {
1908        if sparse {
1909            return Ok(true);
1910        }
1911        return Ok(commit_map
1912            .keys()
1913            .any(|path| paths.iter().any(|spec| pathspec_matches(spec, path))));
1914    }
1915
1916    // Single-parent commit: include only when requested paths changed.
1917    if parents.len() == 1 {
1918        let parent = load_commit(repo, parents[0])?;
1919        let parent_map: HashMap<String, ObjectId> =
1920            flatten_tree(repo, parent.tree, "")?.into_iter().collect();
1921        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
1922        if differs {
1923            return Ok(true);
1924        }
1925        if sparse {
1926            return Ok(true);
1927        }
1928        return Ok(false);
1929    }
1930
1931    // Merge commit simplification for default dense history:
1932    // if exactly one parent is TREESAME for the requested paths, omit this
1933    // merge commit and let traversal effectively follow that parent.
1934    let mut treesame_parents = 0usize;
1935    let mut differs_any = false;
1936    for parent_oid in &parents {
1937        let parent = load_commit(repo, *parent_oid)?;
1938        let parent_map: HashMap<String, ObjectId> =
1939            flatten_tree(repo, parent.tree, "")?.into_iter().collect();
1940        let differs = path_differs_for_specs(&commit_map, &parent_map, paths);
1941        if differs {
1942            differs_any = true;
1943        } else {
1944            treesame_parents += 1;
1945        }
1946    }
1947
1948    if !full_history && treesame_parents == 1 {
1949        return Ok(false);
1950    }
1951
1952    if differs_any {
1953        return Ok(true);
1954    }
1955
1956    Ok(sparse)
1957}
1958
1959fn path_differs_for_specs(
1960    current: &HashMap<String, ObjectId>,
1961    parent: &HashMap<String, ObjectId>,
1962    specs: &[String],
1963) -> bool {
1964    let mut paths = std::collections::BTreeSet::new();
1965    paths.extend(current.keys().cloned());
1966    paths.extend(parent.keys().cloned());
1967
1968    for path in &paths {
1969        if !specs.iter().any(|spec| pathspec_matches(spec, path)) {
1970            continue;
1971        }
1972        if current.get(path) != parent.get(path) {
1973            return true;
1974        }
1975    }
1976    false
1977}
1978
1979fn pathspec_matches(spec: &str, path: &str) -> bool {
1980    let normalized = spec.strip_prefix("./").unwrap_or(spec);
1981    if normalized == "." || normalized.is_empty() {
1982        return true;
1983    }
1984
1985    if normalized.contains('*') || normalized.contains('?') || normalized.contains('[') {
1986        return crate::wildmatch::wildmatch(
1987            normalized.as_bytes(),
1988            path.as_bytes(),
1989            crate::wildmatch::WM_PATHNAME,
1990        );
1991    }
1992
1993    if let Some(prefix) = normalized.strip_suffix('/') {
1994        return path == prefix || path.starts_with(&format!("{prefix}/"));
1995    }
1996
1997    path == normalized || path.starts_with(&format!("{normalized}/"))
1998}
1999
2000fn load_commit(repo: &Repository, oid: ObjectId) -> Result<crate::objects::CommitData> {
2001    let object = repo.odb.read(&oid)?;
2002    if object.kind != ObjectKind::Commit {
2003        return Err(Error::CorruptObject(format!(
2004            "object {oid} is not a commit"
2005        )));
2006    }
2007    parse_commit(&object.data)
2008}
2009
2010fn parse_signature_time(sig: &str) -> i64 {
2011    let mut parts = sig.split_whitespace().collect::<Vec<_>>();
2012    if parts.len() < 2 {
2013        return 0;
2014    }
2015    let ts = parts.remove(parts.len().saturating_sub(2));
2016    ts.parse::<i64>().unwrap_or(0)
2017}
2018
2019/// Merge command-line arguments and `--stdin` input lines for this subset.
2020///
2021/// Returns `(positive_specs, negative_specs)`.
2022///
2023/// # Errors
2024///
2025/// Returns [`Error::InvalidRef`] when stdin provides invalid pseudo-options.
2026pub fn collect_revision_specs_with_stdin(
2027    args_specs: &[String],
2028    read_stdin: bool,
2029) -> Result<(Vec<String>, Vec<String>, bool)> {
2030    let mut positive = Vec::new();
2031    let mut negative = Vec::new();
2032    let mut not_mode = false;
2033
2034    for spec in args_specs {
2035        let (pos, neg) = split_revision_token(spec);
2036        if not_mode {
2037            positive.extend(neg);
2038            negative.extend(pos);
2039        } else {
2040            positive.extend(pos);
2041            negative.extend(neg);
2042        }
2043    }
2044
2045    if !read_stdin {
2046        return Ok((positive, negative, false));
2047    }
2048
2049    let mut in_paths = false;
2050    let mut stdin_all_refs = false;
2051    let stdin = std::io::read_to_string(std::io::stdin()).map_err(Error::Io)?;
2052    for raw_line in stdin.lines() {
2053        let line = raw_line.trim();
2054        if line.is_empty() {
2055            continue;
2056        }
2057        if in_paths {
2058            continue;
2059        }
2060        if line == "--" {
2061            in_paths = true;
2062            continue;
2063        }
2064        if line == "--not" {
2065            not_mode = !not_mode;
2066            continue;
2067        }
2068        if line == "--all" {
2069            stdin_all_refs = true;
2070            continue;
2071        }
2072        if line.starts_with("--") {
2073            return Err(Error::InvalidRef(format!(
2074                "invalid option '{line}' in --stdin mode"
2075            )));
2076        }
2077        if line.starts_with('-') {
2078            return Err(Error::InvalidRef(format!(
2079                "invalid option '{line}' in --stdin mode"
2080            )));
2081        }
2082        let (pos, neg) = split_revision_token(line);
2083        if not_mode {
2084            positive.extend(neg);
2085            negative.extend(pos);
2086        } else {
2087            positive.extend(pos);
2088            negative.extend(neg);
2089        }
2090    }
2091
2092    Ok((positive, negative, stdin_all_refs))
2093}
2094
2095/// Resolve every local tag object ID.
2096pub fn tag_targets(git_dir: &Path) -> Result<HashSet<ObjectId>> {
2097    Ok(refs::list_refs(git_dir, "refs/tags/")?
2098        .into_iter()
2099        .map(|(_, oid)| oid)
2100        .collect())
2101}
2102
2103struct CommitGraph<'r> {
2104    repo: &'r Repository,
2105    first_parent_only: bool,
2106    parents: HashMap<ObjectId, Vec<ObjectId>>,
2107    committer_time: HashMap<ObjectId, i64>,
2108    shallow_boundaries: HashSet<ObjectId>,
2109    graft_parents: HashMap<ObjectId, Vec<ObjectId>>,
2110}
2111
2112impl<'r> CommitGraph<'r> {
2113    fn new(repo: &'r Repository, first_parent_only: bool) -> Self {
2114        let shallow_boundaries = load_shallow_boundaries(&repo.git_dir);
2115        let graft_parents = load_graft_parents(&repo.git_dir);
2116        Self {
2117            repo,
2118            first_parent_only,
2119            parents: HashMap::new(),
2120            committer_time: HashMap::new(),
2121            shallow_boundaries,
2122            graft_parents,
2123        }
2124    }
2125
2126    fn parents_of(&mut self, oid: ObjectId) -> Result<Vec<ObjectId>> {
2127        self.populate(oid)?;
2128        Ok(self.parents.get(&oid).cloned().unwrap_or_default())
2129    }
2130
2131    fn committer_time(&mut self, oid: ObjectId) -> i64 {
2132        if self.populate(oid).is_err() {
2133            return 0;
2134        }
2135        self.committer_time.get(&oid).copied().unwrap_or(0)
2136    }
2137
2138    fn populate(&mut self, oid: ObjectId) -> Result<()> {
2139        if self.parents.contains_key(&oid) {
2140            return Ok(());
2141        }
2142        let commit = load_commit(self.repo, oid)?;
2143        // Shallow boundaries: treat commit as having no parents
2144        let mut parents = if self.shallow_boundaries.contains(&oid) {
2145            Vec::new()
2146        } else {
2147            commit.parents
2148        };
2149        if let Some(graft_parents) = self.graft_parents.get(&oid) {
2150            parents = graft_parents.clone();
2151        }
2152        if self.first_parent_only && parents.len() > 1 {
2153            parents.truncate(1);
2154        }
2155        self.committer_time
2156            .insert(oid, parse_signature_time(&commit.committer));
2157        self.parents.insert(oid, parents);
2158        Ok(())
2159    }
2160}
2161
2162/// Load shallow boundary commit OIDs from `.git/shallow`.
2163fn load_shallow_boundaries(git_dir: &Path) -> HashSet<ObjectId> {
2164    let shallow_path = git_dir.join("shallow");
2165    let mut set = HashSet::new();
2166    if let Ok(contents) = fs::read_to_string(&shallow_path) {
2167        for line in contents.lines() {
2168            let line = line.trim();
2169            if !line.is_empty() {
2170                if let Ok(oid) = line.parse::<ObjectId>() {
2171                    set.insert(oid);
2172                }
2173            }
2174        }
2175    }
2176    set
2177}
2178
2179/// Load commit parent overrides from `.git/info/grafts`.
2180fn load_graft_parents(git_dir: &Path) -> HashMap<ObjectId, Vec<ObjectId>> {
2181    let graft_path = git_dir.join("info/grafts");
2182    let mut grafts = HashMap::new();
2183    let Ok(contents) = fs::read_to_string(&graft_path) else {
2184        return grafts;
2185    };
2186    for raw_line in contents.lines() {
2187        let line = raw_line.trim();
2188        if line.is_empty() || line.starts_with('#') {
2189            continue;
2190        }
2191        let mut fields = line.split_whitespace();
2192        let Some(commit_hex) = fields.next() else {
2193            continue;
2194        };
2195        let Ok(commit_oid) = commit_hex.parse::<ObjectId>() else {
2196            continue;
2197        };
2198        let mut parents = Vec::new();
2199        let mut valid = true;
2200        for parent_hex in fields {
2201            match parent_hex.parse::<ObjectId>() {
2202                Ok(parent_oid) => parents.push(parent_oid),
2203                Err(_) => {
2204                    valid = false;
2205                    break;
2206                }
2207            }
2208        }
2209        if valid {
2210            grafts.insert(commit_oid, parents);
2211        }
2212    }
2213    grafts
2214}
2215
2216#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2217struct CommitDateKey {
2218    oid: ObjectId,
2219    date: i64,
2220}
2221
2222impl Ord for CommitDateKey {
2223    fn cmp(&self, other: &Self) -> Ordering {
2224        match self.date.cmp(&other.date) {
2225            Ordering::Equal => self.oid.cmp(&other.oid),
2226            ord => ord,
2227        }
2228    }
2229}
2230
2231impl PartialOrd for CommitDateKey {
2232    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2233        Some(self.cmp(other))
2234    }
2235}
2236
2237/// Read every line from a newline-delimited file.
2238///
2239/// # Errors
2240///
2241/// Returns [`Error::Io`] when the file cannot be read.
2242pub fn read_lines(path: &Path) -> Result<Vec<String>> {
2243    let content = fs::read_to_string(path)?;
2244    Ok(content.lines().map(|line| line.to_owned()).collect())
2245}
2246
2247/// Check if a token uses the symmetric diff `...` notation.
2248#[must_use]
2249pub fn is_symmetric_diff(token: &str) -> bool {
2250    token.contains("...") && !token.contains("....")
2251}
2252
2253/// Split a symmetric diff token into (lhs, rhs).
2254#[must_use]
2255pub fn split_symmetric_diff(token: &str) -> Option<(String, String)> {
2256    token
2257        .split_once("...")
2258        .map(|(l, r)| (l.to_owned(), r.to_owned()))
2259}
2260
2261/// Collect all reachable non-commit objects (trees and blobs) from a set of commits.
2262/// Returns (included, omitted) object lists.
2263#[allow(dead_code)]
2264fn collect_reachable_objects(
2265    repo: &Repository,
2266    _graph: &mut CommitGraph<'_>,
2267    commits: &[ObjectId],
2268    object_roots: &[RootObject],
2269    filter: Option<&ObjectFilter>,
2270    missing_action: MissingAction,
2271) -> Result<(Vec<(ObjectId, String)>, Vec<ObjectId>, Vec<ObjectId>)> {
2272    let mut seen = HashSet::new();
2273    let mut result = Vec::new();
2274    let mut omitted = Vec::new();
2275    let mut missing = Vec::new();
2276    let mut missing_seen = HashSet::new();
2277    for &commit_oid in commits {
2278        let commit = match load_commit(repo, commit_oid) {
2279            Ok(commit) => commit,
2280            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2281                if missing_seen.insert(commit_oid) && missing_action == MissingAction::Print {
2282                    missing.push(commit_oid);
2283                }
2284                continue;
2285            }
2286            Err(err) => return Err(err),
2287        };
2288        collect_tree_objects_filtered(
2289            repo,
2290            commit.tree,
2291            "",
2292            0,
2293            &mut seen,
2294            &mut result,
2295            &mut omitted,
2296            &mut missing,
2297            &mut missing_seen,
2298            filter,
2299            missing_action,
2300        )?;
2301    }
2302
2303    for root in object_roots {
2304        collect_root_object(
2305            repo,
2306            root,
2307            &mut seen,
2308            &mut result,
2309            &mut omitted,
2310            &mut missing,
2311            &mut missing_seen,
2312            filter,
2313            missing_action,
2314        )?;
2315    }
2316
2317    Ok((result, omitted, missing))
2318}
2319
2320fn collect_root_object(
2321    repo: &Repository,
2322    root: &RootObject,
2323    seen: &mut HashSet<ObjectId>,
2324    result: &mut Vec<(ObjectId, String)>,
2325    omitted: &mut Vec<ObjectId>,
2326    missing: &mut Vec<ObjectId>,
2327    missing_seen: &mut HashSet<ObjectId>,
2328    filter: Option<&ObjectFilter>,
2329    missing_action: MissingAction,
2330) -> Result<()> {
2331    let object = match repo.odb.read(&root.oid) {
2332        Ok(object) => object,
2333        Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2334            if missing_action == MissingAction::Print && missing_seen.insert(root.oid) {
2335                missing.push(root.oid);
2336            }
2337            return Ok(());
2338        }
2339        Err(err) => return Err(err),
2340    };
2341
2342    if let Some(expected) = root.expected_kind {
2343        if !expected.matches(object.kind) {
2344            return Err(Error::CorruptObject(format!(
2345                "object {} is not a {}",
2346                root.input,
2347                expected.as_str()
2348            )));
2349        }
2350    }
2351
2352    match object.kind {
2353        ObjectKind::Commit => {
2354            let commit = parse_commit(&object.data)?;
2355            collect_tree_objects_filtered(
2356                repo,
2357                commit.tree,
2358                "",
2359                0,
2360                seen,
2361                result,
2362                omitted,
2363                missing,
2364                missing_seen,
2365                filter,
2366                missing_action,
2367            )?;
2368        }
2369        ObjectKind::Tree => {
2370            seen.remove(&root.oid);
2371            collect_tree_objects_filtered(
2372                repo,
2373                root.oid,
2374                "",
2375                0,
2376                seen,
2377                result,
2378                omitted,
2379                missing,
2380                missing_seen,
2381                filter,
2382                missing_action,
2383            )?;
2384        }
2385        ObjectKind::Blob => {
2386            if !seen.insert(root.oid) {
2387                return Ok(());
2388            }
2389            let blob_included = filter.is_none_or(|f| f.includes_blob(object.data.len() as u64));
2390            if blob_included {
2391                result.push((root.oid, String::new()));
2392            } else {
2393                omitted.push(root.oid);
2394            }
2395        }
2396        ObjectKind::Tag => {
2397            let tag = parse_tag(&object.data)?;
2398            let expected_kind =
2399                ExpectedObjectKind::from_tag_type(&tag.object_type).ok_or_else(|| {
2400                    Error::CorruptObject(format!(
2401                        "object {} has unsupported tag type '{}'",
2402                        root.input, tag.object_type
2403                    ))
2404                })?;
2405            let nested = RootObject {
2406                oid: tag.object,
2407                input: root.input.clone(),
2408                expected_kind: Some(expected_kind),
2409            };
2410            collect_root_object(
2411                repo,
2412                &nested,
2413                seen,
2414                result,
2415                omitted,
2416                missing,
2417                missing_seen,
2418                filter,
2419                missing_action,
2420            )?;
2421        }
2422    }
2423
2424    Ok(())
2425}
2426
2427#[allow(dead_code)]
2428fn collect_tree_objects_filtered(
2429    repo: &Repository,
2430    tree_oid: ObjectId,
2431    prefix: &str,
2432    depth: u64,
2433    seen: &mut HashSet<ObjectId>,
2434    result: &mut Vec<(ObjectId, String)>,
2435    omitted: &mut Vec<ObjectId>,
2436    missing: &mut Vec<ObjectId>,
2437    missing_seen: &mut HashSet<ObjectId>,
2438    filter: Option<&ObjectFilter>,
2439    missing_action: MissingAction,
2440) -> Result<()> {
2441    if !seen.insert(tree_oid) {
2442        return Ok(());
2443    }
2444    let object = match repo.odb.read(&tree_oid) {
2445        Ok(object) => object,
2446        Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2447            if missing_action == MissingAction::Print && missing_seen.insert(tree_oid) {
2448                missing.push(tree_oid);
2449            }
2450            return Ok(());
2451        }
2452        Err(err) => return Err(err),
2453    };
2454    if object.kind != ObjectKind::Tree {
2455        return Err(Error::CorruptObject(format!(
2456            "object {tree_oid} is not a tree"
2457        )));
2458    }
2459    // Check if this tree passes the filter
2460    let tree_included = filter.is_none_or(|f| f.includes_tree(depth));
2461    if tree_included {
2462        result.push((tree_oid, prefix.to_owned()));
2463    } else {
2464        omitted.push(tree_oid);
2465    }
2466    let entries = parse_tree(&object.data)?;
2467    for entry in entries {
2468        // Skip gitlink (submodule) entries — their OIDs reference commits
2469        // in the submodule's object store, not the parent repo.
2470        if entry.mode == 0o160000 {
2471            continue;
2472        }
2473        let name = String::from_utf8_lossy(&entry.name).to_string();
2474        let path = if prefix.is_empty() {
2475            name.clone()
2476        } else {
2477            format!("{prefix}/{name}")
2478        };
2479        let seen_before = seen.contains(&entry.oid);
2480        let child_obj = match repo.odb.read(&entry.oid) {
2481            Ok(object) => object,
2482            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2483                if missing_action == MissingAction::Print && missing_seen.insert(entry.oid) {
2484                    missing.push(entry.oid);
2485                }
2486                continue;
2487            }
2488            Err(err) => return Err(err),
2489        };
2490        if entry.mode == 0o040000 {
2491            if child_obj.kind != ObjectKind::Tree {
2492                return Err(Error::CorruptObject(format!(
2493                    "object {} is not a tree",
2494                    entry.oid
2495                )));
2496            }
2497            if seen_before {
2498                continue;
2499            }
2500            seen.remove(&entry.oid);
2501            collect_tree_objects_filtered(
2502                repo,
2503                entry.oid,
2504                &path,
2505                depth + 1,
2506                seen,
2507                result,
2508                omitted,
2509                missing,
2510                missing_seen,
2511                filter,
2512                missing_action,
2513            )?;
2514        } else {
2515            if child_obj.kind != ObjectKind::Blob && seen_before {
2516                return Err(Error::CorruptObject(format!(
2517                    "object {} is not a blob",
2518                    entry.oid
2519                )));
2520            }
2521            if seen_before {
2522                continue;
2523            }
2524            seen.insert(entry.oid);
2525
2526            if child_obj.kind == ObjectKind::Blob {
2527                let blob_included =
2528                    filter.is_none_or(|f| f.includes_blob(child_obj.data.len() as u64));
2529                if blob_included {
2530                    result.push((entry.oid, path));
2531                } else {
2532                    omitted.push(entry.oid);
2533                }
2534            } else {
2535                // Git historically tolerates lone non-blob entries in blob slots.
2536                // Keep traversing while still detecting seen-object kind mismatches
2537                // through the `seen_before` check above.
2538                result.push((entry.oid, path));
2539            }
2540        }
2541    }
2542    Ok(())
2543}
2544
2545/// Collect reachable objects in commit order: objects for each commit are emitted
2546/// right after that commit, rather than all objects after all commits.
2547/// Returns (objects, omitted, per_commit_counts).
2548fn collect_reachable_objects_in_commit_order(
2549    repo: &Repository,
2550    _graph: &mut CommitGraph<'_>,
2551    commits: &[ObjectId],
2552    object_roots: &[RootObject],
2553    filter: Option<&ObjectFilter>,
2554    missing_action: MissingAction,
2555) -> Result<(
2556    Vec<(ObjectId, String)>,
2557    Vec<ObjectId>,
2558    Vec<ObjectId>,
2559    Vec<usize>,
2560)> {
2561    let mut seen = HashSet::new();
2562    let mut result = Vec::new();
2563    let mut omitted = Vec::new();
2564    let mut missing = Vec::new();
2565    let mut missing_seen = HashSet::new();
2566    let mut counts = Vec::with_capacity(commits.len());
2567    for &commit_oid in commits {
2568        let commit = match load_commit(repo, commit_oid) {
2569            Ok(commit) => commit,
2570            Err(Error::ObjectNotFound(_)) if missing_action != MissingAction::Error => {
2571                if missing_action == MissingAction::Print && missing_seen.insert(commit_oid) {
2572                    missing.push(commit_oid);
2573                }
2574                counts.push(0);
2575                continue;
2576            }
2577            Err(err) => return Err(err),
2578        };
2579        let before = result.len();
2580        collect_tree_objects_filtered(
2581            repo,
2582            commit.tree,
2583            "",
2584            0,
2585            &mut seen,
2586            &mut result,
2587            &mut omitted,
2588            &mut missing,
2589            &mut missing_seen,
2590            filter,
2591            missing_action,
2592        )?;
2593        counts.push(result.len() - before);
2594    }
2595
2596    for root in object_roots {
2597        collect_root_object(
2598            repo,
2599            root,
2600            &mut seen,
2601            &mut result,
2602            &mut omitted,
2603            &mut missing,
2604            &mut missing_seen,
2605            filter,
2606            missing_action,
2607        )?;
2608    }
2609
2610    Ok((result, omitted, missing, counts))
2611}
2612
2613/// Collect OIDs of all objects in packs that have a `.keep` file.
2614fn kept_object_ids(repo: &Repository) -> Result<HashSet<ObjectId>> {
2615    let pack_dir = repo.git_dir.join("objects/pack");
2616    let mut kept = HashSet::new();
2617    if !pack_dir.is_dir() {
2618        return Ok(kept);
2619    }
2620    for entry in std::fs::read_dir(&pack_dir)? {
2621        let entry = entry?;
2622        let path = entry.path();
2623        if path.extension().is_some_and(|ext| ext == "keep") {
2624            // Find the corresponding .idx file
2625            let idx_path = path.with_extension("idx");
2626            if idx_path.exists() {
2627                if let Ok(oids) = crate::pack::read_idx_object_ids(&idx_path) {
2628                    kept.extend(oids);
2629                }
2630            }
2631        }
2632    }
2633    Ok(kept)
2634}
2635
2636/// Compute a simple patch-id for each commit.
2637#[allow(dead_code)]
2638fn compute_patch_ids(
2639    repo: &Repository,
2640    graph: &mut CommitGraph<'_>,
2641    commits: &[ObjectId],
2642) -> Result<HashMap<ObjectId, String>> {
2643    let mut result = HashMap::new();
2644    for &oid in commits {
2645        let commit = load_commit(repo, oid)?;
2646        let parents = graph.parents_of(oid)?;
2647        let parent_tree = if let Some(&parent) = parents.first() {
2648            load_commit(repo, parent)?.tree
2649        } else {
2650            ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?
2651        };
2652        let patch_id = compute_tree_diff_id(repo, parent_tree, commit.tree)?;
2653        result.insert(oid, patch_id);
2654    }
2655    Ok(result)
2656}
2657
2658#[allow(dead_code)]
2659fn compute_tree_diff_id(repo: &Repository, tree_a: ObjectId, tree_b: ObjectId) -> Result<String> {
2660    use std::collections::BTreeMap;
2661    let entries_a = flatten_tree(repo, tree_a, "")?;
2662    let entries_b = flatten_tree(repo, tree_b, "")?;
2663    let map_a: BTreeMap<_, _> = entries_a.into_iter().collect();
2664    let map_b: BTreeMap<_, _> = entries_b.into_iter().collect();
2665    let mut diff_parts = Vec::new();
2666    for (path, oid_b) in &map_b {
2667        match map_a.get(path) {
2668            Some(oid_a) if oid_a != oid_b => {
2669                diff_parts.push(format!("+{path}:{oid_b}"));
2670            }
2671            None => {
2672                diff_parts.push(format!("A{path}:{oid_b}"));
2673            }
2674            _ => {}
2675        }
2676    }
2677    for (path, oid_a) in &map_a {
2678        if !map_b.contains_key(path) {
2679            diff_parts.push(format!("D{path}:{oid_a}"));
2680        }
2681    }
2682    diff_parts.sort();
2683    Ok(diff_parts.join("\n"))
2684}
2685
2686#[allow(dead_code)]
2687fn flatten_tree(
2688    repo: &Repository,
2689    tree_oid: ObjectId,
2690    prefix: &str,
2691) -> Result<Vec<(String, ObjectId)>> {
2692    let mut result = Vec::new();
2693    let object = match repo.odb.read(&tree_oid) {
2694        Ok(o) => o,
2695        Err(_) => return Ok(result),
2696    };
2697    if object.kind != ObjectKind::Tree {
2698        return Ok(result);
2699    }
2700    let entries = parse_tree(&object.data)?;
2701    for entry in entries {
2702        let name = String::from_utf8_lossy(&entry.name).to_string();
2703        let path = if prefix.is_empty() {
2704            name
2705        } else {
2706            format!("{prefix}/{name}")
2707        };
2708        let child = repo.odb.read(&entry.oid)?;
2709        if child.kind == ObjectKind::Tree {
2710            result.extend(flatten_tree(repo, entry.oid, &path)?);
2711        } else {
2712            result.push((path, entry.oid));
2713        }
2714    }
2715    Ok(result)
2716}
2717
2718fn filter_requests_tag_objects(filter: &ObjectFilter) -> bool {
2719    match filter {
2720        ObjectFilter::ObjectType(k) => *k == ObjectKind::Tag,
2721        ObjectFilter::Combine(parts) => parts.iter().any(filter_requests_tag_objects),
2722        _ => false,
2723    }
2724}
2725
2726fn append_tag_ref_object_ids(repo: &Repository, oids: &mut Vec<ObjectId>) -> Result<()> {
2727    for (_, tip) in refs::list_refs(&repo.git_dir, "refs/tags/")? {
2728        let obj = match repo.odb.read(&tip) {
2729            Ok(o) => o,
2730            Err(_) => continue,
2731        };
2732        if obj.kind == ObjectKind::Tag {
2733            oids.push(tip);
2734        }
2735    }
2736    Ok(())
2737}
2738
2739/// Sorted unique object IDs for `git cat-file --batch-all-objects --filter`.
2740pub fn object_ids_for_cat_file_filtered(
2741    repo: &Repository,
2742    merged_filter: &ObjectFilter,
2743) -> Result<Vec<ObjectId>> {
2744    let mut options = RevListOptions::default();
2745    options.all_refs = true;
2746    options.objects = true;
2747    options.no_object_names = true;
2748    options.filter = Some(merged_filter.clone());
2749    options.missing_action = MissingAction::Allow;
2750    let r = rev_list(repo, &[], &[], &options)?;
2751    let mut oids: Vec<ObjectId> = Vec::new();
2752    oids.extend(r.commits.iter().copied());
2753    oids.extend(r.objects.iter().map(|(o, _)| *o));
2754    if filter_requests_tag_objects(merged_filter) {
2755        append_tag_ref_object_ids(repo, &mut oids)?;
2756    }
2757    oids.sort_unstable();
2758    oids.dedup();
2759    Ok(oids)
2760}
2761
2762/// Compute merge bases between two commits.
2763pub fn merge_bases(
2764    repo: &Repository,
2765    a: ObjectId,
2766    b: ObjectId,
2767    first_parent_only: bool,
2768) -> Result<Vec<ObjectId>> {
2769    let mut graph = CommitGraph::new(repo, first_parent_only);
2770    let ancestors_a = walk_closure(&mut graph, &[a])?;
2771    let ancestors_b = walk_closure(&mut graph, &[b])?;
2772    let common: HashSet<ObjectId> = ancestors_a.intersection(&ancestors_b).copied().collect();
2773    if common.is_empty() {
2774        return Ok(Vec::new());
2775    }
2776    // Merge bases: common ancestors not dominated by other common ancestors
2777    let mut bases = Vec::new();
2778    for &c in &common {
2779        let is_dominated = common.iter().any(|&other| {
2780            if other == c {
2781                return false;
2782            }
2783            let other_anc = walk_closure(&mut graph, &[other]).unwrap_or_default();
2784            other_anc.contains(&c)
2785        });
2786        if !is_dominated {
2787            bases.push(c);
2788        }
2789    }
2790    if bases.is_empty() {
2791        let mut sorted: Vec<_> = common.into_iter().collect();
2792        sorted.sort_by_key(|b| std::cmp::Reverse(graph.committer_time(*b)));
2793        bases.push(sorted[0]);
2794    }
2795    Ok(bases)
2796}