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_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/// Object filter specification for `--filter=`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ObjectFilter {
33    /// `blob:none` — omit all blobs.
34    BlobNone,
35    /// `blob:limit=<n>` — omit blobs larger than `n` bytes.
36    BlobLimit(u64),
37    /// `tree:<depth>` — omit trees deeper than `depth`.
38    TreeDepth(u64),
39    /// `combine:<filter>+<filter>+…` — apply multiple filters.
40    Combine(Vec<ObjectFilter>),
41}
42
43impl ObjectFilter {
44    /// Parse a `--filter=<spec>` value.
45    pub fn parse(spec: &str) -> std::result::Result<Self, String> {
46        if spec == "blob:none" {
47            return Ok(ObjectFilter::BlobNone);
48        }
49        if let Some(rest) = spec.strip_prefix("blob:limit=") {
50            let bytes = parse_size_suffix(rest)
51                .ok_or_else(|| format!("invalid blob:limit value: {rest}"))?;
52            return Ok(ObjectFilter::BlobLimit(bytes));
53        }
54        if let Some(rest) = spec.strip_prefix("tree:") {
55            let depth: u64 = rest
56                .parse()
57                .map_err(|_| format!("invalid tree depth: {rest}"))?;
58            return Ok(ObjectFilter::TreeDepth(depth));
59        }
60        if let Some(rest) = spec.strip_prefix("combine:") {
61            let parts = split_combine(rest);
62            let mut filters = Vec::new();
63            for part in parts {
64                filters.push(ObjectFilter::parse(&part)?);
65            }
66            return Ok(ObjectFilter::Combine(filters));
67        }
68        Err(format!("unsupported filter spec: {spec}"))
69    }
70
71    /// Check if a blob should be included given its size.
72    pub fn includes_blob(&self, size: u64) -> bool {
73        match self {
74            ObjectFilter::BlobNone => false,
75            ObjectFilter::BlobLimit(limit) => size <= *limit,
76            ObjectFilter::TreeDepth(_) => true,
77            ObjectFilter::Combine(filters) => {
78                filters.iter().all(|f| f.includes_blob(size))
79            }
80        }
81    }
82
83    /// Check if a tree at given depth should be included.
84    pub fn includes_tree(&self, depth: u64) -> bool {
85        match self {
86            ObjectFilter::BlobNone => true,
87            ObjectFilter::BlobLimit(_) => true,
88            ObjectFilter::TreeDepth(max_depth) => depth < *max_depth,
89            ObjectFilter::Combine(filters) => {
90                filters.iter().all(|f| f.includes_tree(depth))
91            }
92        }
93    }
94}
95
96/// Parse a size with optional k/m/g suffix.
97fn parse_size_suffix(s: &str) -> Option<u64> {
98    let s = s.trim();
99    if s.is_empty() {
100        return None;
101    }
102    let (num_str, multiplier) = match s.as_bytes().last()? {
103        b'k' | b'K' => (&s[..s.len() - 1], 1024u64),
104        b'm' | b'M' => (&s[..s.len() - 1], 1024 * 1024),
105        b'g' | b'G' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
106        _ => (s, 1u64),
107    };
108    let num: u64 = num_str.parse().ok()?;
109    Some(num * multiplier)
110}
111
112/// Split a combine filter spec on `+`, handling URL encoding.
113fn split_combine(spec: &str) -> Vec<String> {
114    let mut parts = Vec::new();
115    let mut current = String::new();
116    let mut chars = spec.chars().peekable();
117    while let Some(ch) = chars.next() {
118        if ch == '+' {
119            if !current.is_empty() {
120                parts.push(url_decode(&current));
121                current.clear();
122            }
123        } else {
124            current.push(ch);
125        }
126    }
127    if !current.is_empty() {
128        parts.push(url_decode(&current));
129    }
130    parts
131}
132
133/// Simple URL percent-decoding.
134fn url_decode(s: &str) -> String {
135    let mut result = String::new();
136    let mut chars = s.chars();
137    while let Some(ch) = chars.next() {
138        if ch == '%' {
139            let hi = chars.next().unwrap_or('0');
140            let lo = chars.next().unwrap_or('0');
141            let byte = u8::from_str_radix(&format!("{hi}{lo}"), 16).unwrap_or(b'?');
142            result.push(byte as char);
143        } else {
144            result.push(ch);
145        }
146    }
147    result
148}
149
150/// Ordering mode for commit output.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum OrderingMode {
153    /// Reverse-chronological by commit date.
154    Default,
155    /// Topological ordering with date tie-breaks.
156    Topo,
157    /// Date-order variant (same constraints as topo for this subset).
158    Date,
159}
160
161/// Parsed and normalized options for rev-list traversal.
162#[derive(Debug, Clone)]
163pub struct RevListOptions {
164    /// Include all refs (`--all`) as positive tips.
165    pub all_refs: bool,
166    /// Follow only first parent when walking merges.
167    pub first_parent: bool,
168    /// Enable ancestry-path filtering.
169    pub ancestry_path: bool,
170    /// Optional explicit ancestry-path pivot commits.
171    pub ancestry_path_bottoms: Vec<ObjectId>,
172    /// Keep only decorated commits after traversal.
173    pub simplify_by_decoration: bool,
174    /// Commit output mode.
175    pub output_mode: OutputMode,
176    /// Suppress commit output.
177    pub quiet: bool,
178    /// Print only final count.
179    pub count: bool,
180    /// Skip N commits from selected list.
181    pub skip: usize,
182    /// Optional maximum selected commits.
183    pub max_count: Option<usize>,
184    /// Ordering strategy.
185    pub ordering: OrderingMode,
186    /// Reverse selected output order.
187    pub reverse: bool,
188    /// List reachable objects (trees, blobs) in addition to commits.
189    pub objects: bool,
190    /// Suppress object path names in --objects output.
191    pub no_object_names: bool,
192    /// Show boundary commits with `-` prefix.
193    pub boundary: bool,
194    /// Show left/right markers for symmetric diff.
195    pub left_right: bool,
196    /// Filter to left-only commits in symmetric diff.
197    pub left_only: bool,
198    /// Filter to right-only commits in symmetric diff.
199    pub right_only: bool,
200    /// Cherry-mark equivalent commits with `=` instead of `+`.
201    pub cherry_mark: bool,
202    /// Cherry-pick: omit equivalent commits from output.
203    pub cherry_pick: bool,
204    /// Minimum number of parents a commit must have to be included.
205    pub min_parents: Option<usize>,
206    /// Maximum number of parents a commit may have to be included.
207    pub max_parents: Option<usize>,
208    /// Symmetric-diff left OID (set by caller when A...B is used).
209    pub symmetric_left: Option<ObjectId>,
210    /// Symmetric-diff right OID (set by caller when A...B is used).
211    pub symmetric_right: Option<ObjectId>,
212    /// Path filters (files after `--`).
213    pub paths: Vec<String>,
214    /// Show full history (don't simplify) for path-limited walks.
215    pub full_history: bool,
216    /// Sparse mode: don't prune non-matching commits.
217    pub sparse: bool,
218    /// Object filter for `--filter=<spec>`.
219    pub filter: Option<ObjectFilter>,
220    /// Print omitted objects prefixed with `~`.
221    pub filter_print_omitted: bool,
222}
223
224impl Default for RevListOptions {
225    fn default() -> Self {
226        Self {
227            all_refs: false,
228            first_parent: false,
229            ancestry_path: false,
230            ancestry_path_bottoms: Vec::new(),
231            simplify_by_decoration: false,
232            output_mode: OutputMode::OidOnly,
233            quiet: false,
234            count: false,
235            skip: 0,
236            max_count: None,
237            ordering: OrderingMode::Default,
238            reverse: false,
239            objects: false,
240            no_object_names: false,
241            boundary: false,
242            left_right: false,
243            left_only: false,
244            right_only: false,
245            cherry_mark: false,
246            cherry_pick: false,
247            min_parents: None,
248            max_parents: None,
249            symmetric_left: None,
250            symmetric_right: None,
251            paths: Vec::new(),
252            full_history: false,
253            sparse: false,
254            filter: None,
255            filter_print_omitted: false,
256        }
257    }
258}
259
260/// Final commit selection result.
261#[derive(Debug, Clone)]
262pub struct RevListResult {
263    /// Selected commits in final output order, after skip/max/reverse.
264    pub commits: Vec<ObjectId>,
265    /// Reachable non-commit objects when `--objects` is active.
266    /// Each entry is `(oid, optional_path)`.
267    pub objects: Vec<(ObjectId, String)>,
268    /// Objects omitted by `--filter` (for `--filter-print-omitted`).
269    pub omitted_objects: Vec<ObjectId>,
270    /// Boundary commits (excluded parents shown with `-` prefix).
271    pub boundary_commits: Vec<ObjectId>,
272    /// For `--left-right`: mapping commit OID -> true=left, false=right.
273    pub left_right_map: HashMap<ObjectId, bool>,
274    /// For `--cherry-mark`: set of commits that are equivalent (patch-id match).
275    pub cherry_equivalent: HashSet<ObjectId>,
276}
277
278/// Resolve and walk revisions for the requested options.
279///
280/// # Parameters
281///
282/// - `repo` - repository used for ref/object lookup.
283/// - `positive_specs` - positive revision tokens (e.g. `HEAD`, `A..B` rhs).
284/// - `negative_specs` - negative revision tokens (`^A`, `A..B` lhs).
285/// - `options` - traversal and output selection options.
286///
287/// # Errors
288///
289/// Returns [`Error::ObjectNotFound`] / [`Error::InvalidRef`] for bad revision
290/// specs and [`Error::CorruptObject`] for non-commit or malformed commit data.
291pub fn rev_list(
292    repo: &Repository,
293    positive_specs: &[String],
294    negative_specs: &[String],
295    options: &RevListOptions,
296) -> Result<RevListResult> {
297    let mut graph = CommitGraph::new(repo, options.first_parent);
298
299    let mut include = resolve_specs(repo, positive_specs)?;
300    let exclude = resolve_specs(repo, negative_specs)?;
301
302    if options.all_refs {
303        include.extend(all_ref_tips(repo)?);
304    }
305
306    if include.is_empty() {
307        return Err(Error::InvalidRef("no revisions specified".to_owned()));
308    }
309
310    let mut included = walk_closure(&mut graph, &include)?;
311    let excluded = walk_closure(&mut graph, &exclude)?;
312    included.retain(|oid| !excluded.contains(oid));
313
314    if options.simplify_by_decoration {
315        let decorated = all_ref_tips(repo)?;
316        included.retain(|oid| decorated.contains(oid));
317    }
318
319    if options.ancestry_path {
320        let mut bottoms = options.ancestry_path_bottoms.clone();
321        if bottoms.is_empty() {
322            bottoms.extend(exclude.iter().copied());
323        }
324        if bottoms.is_empty() {
325            return Err(Error::InvalidRef(
326                "--ancestry-path requires a range with excluded tips".to_owned(),
327            ));
328        }
329        limit_to_ancestry(&mut graph, &mut included, &bottoms)?;
330    }
331
332    // Filter by parent count (--merges, --no-merges, --min-parents, --max-parents)
333    if options.min_parents.is_some() || options.max_parents.is_some() {
334        let min_p = options.min_parents.unwrap_or(0);
335        let max_p = options.max_parents.unwrap_or(usize::MAX);
336        included.retain(|oid| {
337            let count = graph.parents_of(*oid).map(|p| p.len()).unwrap_or(0);
338            count >= min_p && count <= max_p
339        });
340    }
341
342    let mut ordered = match options.ordering {
343        OrderingMode::Default => sort_by_commit_date_desc(&mut graph, &included)?,
344        OrderingMode::Topo | OrderingMode::Date => topo_sort(&mut graph, &included)?,
345    };
346
347    // Path filtering: keep only commits that modify given paths
348    if !options.paths.is_empty() && !options.sparse {
349        let paths = &options.paths;
350        ordered.retain(|oid| {
351            commit_touches_paths(repo, &mut graph, *oid, paths).unwrap_or(false)
352        });
353    }
354
355    // Left-right classification for symmetric diffs
356    let mut left_right_map = HashMap::new();
357    if options.left_right || options.left_only || options.right_only || options.cherry_mark || options.cherry_pick {
358        if let (Some(left_oid), Some(right_oid)) = (options.symmetric_left, options.symmetric_right) {
359            let left_closure = walk_closure(&mut graph, &[left_oid])?;
360            let right_closure = walk_closure(&mut graph, &[right_oid])?;
361            for &oid in &ordered {
362                let in_left = left_closure.contains(&oid);
363                let in_right = right_closure.contains(&oid);
364                if in_left && !in_right {
365                    left_right_map.insert(oid, true);
366                } else if in_right && !in_left {
367                    left_right_map.insert(oid, false);
368                } else {
369                    left_right_map.insert(oid, false);
370                }
371            }
372        }
373    }
374
375    // Cherry-pick / cherry-mark: compute patch-ids and find equivalents
376    let mut cherry_equivalent = HashSet::new();
377    if options.cherry_pick || options.cherry_mark {
378        let patch_ids = compute_patch_ids(repo, &mut graph, &ordered)?;
379        let left_commits: Vec<_> = ordered.iter().filter(|o| left_right_map.get(o) == Some(&true)).copied().collect();
380        let right_commits: Vec<_> = ordered.iter().filter(|o| left_right_map.get(o) == Some(&false)).copied().collect();
381        let left_patches: HashMap<&str, ObjectId> = left_commits.iter()
382            .filter_map(|o| patch_ids.get(o).map(|p| (p.as_str(), *o)))
383            .collect();
384        let right_patches: HashMap<&str, ObjectId> = right_commits.iter()
385            .filter_map(|o| patch_ids.get(o).map(|p| (p.as_str(), *o)))
386            .collect();
387        for (&pid, &oid) in &left_patches {
388            if !pid.is_empty() && right_patches.contains_key(pid) {
389                cherry_equivalent.insert(oid);
390                cherry_equivalent.insert(right_patches[pid]);
391            }
392        }
393        for (&pid, &oid) in &right_patches {
394            if !pid.is_empty() && left_patches.contains_key(pid) {
395                cherry_equivalent.insert(oid);
396                cherry_equivalent.insert(left_patches[pid]);
397            }
398        }
399    }
400
401    // Filter left-only / right-only
402    if options.left_only {
403        ordered.retain(|oid| left_right_map.get(oid) == Some(&true));
404    }
405    if options.right_only {
406        ordered.retain(|oid| left_right_map.get(oid) == Some(&false));
407    }
408
409    // Cherry-pick: remove equivalent commits
410    if options.cherry_pick {
411        ordered.retain(|oid| !cherry_equivalent.contains(oid));
412    }
413
414    if options.skip > 0 {
415        ordered = ordered.into_iter().skip(options.skip).collect();
416    }
417    if let Some(max_count) = options.max_count {
418        ordered.truncate(max_count);
419    }
420    if options.reverse {
421        ordered.reverse();
422    }
423
424    // Collect reachable objects if --objects
425    let (objects, omitted_objects) = if options.objects {
426        collect_reachable_objects(repo, &mut graph, &ordered, options.filter.as_ref())?
427    } else {
428        (Vec::new(), Vec::new())
429    };
430
431    Ok(RevListResult {
432        commits: ordered,
433        objects,
434        omitted_objects,
435        boundary_commits: Vec::new(),
436        left_right_map,
437        cherry_equivalent,
438    })
439}
440
441/// Parse a raw revision token into positive and negative specs.
442///
443/// Supports:
444/// - `<a>..<b>` => negative `<a>`, positive `<b>`
445/// - `^<rev>` => negative `<rev>`
446/// - `<rev>` => positive `<rev>`
447#[must_use]
448pub fn split_revision_token(token: &str) -> (Vec<String>, Vec<String>) {
449    if let Some((lhs, rhs)) = token.split_once("..") {
450        return (vec![rhs.to_owned()], vec![lhs.to_owned()]);
451    }
452    if let Some(rest) = token.strip_prefix('^') {
453        return (Vec::new(), vec![rest.to_owned()]);
454    }
455    (vec![token.to_owned()], Vec::new())
456}
457
458/// Render one commit according to the selected output mode.
459///
460/// # Errors
461///
462/// Returns object decode errors when commit metadata is required.
463pub fn render_commit(
464    repo: &Repository,
465    oid: ObjectId,
466    mode: &OutputMode,
467    abbrev_len: usize,
468) -> Result<String> {
469    match mode {
470        OutputMode::OidOnly => Ok(format!("{oid}")),
471        OutputMode::Parents => {
472            let mut out = format!("{oid}");
473            let commit = load_commit(repo, oid)?;
474            for parent in commit.parents {
475                out.push(' ');
476                out.push_str(&parent.to_hex());
477            }
478            Ok(out)
479        }
480        OutputMode::Format(fmt) => {
481            let commit = load_commit(repo, oid)?;
482            let subject = commit.message.lines().next().unwrap_or_default();
483            let mut rendered = String::new();
484            let mut chars = fmt.chars().peekable();
485            while let Some(ch) = chars.next() {
486                if ch != '%' {
487                    rendered.push(ch);
488                    continue;
489                }
490                match chars.next() {
491                    Some('%') => rendered.push('%'),
492                    Some('H') => rendered.push_str(&oid.to_hex()),
493                    Some('h') => {
494                        let hex = oid.to_hex();
495                        let n = abbrev_len.clamp(4, 40).min(hex.len());
496                        rendered.push_str(&hex[..n]);
497                    }
498                    Some('s') => rendered.push_str(subject),
499                    Some(other) => {
500                        rendered.push('%');
501                        rendered.push(other);
502                    }
503                    None => rendered.push('%'),
504                }
505            }
506            Ok(rendered)
507        }
508    }
509}
510
511fn resolve_specs(repo: &Repository, specs: &[String]) -> Result<Vec<ObjectId>> {
512    let mut out = Vec::with_capacity(specs.len());
513    for spec in specs {
514        let oid = resolve_revision(repo, spec)?;
515        ensure_commit(repo, oid)?;
516        out.push(oid);
517    }
518    Ok(out)
519}
520
521fn all_ref_tips(repo: &Repository) -> Result<Vec<ObjectId>> {
522    let mut out = Vec::new();
523    if let Ok(head) = refs::resolve_ref(&repo.git_dir, "HEAD") {
524        out.push(head);
525    }
526    out.extend(
527        refs::list_refs(&repo.git_dir, "refs/")?
528            .into_iter()
529            .map(|(_, oid)| oid),
530    );
531    out.sort();
532    out.dedup();
533    Ok(out)
534}
535
536fn walk_closure(graph: &mut CommitGraph<'_>, starts: &[ObjectId]) -> Result<HashSet<ObjectId>> {
537    let mut seen = HashSet::new();
538    let mut queue = VecDeque::new();
539    for &start in starts {
540        queue.push_back(start);
541    }
542    while let Some(oid) = queue.pop_front() {
543        if !seen.insert(oid) {
544            continue;
545        }
546        for parent in graph.parents_of(oid)? {
547            queue.push_back(parent);
548        }
549    }
550    Ok(seen)
551}
552
553fn sort_by_commit_date_desc(
554    graph: &mut CommitGraph<'_>,
555    selected: &HashSet<ObjectId>,
556) -> Result<Vec<ObjectId>> {
557    let mut out = selected.iter().copied().collect::<Vec<_>>();
558    out.sort_by(
559        |a, b| match graph.committer_time(*b).cmp(&graph.committer_time(*a)) {
560            Ordering::Equal => b.cmp(a),
561            other => other,
562        },
563    );
564    Ok(out)
565}
566
567fn topo_sort(graph: &mut CommitGraph<'_>, selected: &HashSet<ObjectId>) -> Result<Vec<ObjectId>> {
568    let mut child_count: HashMap<ObjectId, usize> = selected.iter().map(|&oid| (oid, 0)).collect();
569
570    for &oid in selected {
571        for parent in graph.parents_of(oid)? {
572            if !selected.contains(&parent) {
573                continue;
574            }
575            if let Some(count) = child_count.get_mut(&parent) {
576                *count += 1;
577            }
578        }
579    }
580
581    let mut ready = BinaryHeap::new();
582    for (&oid, &count) in &child_count {
583        if count == 0 {
584            ready.push(CommitDateKey {
585                oid,
586                date: graph.committer_time(oid),
587            });
588        }
589    }
590
591    let mut out = Vec::with_capacity(selected.len());
592    while let Some(item) = ready.pop() {
593        let oid = item.oid;
594        out.push(oid);
595        for parent in graph.parents_of(oid)? {
596            if !selected.contains(&parent) {
597                continue;
598            }
599            if let Some(count) = child_count.get_mut(&parent) {
600                *count = count.saturating_sub(1);
601                if *count == 0 {
602                    ready.push(CommitDateKey {
603                        oid: parent,
604                        date: graph.committer_time(parent),
605                    });
606                }
607            }
608        }
609    }
610
611    Ok(out)
612}
613
614fn limit_to_ancestry(
615    graph: &mut CommitGraph<'_>,
616    selected: &mut HashSet<ObjectId>,
617    bottoms: &[ObjectId],
618) -> Result<()> {
619    let mut keep = HashSet::new();
620    for &bottom in bottoms {
621        let ancestors = walk_closure(graph, &[bottom])?;
622        keep.extend(
623            ancestors
624                .iter()
625                .copied()
626                .filter(|oid| selected.contains(oid)),
627        );
628
629        for &candidate in selected.iter() {
630            if candidate == bottom {
631                keep.insert(candidate);
632                continue;
633            }
634            let closure = walk_closure(graph, &[candidate])?;
635            if closure.contains(&bottom) {
636                keep.insert(candidate);
637            }
638        }
639    }
640    selected.retain(|oid| keep.contains(oid));
641    Ok(())
642}
643
644/// Check if a commit modifies any of the given paths compared to its first parent.
645fn commit_touches_paths(
646    repo: &Repository,
647    graph: &mut CommitGraph<'_>,
648    oid: ObjectId,
649    paths: &[String],
650) -> Result<bool> {
651    let commit = load_commit(repo, oid)?;
652    let parents = graph.parents_of(oid)?;
653    let parent_tree = if let Some(&parent) = parents.first() {
654        Some(load_commit(repo, parent)?.tree)
655    } else {
656        None
657    };
658    let commit_entries = flatten_tree(repo, commit.tree, "")?;
659    let commit_map: HashMap<String, ObjectId> = commit_entries.into_iter().collect();
660    let parent_map: HashMap<String, ObjectId> = if let Some(pt) = parent_tree {
661        flatten_tree(repo, pt, "")?.into_iter().collect()
662    } else {
663        HashMap::new()
664    };
665    for path in paths {
666        let c_oid = commit_map.get(path.as_str());
667        let p_oid = parent_map.get(path.as_str());
668        if c_oid != p_oid {
669            return Ok(true);
670        }
671    }
672    Ok(false)
673}
674
675fn load_commit(repo: &Repository, oid: ObjectId) -> Result<crate::objects::CommitData> {
676    let object = repo.odb.read(&oid)?;
677    if object.kind != ObjectKind::Commit {
678        return Err(Error::CorruptObject(format!(
679            "object {oid} is not a commit"
680        )));
681    }
682    parse_commit(&object.data)
683}
684
685fn ensure_commit(repo: &Repository, oid: ObjectId) -> Result<()> {
686    let object = repo.odb.read(&oid)?;
687    if object.kind != ObjectKind::Commit {
688        return Err(Error::CorruptObject(format!(
689            "object {oid} is not a commit"
690        )));
691    }
692    Ok(())
693}
694
695fn parse_signature_time(sig: &str) -> i64 {
696    let mut parts = sig.split_whitespace().collect::<Vec<_>>();
697    if parts.len() < 2 {
698        return 0;
699    }
700    let ts = parts.remove(parts.len().saturating_sub(2));
701    ts.parse::<i64>().unwrap_or(0)
702}
703
704/// Merge command-line arguments and `--stdin` input lines for this subset.
705///
706/// Returns `(positive_specs, negative_specs)`.
707///
708/// # Errors
709///
710/// Returns [`Error::InvalidRef`] when stdin provides invalid pseudo-options.
711pub fn collect_revision_specs_with_stdin(
712    args_specs: &[String],
713    read_stdin: bool,
714) -> Result<(Vec<String>, Vec<String>, bool)> {
715    let mut positive = Vec::new();
716    let mut negative = Vec::new();
717    let mut not_mode = false;
718
719    for spec in args_specs {
720        let (pos, neg) = split_revision_token(spec);
721        if not_mode {
722            positive.extend(neg);
723            negative.extend(pos);
724        } else {
725            positive.extend(pos);
726            negative.extend(neg);
727        }
728    }
729
730    if !read_stdin {
731        return Ok((positive, negative, false));
732    }
733
734    let mut in_paths = false;
735    let mut stdin_all_refs = false;
736    let stdin = std::io::read_to_string(std::io::stdin()).map_err(Error::Io)?;
737    for raw_line in stdin.lines() {
738        let line = raw_line.trim();
739        if line.is_empty() {
740            continue;
741        }
742        if in_paths {
743            continue;
744        }
745        if line == "--" {
746            in_paths = true;
747            continue;
748        }
749        if line == "--not" {
750            not_mode = !not_mode;
751            continue;
752        }
753        if line == "--all" {
754            stdin_all_refs = true;
755            continue;
756        }
757        if line.starts_with("--") {
758            return Err(Error::InvalidRef(format!(
759                "invalid option '{line}' in --stdin mode"
760            )));
761        }
762        if line.starts_with('-') {
763            return Err(Error::InvalidRef(format!(
764                "invalid option '{line}' in --stdin mode"
765            )));
766        }
767        let (pos, neg) = split_revision_token(line);
768        if not_mode {
769            positive.extend(neg);
770            negative.extend(pos);
771        } else {
772            positive.extend(pos);
773            negative.extend(neg);
774        }
775    }
776
777    Ok((positive, negative, stdin_all_refs))
778}
779
780/// Resolve every local tag object ID.
781pub fn tag_targets(git_dir: &Path) -> Result<HashSet<ObjectId>> {
782    Ok(refs::list_refs(git_dir, "refs/tags/")?
783        .into_iter()
784        .map(|(_, oid)| oid)
785        .collect())
786}
787
788struct CommitGraph<'r> {
789    repo: &'r Repository,
790    first_parent_only: bool,
791    parents: HashMap<ObjectId, Vec<ObjectId>>,
792    committer_time: HashMap<ObjectId, i64>,
793}
794
795impl<'r> CommitGraph<'r> {
796    fn new(repo: &'r Repository, first_parent_only: bool) -> Self {
797        Self {
798            repo,
799            first_parent_only,
800            parents: HashMap::new(),
801            committer_time: HashMap::new(),
802        }
803    }
804
805    fn parents_of(&mut self, oid: ObjectId) -> Result<Vec<ObjectId>> {
806        self.populate(oid)?;
807        Ok(self.parents.get(&oid).cloned().unwrap_or_default())
808    }
809
810    fn committer_time(&mut self, oid: ObjectId) -> i64 {
811        if self.populate(oid).is_err() {
812            return 0;
813        }
814        self.committer_time.get(&oid).copied().unwrap_or(0)
815    }
816
817    fn populate(&mut self, oid: ObjectId) -> Result<()> {
818        if self.parents.contains_key(&oid) {
819            return Ok(());
820        }
821        let commit = load_commit(self.repo, oid)?;
822        let mut parents = commit.parents;
823        if self.first_parent_only && parents.len() > 1 {
824            parents.truncate(1);
825        }
826        self.committer_time
827            .insert(oid, parse_signature_time(&commit.committer));
828        self.parents.insert(oid, parents);
829        Ok(())
830    }
831}
832
833#[derive(Clone, Copy, Debug, Eq, PartialEq)]
834struct CommitDateKey {
835    oid: ObjectId,
836    date: i64,
837}
838
839impl Ord for CommitDateKey {
840    fn cmp(&self, other: &Self) -> Ordering {
841        match self.date.cmp(&other.date) {
842            Ordering::Equal => self.oid.cmp(&other.oid),
843            ord => ord,
844        }
845    }
846}
847
848impl PartialOrd for CommitDateKey {
849    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
850        Some(self.cmp(other))
851    }
852}
853
854/// Read every line from a newline-delimited file.
855///
856/// # Errors
857///
858/// Returns [`Error::Io`] when the file cannot be read.
859pub fn read_lines(path: &Path) -> Result<Vec<String>> {
860    let content = fs::read_to_string(path)?;
861    Ok(content.lines().map(|line| line.to_owned()).collect())
862}
863
864/// Check if a token uses the symmetric diff `...` notation.
865#[must_use]
866pub fn is_symmetric_diff(token: &str) -> bool {
867    token.contains("...") && !token.contains("....")
868}
869
870/// Split a symmetric diff token into (lhs, rhs).
871#[must_use]
872pub fn split_symmetric_diff(token: &str) -> Option<(String, String)> {
873    token.split_once("...").map(|(l, r)| (l.to_owned(), r.to_owned()))
874}
875
876/// Collect all reachable non-commit objects (trees and blobs) from a set of commits.
877/// Returns (included, omitted) object lists.
878#[allow(dead_code)]
879fn collect_reachable_objects(
880    repo: &Repository,
881    _graph: &mut CommitGraph<'_>,
882    commits: &[ObjectId],
883    filter: Option<&ObjectFilter>,
884) -> Result<(Vec<(ObjectId, String)>, Vec<ObjectId>)> {
885    let mut seen = HashSet::new();
886    let mut result = Vec::new();
887    let mut omitted = Vec::new();
888    for &commit_oid in commits {
889        let commit = load_commit(repo, commit_oid)?;
890        collect_tree_objects_filtered(
891            repo, commit.tree, "", 0, &mut seen, &mut result, &mut omitted, filter,
892        )?;
893    }
894    Ok((result, omitted))
895}
896
897#[allow(dead_code)]
898fn collect_tree_objects_filtered(
899    repo: &Repository,
900    tree_oid: ObjectId,
901    prefix: &str,
902    depth: u64,
903    seen: &mut HashSet<ObjectId>,
904    result: &mut Vec<(ObjectId, String)>,
905    omitted: &mut Vec<ObjectId>,
906    filter: Option<&ObjectFilter>,
907) -> Result<()> {
908    if !seen.insert(tree_oid) {
909        return Ok(());
910    }
911    // Check if this tree passes the filter
912    let tree_included = filter.map_or(true, |f| f.includes_tree(depth));
913    if tree_included {
914        result.push((tree_oid, prefix.to_owned()));
915    } else {
916        omitted.push(tree_oid);
917    }
918    let object = repo.odb.read(&tree_oid)?;
919    if object.kind != ObjectKind::Tree {
920        return Ok(());
921    }
922    let entries = parse_tree(&object.data)?;
923    for entry in entries {
924        let name = String::from_utf8_lossy(&entry.name).to_string();
925        let path = if prefix.is_empty() {
926            name.clone()
927        } else {
928            format!("{prefix}/{name}")
929        };
930        if !seen.insert(entry.oid) {
931            continue;
932        }
933        let child_obj = repo.odb.read(&entry.oid)?;
934        match child_obj.kind {
935            ObjectKind::Tree => {
936                // Recurse into subtrees; the filter check happens at the recursive call
937                seen.remove(&entry.oid);
938                collect_tree_objects_filtered(
939                    repo,
940                    entry.oid,
941                    &path,
942                    depth + 1,
943                    seen,
944                    result,
945                    omitted,
946                    filter,
947                )?;
948            }
949            _ => {
950                // Blob: check filter
951                let blob_included = filter.map_or(true, |f| {
952                    f.includes_blob(child_obj.data.len() as u64)
953                });
954                if blob_included {
955                    result.push((entry.oid, path));
956                } else {
957                    omitted.push(entry.oid);
958                }
959            }
960        }
961    }
962    Ok(())
963}
964
965/// Compute a simple patch-id for each commit.
966#[allow(dead_code)]
967fn compute_patch_ids(
968    repo: &Repository,
969    graph: &mut CommitGraph<'_>,
970    commits: &[ObjectId],
971) -> Result<HashMap<ObjectId, String>> {
972    let mut result = HashMap::new();
973    for &oid in commits {
974        let commit = load_commit(repo, oid)?;
975        let parents = graph.parents_of(oid)?;
976        let parent_tree = if let Some(&parent) = parents.first() {
977            load_commit(repo, parent)?.tree
978        } else {
979            ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?
980        };
981        let patch_id = compute_tree_diff_id(repo, parent_tree, commit.tree)?;
982        result.insert(oid, patch_id);
983    }
984    Ok(result)
985}
986
987#[allow(dead_code)]
988fn compute_tree_diff_id(
989    repo: &Repository,
990    tree_a: ObjectId,
991    tree_b: ObjectId,
992) -> Result<String> {
993    use std::collections::BTreeMap;
994    let entries_a = flatten_tree(repo, tree_a, "")?;
995    let entries_b = flatten_tree(repo, tree_b, "")?;
996    let map_a: BTreeMap<_, _> = entries_a.into_iter().collect();
997    let map_b: BTreeMap<_, _> = entries_b.into_iter().collect();
998    let mut diff_parts = Vec::new();
999    for (path, oid_b) in &map_b {
1000        match map_a.get(path) {
1001            Some(oid_a) if oid_a != oid_b => {
1002                diff_parts.push(format!("+{path}:{oid_b}"));
1003            }
1004            None => {
1005                diff_parts.push(format!("A{path}:{oid_b}"));
1006            }
1007            _ => {}
1008        }
1009    }
1010    for (path, oid_a) in &map_a {
1011        if !map_b.contains_key(path) {
1012            diff_parts.push(format!("D{path}:{oid_a}"));
1013        }
1014    }
1015    diff_parts.sort();
1016    Ok(diff_parts.join("\n"))
1017}
1018
1019#[allow(dead_code)]
1020fn flatten_tree(
1021    repo: &Repository,
1022    tree_oid: ObjectId,
1023    prefix: &str,
1024) -> Result<Vec<(String, ObjectId)>> {
1025    let mut result = Vec::new();
1026    let object = match repo.odb.read(&tree_oid) {
1027        Ok(o) => o,
1028        Err(_) => return Ok(result),
1029    };
1030    if object.kind != ObjectKind::Tree {
1031        return Ok(result);
1032    }
1033    let entries = parse_tree(&object.data)?;
1034    for entry in entries {
1035        let name = String::from_utf8_lossy(&entry.name).to_string();
1036        let path = if prefix.is_empty() {
1037            name
1038        } else {
1039            format!("{prefix}/{name}")
1040        };
1041        let child = repo.odb.read(&entry.oid)?;
1042        if child.kind == ObjectKind::Tree {
1043            result.extend(flatten_tree(repo, entry.oid, &path)?);
1044        } else {
1045            result.push((path, entry.oid));
1046        }
1047    }
1048    Ok(result)
1049}
1050
1051/// Compute merge bases between two commits.
1052pub fn merge_bases(
1053    repo: &Repository,
1054    a: ObjectId,
1055    b: ObjectId,
1056    first_parent_only: bool,
1057) -> Result<Vec<ObjectId>> {
1058    let mut graph = CommitGraph::new(repo, first_parent_only);
1059    let ancestors_a = walk_closure(&mut graph, &[a])?;
1060    let ancestors_b = walk_closure(&mut graph, &[b])?;
1061    let common: HashSet<ObjectId> = ancestors_a.intersection(&ancestors_b).copied().collect();
1062    if common.is_empty() {
1063        return Ok(Vec::new());
1064    }
1065    // Merge bases: common ancestors not dominated by other common ancestors
1066    let mut bases = Vec::new();
1067    for &c in &common {
1068        let is_dominated = common.iter().any(|&other| {
1069            if other == c { return false; }
1070            let other_anc = walk_closure(&mut graph, &[other]).unwrap_or_default();
1071            other_anc.contains(&c)
1072        });
1073        if !is_dominated {
1074            bases.push(c);
1075        }
1076    }
1077    if bases.is_empty() {
1078        let mut sorted: Vec<_> = common.into_iter().collect();
1079        sorted.sort_by_key(|b| std::cmp::Reverse(graph.committer_time(*b)));
1080        bases.push(sorted[0]);
1081    }
1082    Ok(bases)
1083}