Skip to main content

grit_lib/revwalk/
queries.rs

1//! Typed revision-range parsing and high-level rev-walk queries on [`super::RevWalk`].
2use crate::error::{Error, Result};
3use crate::git_date::parse::DateParseError;
4use crate::git_date::parse_date_cutoff;
5use crate::merge_base::{
6    count_commits_reachable_excluding_ancestors_of, is_ancestor as merge_base_is_ancestor,
7    merge_bases_all, merge_bases_first_vs_rest, ReachableCountFilter,
8};
9use crate::objects::ObjectId;
10use crate::refs::{list_refs_for_repository, list_refs_glob_for_repository};
11use crate::repo::Repository;
12use std::sync::Arc;
13
14use crate::rev_list::{
15    rev_list, split_revision_token, OrderingMode, RevListOptions, RevListResult,
16};
17use crate::rev_list_error::RevListError;
18use crate::rev_parse::{
19    is_top_level_range, peel_to_commit_for_merge_base, resolve_revision,
20    resolve_revision_for_range_end, resolve_walk_range_end, split_top_level_triple_dot_range,
21    try_peel_to_commit_for_merge_base,
22};
23use crate::revwalk::filter::CommitFilter;
24
25use super::RevWalk;
26
27/// Parent-count filter for commit lists (Git `--merges` / `--no-merges`).
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub enum MergeFilter {
30    /// Include commits with any parent count.
31    #[default]
32    Any,
33    /// Only merge commits (at least two parents).
34    OnlyMerges,
35    /// Only non-merge commits (at most one parent).
36    NoMerges,
37}
38
39/// Positive ref tips to include in a rev walk (Git `--all`, `--branches`, `--tags`, `--remotes`).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum RefSelection {
42    /// All refs, including `HEAD` when it points at a commit (`--all`).
43    All,
44    /// All branch tips, or those matching an optional `refs/heads/` glob pattern.
45    Branches(Option<String>),
46    /// All tag tips, or those matching an optional `refs/tags/` glob pattern.
47    Tags(Option<String>),
48    /// All remote-tracking tips, or those matching an optional `refs/remotes/` glob pattern.
49    Remotes(Option<String>),
50}
51
52/// Left/right commit counts for a symmetric range (`A...B`), matching `git rev-list --left-right --count`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct LeftRightCounts {
55    /// Commits reachable from the left tip but not the right tip.
56    pub left: usize,
57    /// Commits reachable from the right tip but not the left tip.
58    pub right: usize,
59}
60
61/// Options for [`RevWalk::commits_iter`]: lazy commit-only walks without object/cherry tables.
62///
63/// For token lists, ref selections, path limits, or symmetric diff markers, use
64/// [`RevWalkListOptions`] with [`RevWalk::commits`] or [`RevWalk::rev_list_result`].
65#[derive(Debug, Clone, Default)]
66pub struct RevWalkCommitIterOptions {
67    /// Skip this many matching commits before yielding.
68    pub skip: usize,
69    /// Maximum commits to yield (`None` = unbounded).
70    pub limit: Option<usize>,
71    /// Exclude commits with committer date strictly before this Unix timestamp.
72    pub since: Option<i64>,
73    /// Exclude commits with committer date strictly after this Unix timestamp.
74    pub until: Option<i64>,
75    /// Follow only the first parent at merges when walking history.
76    pub first_parent: bool,
77}
78
79/// Options for listing or counting commits through [`RevWalk`].
80///
81/// This is the high-level, token-based API: pass these to [`RevWalk::commits`],
82/// [`RevWalk::rev_list_result`], or [`RevWalk::count`] together with revision tokens
83/// (`main`, `^v1.0`, `A..B`, `A...B`). Ref selections ([`Self::refs`]) and path limits
84/// ([`Self::paths`]) live here rather than in the tokens. The low-level engine options are
85/// [`RevListOptions`], used by [`rev_list`] and [`RevWalk::walk`] (one spec string).
86#[derive(Debug, Clone, Default)]
87pub struct RevWalkListOptions {
88    /// Maximum number of commits to return (matches `rev-list --max-count` / `-n`).
89    pub limit: Option<usize>,
90    /// Skip this many commits after ordering (matches `rev-list --skip`).
91    pub skip: usize,
92    /// Exclude commits with committer date strictly before this Unix timestamp (`--since` / `--after`).
93    pub since: Option<i64>,
94    /// Exclude commits with committer date strictly after this Unix timestamp (`--until` / `--before`).
95    pub until: Option<i64>,
96    /// Filter by parent count (`--merges` / `--no-merges`).
97    pub merges: MergeFilter,
98    /// Reverse the selected commit order (`--reverse`).
99    pub reverse: bool,
100    /// Additional positive ref tips (`--all`, `--branches`, `--tags`, `--remotes`).
101    pub refs: Vec<RefSelection>,
102    /// Follow only the first parent at merges when collecting listed commits. Exclusions
103    /// (`^A`, the left side of `A..B`) still hide everything they reach through every parent.
104    pub first_parent: bool,
105    /// Limit history to commits that touch these paths (same rules as `rev-list --`).
106    pub paths: Vec<String>,
107    /// Author/committer/grep filter (same semantics as `git rev-list` / `git log`).
108    pub commit_filter: Option<Arc<CommitFilter>>,
109    /// Pickaxe filter (`-S` / `-G`).
110    pub pickaxe: Option<Arc<crate::diff::Pickaxe>>,
111    /// Topological order and graph-friendly path handling (`log --graph`).
112    pub graph: bool,
113    /// Return the commits the walk parses in [`RevListResult::commit_data`] (see
114    /// [`RevListOptions::retain_commit_data`]), for callers that render the listed commits.
115    pub retain_commit_data: bool,
116    /// Classify each listed commit as left or right of the symmetric range (`--left-right`)
117    /// in [`RevListResult::left_right_map`]. Requires exactly one `A...B` token.
118    pub left_right: bool,
119    /// Match the two sides of the symmetric range by patch-id (`--cherry-mark`), filling
120    /// [`RevListResult::left_right_map`], [`RevListResult::cherry_equivalent`] and
121    /// [`RevListResult::cherry_matches`]. Requires exactly one `A...B` token.
122    pub cherry_mark: bool,
123}
124
125/// Positive and negative revision specs produced by [`parse_rev_walk_tokens`].
126///
127/// Feed `positive` / `negative` to [`rev_list`] and copy `symmetric_left` / `symmetric_right`
128/// into the matching [`RevListOptions`] fields. [`RevWalk::commits`] and [`RevWalk::walk`] do
129/// this for you.
130#[derive(Debug, Clone, Default, PartialEq, Eq)]
131pub struct RevWalkParsedSpecs {
132    /// Tips and ranges to include.
133    pub positive: Vec<String>,
134    /// Excluded tips (`^rev` and range left sides).
135    pub negative: Vec<String>,
136    /// Left tip when a token used symmetric difference (`A...B`).
137    pub symmetric_left: Option<ObjectId>,
138    /// Right tip when a token used symmetric difference (`A...B`).
139    pub symmetric_right: Option<ObjectId>,
140}
141
142/// Parse user revision tokens into positive/negative specs for [`rev_list`].
143///
144/// Supports plain tips, `A..B`, `A...B`, and `^X` exclusions across multiple tokens. An empty
145/// side of a range defaults to `HEAD`. `A...B` expands to both tips as positive specs and all
146/// of their merge bases as negative specs. Dots inside `{…}` selectors or after a `:` are
147/// literal, so `:/fix...up`, `main^{/fix...up}` and `HEAD:a...b` stay single revisions, as in
148/// Git.
149///
150/// # Errors
151///
152/// Returns rev-parse and merge-base errors from symmetric-diff expansion.
153pub fn parse_rev_walk_tokens(repo: &Repository, tokens: &[&str]) -> Result<RevWalkParsedSpecs> {
154    let mut positive = Vec::new();
155    let mut negative = Vec::new();
156    let mut symmetric_left = None;
157    let mut symmetric_right = None;
158    for token in tokens {
159        if !is_top_level_range(token) {
160            match token.strip_prefix('^') {
161                Some(excluded) => negative.push(excluded.to_owned()),
162                None => positive.push((*token).to_owned()),
163            }
164        } else if let Some((left, right)) = split_top_level_triple_dot_range(token) {
165            let left_spec = if left.is_empty() { "HEAD" } else { left };
166            let right_spec = if right.is_empty() { "HEAD" } else { right };
167            let (pos, neg, left_oid, right_oid) =
168                symmetric_rev_list_specs(repo, left_spec, right_spec)?;
169            positive.extend(pos);
170            negative.extend(neg);
171            symmetric_left = Some(left_oid);
172            symmetric_right = Some(right_oid);
173        } else {
174            let (pos, neg) = split_revision_token(token);
175            positive.extend(pos);
176            negative.extend(neg);
177        }
178    }
179    Ok(RevWalkParsedSpecs {
180        positive,
181        negative,
182        symmetric_left,
183        symmetric_right,
184    })
185}
186
187fn symmetric_rev_list_specs(
188    repo: &Repository,
189    left_spec: &str,
190    right_spec: &str,
191) -> Result<(Vec<String>, Vec<String>, ObjectId, ObjectId)> {
192    let left_tip = resolve_walk_range_end(repo, left_spec)?;
193    let right_tip = resolve_walk_range_end(repo, right_spec)?;
194    let left = peel_to_commit_for_merge_base(repo, left_tip)?;
195    let right = peel_to_commit_for_merge_base(repo, right_tip)?;
196    let negative = merge_bases_all(repo, &[left, right])?
197        .iter()
198        .map(ObjectId::to_hex)
199        .collect();
200    Ok((vec![left.to_hex(), right.to_hex()], negative, left, right))
201}
202
203pub(crate) fn expand_ref_selections(
204    repo: &Repository,
205    selections: &[RefSelection],
206) -> Result<(bool, Vec<String>)> {
207    let mut all_refs = false;
208    let mut positive = Vec::new();
209    for selection in selections {
210        match selection {
211            RefSelection::All => {
212                all_refs = true;
213            }
214            RefSelection::Branches(pattern) => {
215                positive.extend(ref_glob_tips(repo, "refs/heads/", pattern)?);
216            }
217            RefSelection::Tags(pattern) => {
218                positive.extend(ref_glob_tips(repo, "refs/tags/", pattern)?);
219            }
220            RefSelection::Remotes(pattern) => {
221                positive.extend(ref_glob_tips(repo, "refs/remotes/", pattern)?);
222            }
223        }
224    }
225    Ok((all_refs, positive))
226}
227
228fn ref_glob_tips(repo: &Repository, prefix: &str, pattern: &Option<String>) -> Result<Vec<String>> {
229    let tips = if let Some(glob) = pattern {
230        let full_pattern = format!("{prefix}{glob}");
231        list_refs_glob_for_repository(repo, &full_pattern)?
232    } else {
233        list_refs_for_repository(repo, prefix)?
234    };
235    Ok(tips.into_iter().map(|(_, oid)| oid.to_hex()).collect())
236}
237
238fn merge_filter_to_parent_bounds(merges: MergeFilter) -> (Option<usize>, Option<usize>) {
239    match merges {
240        MergeFilter::Any => (None, None),
241        MergeFilter::OnlyMerges => (Some(2), None),
242        MergeFilter::NoMerges => (None, Some(1)),
243    }
244}
245
246pub(crate) fn rev_list_options_from_walk(
247    repo: &Repository,
248    walk: &RevWalkListOptions,
249    parsed: &RevWalkParsedSpecs,
250    all_refs: bool,
251    count: bool,
252) -> Result<RevListOptions> {
253    let paths = repo.resolve_rev_walk_pathspecs(&walk.paths)?;
254    let (min_parents, max_parents) = merge_filter_to_parent_bounds(walk.merges);
255    let graph = walk.graph;
256    Ok(RevListOptions {
257        first_parent: walk.first_parent,
258        max_count: walk.limit,
259        skip: walk.skip,
260        since_cutoff: walk.since,
261        until_cutoff: walk.until,
262        reverse: walk.reverse,
263        min_parents,
264        max_parents,
265        all_refs,
266        paths,
267        count,
268        ordering: if graph {
269            OrderingMode::Topo
270        } else {
271            OrderingMode::Default
272        },
273        preserve_simplify_merges_graph_merges: graph,
274        path_graph_reorder: graph,
275        parent_rewrite: graph && !walk.paths.is_empty(),
276        use_commit_graph: true,
277        use_commit_graph_bloom: !walk.paths.is_empty(),
278        paths_resolved_from_argv: !walk.paths.is_empty(),
279        symmetric_left: parsed.symmetric_left,
280        symmetric_right: parsed.symmetric_right,
281        commit_filter: walk.commit_filter.clone(),
282        pickaxe: walk.pickaxe.clone(),
283        retain_commit_data: walk.retain_commit_data,
284        left_right: walk.left_right || walk.cherry_mark,
285        cherry_mark: walk.cherry_mark,
286        ..RevListOptions::default()
287    })
288}
289
290/// Parse a user date string into a Unix committer cutoff (Git `--since` / `--until`).
291///
292/// Uses Git's `parse_date_basic` rules with `now_sec` for relative dates.
293///
294/// # Errors
295///
296/// Returns [`Error::Message`] when the string cannot be parsed.
297pub fn committer_cutoff_from_date(
298    date: &str,
299    now_sec: i64,
300) -> std::result::Result<i64, DateParseError> {
301    parse_date_cutoff(date.trim(), now_sec).map_err(|_| DateParseError)
302}
303
304/// Positive tips and hide tips for a default-order reachable walk (no path filter).
305///
306/// Used by [`crate::log_walk::LogWalkQuery`] to mirror `rev-list` tip resolution.
307///
308/// # Errors
309///
310/// Propagates rev-parse errors from resolving revision tokens and ref globs.
311pub fn resolve_log_walk_tips_and_hide(
312    repo: &Repository,
313    tokens: &[&str],
314    walk: &RevWalkListOptions,
315) -> Result<(Vec<ObjectId>, Vec<ObjectId>)> {
316    let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, walk)?;
317    let mut include = crate::rev_list::resolve_revision_specs_to_commits(repo, &positive)?;
318    if all_refs {
319        include.extend(crate::rev_list::all_ref_commit_tips_for_walk(repo)?);
320    }
321    let exclude = crate::rev_list::resolve_revision_specs_to_commits(repo, &parsed.negative)?;
322    Ok((include, exclude))
323}
324
325/// Commit OIDs reachable from `tokens`, preferring pack bitmap acceleration when compatible.
326///
327/// Used by author shortlog-style counts where walk order does not matter.
328///
329/// # Errors
330///
331/// Propagates rev-parse and rev-list errors.
332pub fn rev_list_commits_for_author_group(
333    repo: &Repository,
334    tokens: &[&str],
335    walk: &RevWalkListOptions,
336) -> Result<Vec<ObjectId>> {
337    let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, walk)?;
338    let mut opts = rev_list_options_from_walk(repo, walk, &parsed, all_refs, false)?;
339    opts.use_bitmap_index = true;
340    let result = rev_list(repo, &positive, &parsed.negative, &opts)?;
341    Ok(result.commits)
342}
343
344pub(crate) fn positive_specs_for_walk(
345    repo: &Repository,
346    tokens: &[&str],
347    walk: &RevWalkListOptions,
348) -> Result<(RevWalkParsedSpecs, Vec<String>, bool)> {
349    let parsed = parse_rev_walk_tokens(repo, tokens)?;
350    let (all_refs, mut positive) = expand_ref_selections(repo, &walk.refs)?;
351    positive.extend(parsed.positive.iter().cloned());
352    Ok((parsed, positive, all_refs))
353}
354
355fn single_symmetric_range(
356    tokens: &[&str],
357    parsed: &RevWalkParsedSpecs,
358) -> Result<(ObjectId, ObjectId)> {
359    match (tokens, parsed.symmetric_left, parsed.symmetric_right) {
360        ([_], Some(left), Some(right)) => Ok((left, right)),
361        _ => Err(Error::RevList(RevListError::SymmetricRangeRequired)),
362    }
363}
364
365fn commit_count_from_result(result: RevListResult) -> Result<usize> {
366    Ok(result.commit_count())
367}
368
369fn walk_list_is_single_positive_bounded_page(walk: &RevWalkListOptions) -> bool {
370    walk.skip == 0
371        && matches!(walk.limit, Some(1) | Some(2))
372        && !walk.reverse
373        && walk.merges == MergeFilter::Any
374        && !walk.first_parent
375        && walk.paths.is_empty()
376        && walk.refs.is_empty()
377        && walk.since.is_none()
378        && walk.until.is_none()
379        && walk.pickaxe.is_none()
380        && walk.commit_filter.as_ref().is_none_or(|f| !f.is_active())
381        && !walk.graph
382}
383
384/// Resolve one positive tip when `-n 1` lists exactly that commit (no range tokens).
385fn try_single_positive_bounded_page(
386    repo: &Repository,
387    tokens: &[&str],
388    walk: &RevWalkListOptions,
389) -> Result<Option<RevListResult>> {
390    if !walk_list_is_single_positive_bounded_page(walk) || tokens.len() != 1 {
391        return Ok(None);
392    }
393    let parsed = parse_rev_walk_tokens(repo, tokens)?;
394    if !parsed.negative.is_empty() || parsed.symmetric_left.is_some() || parsed.positive.len() != 1
395    {
396        return Ok(None);
397    }
398    let spec = &parsed.positive[0];
399    if spec.contains("..") {
400        return Ok(None);
401    }
402    let oid = resolve_revision_for_range_end(repo, spec)?;
403    let Some(commit_tip) = try_peel_to_commit_for_merge_base(repo, oid)? else {
404        return Ok(None);
405    };
406    let max_fetch = walk.limit.unwrap_or(1);
407    if max_fetch >= 2 {
408        let parents = crate::rev_parse::commit_parents_for_navigation(repo, commit_tip)?;
409        if parents.len() > 1 {
410            return Ok(None);
411        }
412    }
413    Ok(Some(
414        crate::rev_list::single_tip_bounded_page_with_parent_probe(
415            repo,
416            commit_tip,
417            max_fetch,
418            walk.retain_commit_data,
419        )?,
420    ))
421}
422
423impl<'repo> RevWalk<'repo> {
424    /// List commit object ids reachable from `tokens` in default `rev-list` order.
425    ///
426    /// Each token is a plain tip, `^tip`, `A..B`, or `A...B` (see [`parse_rev_walk_tokens`]);
427    /// put ref selections in [`RevWalkListOptions::refs`] and path limits in
428    /// [`RevWalkListOptions::paths`]. For a single spec with the full engine options, see
429    /// [`RevWalk::walk`].
430    ///
431    /// # Errors
432    ///
433    /// Propagates rev-parse and rev-list errors.
434    pub fn commits(&self, tokens: &[&str], options: &RevWalkListOptions) -> Result<Vec<ObjectId>> {
435        Ok(self.rev_list_result(tokens, options)?.commits)
436    }
437
438    /// Like [`Self::commits`], but retains bounded `--graph` linkage when available, and the
439    /// side and patch-id pairing of each commit with [`RevWalkListOptions::left_right`] or
440    /// [`RevWalkListOptions::cherry_mark`].
441    ///
442    /// # Errors
443    ///
444    /// Returns [`RevListError::SymmetricRangeRequired`] when `left_right` or `cherry_mark`
445    /// is set and `tokens` is not exactly one `A...B` range. Propagates rev-parse and
446    /// rev-list errors.
447    pub fn rev_list_result(
448        &self,
449        tokens: &[&str],
450        options: &RevWalkListOptions,
451    ) -> Result<crate::rev_list::RevListResult> {
452        let repo = self.repository();
453        let sides = options.left_right || options.cherry_mark;
454        if !sides {
455            if let Some(result) = try_single_positive_bounded_page(repo, tokens, options)? {
456                return Ok(result);
457            }
458        }
459        let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, options)?;
460        if sides {
461            single_symmetric_range(tokens, &parsed)?;
462        }
463        let opts = rev_list_options_from_walk(repo, options, &parsed, all_refs, false)?;
464        rev_list(repo, &positive, &parsed.negative, &opts)
465    }
466
467    /// Count reachable commits without collecting them, using the commit-graph when loaded.
468    ///
469    /// # Errors
470    ///
471    /// Propagates rev-parse and rev-list errors.
472    pub fn count(&self, tokens: &[&str], options: &RevWalkListOptions) -> Result<usize> {
473        let repo = self.repository();
474        let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, options)?;
475        if parsed.negative.is_empty()
476            && positive.len() == 1
477            && options.skip == 0
478            && options.limit.is_some()
479            && (options.since.is_some() || options.until.is_some())
480            && options.paths.is_empty()
481            && options.refs.is_empty()
482            && !options.reverse
483            && options.merges == MergeFilter::Any
484            && !options.first_parent
485            && options
486                .commit_filter
487                .as_ref()
488                .is_none_or(|f| !f.is_active())
489            && options.pickaxe.is_none()
490        {
491            let tip = crate::rev_parse::resolve_revision_for_range_end(repo, &positive[0])?;
492            let tip = crate::rev_parse::peel_to_commit_for_merge_base(repo, tip)?;
493            let need = options.limit.unwrap_or(usize::MAX);
494            let cutoffs = crate::merge_base::ReachableWalkCommitterCutoffs {
495                since: options.since,
496                until: options.until,
497            };
498            if let Some(list) =
499                crate::merge_base::try_linear_chain_commits_with_cutoffs(repo, tip, need, cutoffs)?
500            {
501                return Ok(list.len());
502            }
503        }
504        if !parsed.negative.is_empty()
505            && parsed.symmetric_left.is_none()
506            && options.paths.is_empty()
507            && options.refs.is_empty()
508            && options.skip == 0
509            && options.limit.is_none()
510            && !options.reverse
511            && options
512                .commit_filter
513                .as_ref()
514                .is_none_or(|f| !f.is_active())
515            && options.pickaxe.is_none()
516            && !options.graph
517        {
518            let (include, exclude) = resolve_log_walk_tips_and_hide(repo, tokens, options)?;
519            let (min_parents, max_parents) = merge_filter_to_parent_bounds(options.merges);
520            let filter = ReachableCountFilter {
521                min_parents,
522                max_parents,
523                first_parent_only: options.first_parent,
524                since: options.since,
525                until: options.until,
526            };
527            return count_commits_reachable_excluding_ancestors_of(
528                repo, &include, &exclude, filter,
529            );
530        }
531        let opts = rev_list_options_from_walk(repo, options, &parsed, all_refs, true)?;
532        let result = rev_list(repo, &positive, &parsed.negative, &opts)?;
533        commit_count_from_result(result)
534    }
535
536    /// Left and right tips of `tokens` when it is exactly one symmetric range (`A...B`).
537    ///
538    /// The same check [`Self::left_right_counts`] and [`Self::rev_list_result`] (with
539    /// [`RevWalkListOptions::left_right`] or [`RevWalkListOptions::cherry_mark`]) apply, for
540    /// callers that must validate a range without walking it (for example a zero limit).
541    ///
542    /// # Errors
543    ///
544    /// Returns [`RevListError::SymmetricRangeRequired`] when `tokens` is not exactly one `A...B`
545    /// range, or rev-parse / merge-base errors from resolving the tips.
546    pub fn symmetric_range_tips(&self, tokens: &[&str]) -> Result<(ObjectId, ObjectId)> {
547        if tokens.len() != 1 {
548            return Err(Error::RevList(RevListError::SymmetricRangeRequired));
549        }
550        let parsed = parse_rev_walk_tokens(self.repository(), tokens)?;
551        single_symmetric_range(tokens, &parsed)
552    }
553
554    /// Left/right commit counts for a single symmetric range token (`A...B`).
555    ///
556    /// Matches `git rev-list --left-right --count` for the same range.
557    ///
558    /// # Errors
559    ///
560    /// Returns [`RevListError::SymmetricRangeRequired`] when `tokens` is not exactly one `A...B`
561    /// range, or rev-parse / merge-base errors from resolving the tips.
562    pub fn left_right_counts(
563        &self,
564        tokens: &[&str],
565        options: &RevWalkListOptions,
566    ) -> Result<LeftRightCounts> {
567        let repo = self.repository();
568        let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, options)?;
569        let (left, right) = single_symmetric_range(tokens, &parsed)?;
570
571        if options.paths.is_empty()
572            && options.refs.is_empty()
573            && options.skip == 0
574            && options.limit.is_none()
575            && !options.reverse
576            && options
577                .commit_filter
578                .as_ref()
579                .is_none_or(|f| !f.is_active())
580            && options.pickaxe.is_none()
581        {
582            let (min_parents, max_parents) = merge_filter_to_parent_bounds(options.merges);
583            let filter = ReachableCountFilter {
584                min_parents,
585                max_parents,
586                first_parent_only: options.first_parent,
587                since: options.since,
588                until: options.until,
589            };
590            let left_count =
591                count_commits_reachable_excluding_ancestors_of(repo, &[left], &[right], filter)?;
592            let right_count =
593                count_commits_reachable_excluding_ancestors_of(repo, &[right], &[left], filter)?;
594            return Ok(LeftRightCounts {
595                left: left_count,
596                right: right_count,
597            });
598        }
599
600        let mut opts = rev_list_options_from_walk(repo, options, &parsed, all_refs, true)?;
601        // `--count` must not apply `-n`/`--max-count` per side; Git counts the full
602        // left and right sets even when a list limit is also set.
603        opts.max_count = None;
604        opts.left_right = true;
605        let left_count = {
606            let mut left_opts = opts.clone();
607            left_opts.left_only = true;
608            rev_list(repo, &positive, &parsed.negative, &left_opts)?.commit_count()
609        };
610        let mut right_opts = opts;
611        right_opts.right_only = true;
612        let right_count = rev_list(repo, &positive, &parsed.negative, &right_opts)?.commit_count();
613        Ok(LeftRightCounts {
614            left: left_count,
615            right: right_count,
616        })
617    }
618
619    /// Merge bases of two revisions (`git merge-base`, or `--all` when `all` is true).
620    ///
621    /// # Errors
622    ///
623    /// Propagates rev-parse, peel, and merge-base errors.
624    pub fn merge_bases(&self, a: &str, b: &str, all: bool) -> Result<Vec<ObjectId>> {
625        let repo = self.repository();
626        let a_tip = resolve_revision(repo, a)?;
627        let b_tip = resolve_revision(repo, b)?;
628        let a_commit = peel_to_commit_for_merge_base(repo, a_tip)?;
629        let b_commit = peel_to_commit_for_merge_base(repo, b_tip)?;
630        if all {
631            merge_bases_all(repo, &[a_commit, b_commit])
632        } else {
633            merge_bases_first_vs_rest(repo, a_commit, &[b_commit])
634        }
635    }
636
637    /// Whether `ancestor` is reachable from `descendant` (`git merge-base --is-ancestor`).
638    ///
639    /// # Errors
640    ///
641    /// Propagates rev-parse, peel, and merge-base errors.
642    pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
643        let repo = self.repository();
644        let ancestor_tip = resolve_revision(repo, ancestor)?;
645        let descendant_tip = resolve_revision(repo, descendant)?;
646        let ancestor_commit = peel_to_commit_for_merge_base(repo, ancestor_tip)?;
647        let descendant_commit = peel_to_commit_for_merge_base(repo, descendant_tip)?;
648        merge_base_is_ancestor(repo, ancestor_commit, descendant_commit)
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn split_double_dot_token_without_repo() {
658        let (pos, neg) = crate::rev_list::split_revision_token("main..feature");
659        assert_eq!(pos, vec!["feature".to_owned()]);
660        assert_eq!(neg, vec!["main".to_owned()]);
661    }
662
663    #[test]
664    fn merge_filter_parent_bounds() {
665        assert_eq!(
666            merge_filter_to_parent_bounds(MergeFilter::OnlyMerges),
667            (Some(2), None)
668        );
669        assert_eq!(
670            merge_filter_to_parent_bounds(MergeFilter::NoMerges),
671            (None, Some(1))
672        );
673    }
674}