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    open_commit_stream, rev_list, split_revision_token, CommitExtent, CommitStream,
16    CommitStreamItem, CommitStreamParams, OrderingMode, RevListOptions, RevListResult,
17};
18use crate::rev_list_error::RevListError;
19use crate::rev_parse::{
20    is_top_level_range, peel_to_commit_for_merge_base, resolve_revision,
21    resolve_revision_for_range_end, resolve_walk_range_end, split_top_level_triple_dot_range,
22    try_peel_to_commit_for_merge_base,
23};
24use crate::revwalk::filter::CommitFilter;
25
26use super::RevWalk;
27
28/// Parent-count filter for commit lists (Git `--merges` / `--no-merges`).
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub enum MergeFilter {
31    /// Include commits with any parent count.
32    #[default]
33    Any,
34    /// Only merge commits (at least two parents).
35    OnlyMerges,
36    /// Only non-merge commits (at most one parent).
37    NoMerges,
38}
39
40/// Positive ref tips to include in a rev walk (Git `--all`, `--branches`, `--tags`, `--remotes`).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum RefSelection {
43    /// All refs, including `HEAD` when it points at a commit (`--all`).
44    All,
45    /// All branch tips, or those matching an optional `refs/heads/` glob pattern.
46    Branches(Option<String>),
47    /// All tag tips, or those matching an optional `refs/tags/` glob pattern.
48    Tags(Option<String>),
49    /// All remote-tracking tips, or those matching an optional `refs/remotes/` glob pattern.
50    Remotes(Option<String>),
51}
52
53/// Left/right commit counts for a symmetric range (`A...B`), matching `git rev-list --left-right --count`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct LeftRightCounts {
56    /// Commits reachable from the left tip but not the right tip.
57    pub left: usize,
58    /// Commits reachable from the right tip but not the left tip.
59    pub right: usize,
60}
61
62/// Options for [`RevWalk::commits_iter`]: lazy commit-only walks without object/cherry tables.
63///
64/// For token lists, ref selections, path limits, or symmetric diff markers, use
65/// [`RevWalkListOptions`] with [`RevWalk::commits`] or [`RevWalk::rev_list_result`].
66#[derive(Debug, Clone, Default)]
67pub struct RevWalkCommitIterOptions {
68    /// Skip this many matching commits before yielding.
69    pub skip: usize,
70    /// Maximum commits to yield (`None` = unbounded).
71    pub limit: Option<usize>,
72    /// Exclude commits with committer date strictly before this Unix timestamp.
73    pub since: Option<i64>,
74    /// Exclude commits with committer date strictly after this Unix timestamp.
75    pub until: Option<i64>,
76    /// Follow only the first parent at merges when walking history.
77    pub first_parent: bool,
78}
79
80/// Options for listing or counting commits through [`RevWalk`].
81///
82/// This is the high-level, token-based API: pass these to [`RevWalk::commits`],
83/// [`RevWalk::rev_list_result`], or [`RevWalk::count`] together with revision tokens
84/// (`main`, `^v1.0`, `A..B`, `A...B`). Ref selections ([`Self::refs`]) and path limits
85/// ([`Self::paths`]) live here rather than in the tokens. The low-level engine options are
86/// [`RevListOptions`], used by [`rev_list`] and [`RevWalk::walk`] (one spec string).
87#[derive(Debug, Clone, Default)]
88pub struct RevWalkListOptions {
89    /// Maximum number of commits to return (matches `rev-list --max-count` / `-n`).
90    pub limit: Option<usize>,
91    /// Skip this many commits after ordering (matches `rev-list --skip`).
92    pub skip: usize,
93    /// Exclude commits with committer date strictly before this Unix timestamp (`--since` / `--after`).
94    pub since: Option<i64>,
95    /// Exclude commits with committer date strictly after this Unix timestamp (`--until` / `--before`).
96    pub until: Option<i64>,
97    /// Filter by parent count (`--merges` / `--no-merges`).
98    pub merges: MergeFilter,
99    /// Reverse the selected commit order (`--reverse`).
100    pub reverse: bool,
101    /// Additional positive ref tips (`--all`, `--branches`, `--tags`, `--remotes`).
102    pub refs: Vec<RefSelection>,
103    /// Follow only the first parent at merges when collecting listed commits. Exclusions
104    /// (`^A`, the left side of `A..B`) still hide everything they reach through every parent.
105    pub first_parent: bool,
106    /// Limit history to commits that touch these paths (same rules as `rev-list --`).
107    pub paths: Vec<String>,
108    /// Follow renames for a single literal file path (Git `log --follow`). History is still
109    /// walked when the path is absent at the start commit (for example after a rename away).
110    pub follow: bool,
111    /// Include merge commits on the followed path (`log --follow --full-history`).
112    pub full_history: bool,
113    /// Author/committer/grep filter (same semantics as `git rev-list` / `git log`).
114    pub commit_filter: Option<Arc<CommitFilter>>,
115    /// Pickaxe filter (`-S` / `-G`).
116    pub pickaxe: Option<Arc<crate::diff::Pickaxe>>,
117    /// Topological order and graph-friendly path handling (`log --graph`).
118    pub graph: bool,
119    /// Return the commits the walk parses in [`RevListResult::commit_data`] (see
120    /// [`RevListOptions::retain_commit_data`]), for callers that render the listed commits.
121    pub retain_commit_data: bool,
122    /// How much of each commit [`RevWalk::commits_stream`] reads and keeps for
123    /// [`Self::retain_commit_data`]. [`CommitExtent::ThroughSubject`] suits one-line listings
124    /// and skips inflating message bodies; other walks always keep whole commits.
125    pub retained_commit_extent: CommitExtent,
126    /// Classify each listed commit as left or right of the symmetric range (`--left-right`)
127    /// in [`RevListResult::left_right_map`]. Requires exactly one `A...B` token.
128    pub left_right: bool,
129    /// Match the two sides of the symmetric range by patch-id (`--cherry-mark`), filling
130    /// [`RevListResult::left_right_map`], [`RevListResult::cherry_equivalent`] and
131    /// [`RevListResult::cherry_matches`]. Requires exactly one `A...B` token.
132    pub cherry_mark: bool,
133}
134
135/// Positive and negative revision specs produced by [`parse_rev_walk_tokens`].
136///
137/// Feed `positive` / `negative` to [`rev_list`] and copy `symmetric_left` / `symmetric_right`
138/// into the matching [`RevListOptions`] fields. [`RevWalk::commits`] and [`RevWalk::walk`] do
139/// this for you.
140#[derive(Debug, Clone, Default, PartialEq, Eq)]
141pub struct RevWalkParsedSpecs {
142    /// Tips and ranges to include.
143    pub positive: Vec<String>,
144    /// Excluded tips (`^rev` and range left sides).
145    pub negative: Vec<String>,
146    /// Left tip when a token used symmetric difference (`A...B`).
147    pub symmetric_left: Option<ObjectId>,
148    /// Right tip when a token used symmetric difference (`A...B`).
149    pub symmetric_right: Option<ObjectId>,
150}
151
152/// Parse user revision tokens into positive/negative specs for [`rev_list`].
153///
154/// Supports plain tips, `A..B`, `A...B`, and `^X` exclusions across multiple tokens. An empty
155/// side of a range defaults to `HEAD`. `A...B` expands to both tips as positive specs and all
156/// of their merge bases as negative specs. Dots inside `{…}` selectors or after a `:` are
157/// literal, so `:/fix...up`, `main^{/fix...up}` and `HEAD:a...b` stay single revisions, as in
158/// Git.
159///
160/// # Errors
161///
162/// Returns rev-parse and merge-base errors from symmetric-diff expansion.
163pub fn parse_rev_walk_tokens(repo: &Repository, tokens: &[&str]) -> Result<RevWalkParsedSpecs> {
164    let mut positive = Vec::new();
165    let mut negative = Vec::new();
166    let mut symmetric_left = None;
167    let mut symmetric_right = None;
168    for token in tokens {
169        if !is_top_level_range(token) {
170            match token.strip_prefix('^') {
171                Some(excluded) => negative.push(excluded.to_owned()),
172                None => positive.push((*token).to_owned()),
173            }
174        } else if let Some((left, right)) = split_top_level_triple_dot_range(token) {
175            let left_spec = if left.is_empty() { "HEAD" } else { left };
176            let right_spec = if right.is_empty() { "HEAD" } else { right };
177            let (pos, neg, left_oid, right_oid) =
178                symmetric_rev_list_specs(repo, left_spec, right_spec)?;
179            positive.extend(pos);
180            negative.extend(neg);
181            symmetric_left = Some(left_oid);
182            symmetric_right = Some(right_oid);
183        } else {
184            let (pos, neg) = split_revision_token(token);
185            positive.extend(pos);
186            negative.extend(neg);
187        }
188    }
189    Ok(RevWalkParsedSpecs {
190        positive,
191        negative,
192        symmetric_left,
193        symmetric_right,
194    })
195}
196
197fn symmetric_rev_list_specs(
198    repo: &Repository,
199    left_spec: &str,
200    right_spec: &str,
201) -> Result<(Vec<String>, Vec<String>, ObjectId, ObjectId)> {
202    let left_tip = resolve_walk_range_end(repo, left_spec)?;
203    let right_tip = resolve_walk_range_end(repo, right_spec)?;
204    let left = peel_to_commit_for_merge_base(repo, left_tip)?;
205    let right = peel_to_commit_for_merge_base(repo, right_tip)?;
206    let negative = merge_bases_all(repo, &[left, right])?
207        .iter()
208        .map(ObjectId::to_hex)
209        .collect();
210    Ok((vec![left.to_hex(), right.to_hex()], negative, left, right))
211}
212
213pub(crate) fn expand_ref_selections(
214    repo: &Repository,
215    selections: &[RefSelection],
216) -> Result<(bool, Vec<String>)> {
217    let mut all_refs = false;
218    let mut positive = Vec::new();
219    for selection in selections {
220        match selection {
221            RefSelection::All => {
222                all_refs = true;
223            }
224            RefSelection::Branches(pattern) => {
225                positive.extend(ref_glob_tips(repo, "refs/heads/", pattern)?);
226            }
227            RefSelection::Tags(pattern) => {
228                positive.extend(ref_glob_tips(repo, "refs/tags/", pattern)?);
229            }
230            RefSelection::Remotes(pattern) => {
231                positive.extend(ref_glob_tips(repo, "refs/remotes/", pattern)?);
232            }
233        }
234    }
235    Ok((all_refs, positive))
236}
237
238fn ref_glob_tips(repo: &Repository, prefix: &str, pattern: &Option<String>) -> Result<Vec<String>> {
239    let tips = if let Some(glob) = pattern {
240        let full_pattern = format!("{prefix}{glob}");
241        list_refs_glob_for_repository(repo, &full_pattern)?
242    } else {
243        list_refs_for_repository(repo, prefix)?
244    };
245    Ok(tips.into_iter().map(|(_, oid)| oid.to_hex()).collect())
246}
247
248fn merge_filter_to_parent_bounds(merges: MergeFilter) -> (Option<usize>, Option<usize>) {
249    match merges {
250        MergeFilter::Any => (None, None),
251        MergeFilter::OnlyMerges => (Some(2), None),
252        MergeFilter::NoMerges => (None, Some(1)),
253    }
254}
255
256/// Validate [`RevWalkListOptions::follow`] path constraints before walking.
257///
258/// # Errors
259///
260/// Returns [`RevListError::FollowRequiresSinglePath`] or [`RevListError::FollowRequiresLiteralPath`].
261pub fn validate_follow_pathspecs(paths: &[String]) -> Result<()> {
262    if paths.len() != 1 {
263        return Err(crate::rev_list_error::RevListError::FollowRequiresSinglePath.into());
264    }
265    use crate::pathspec::{exact_literal_pathspec_set, PathspecMatchContext};
266    if exact_literal_pathspec_set(paths, PathspecMatchContext::default()).is_none() {
267        return Err(crate::rev_list_error::RevListError::FollowRequiresLiteralPath.into());
268    }
269    Ok(())
270}
271
272pub(crate) fn rev_list_options_from_walk(
273    repo: &Repository,
274    walk: &RevWalkListOptions,
275    parsed: &RevWalkParsedSpecs,
276    all_refs: bool,
277    count: bool,
278) -> Result<RevListOptions> {
279    if walk.follow {
280        validate_follow_pathspecs(&walk.paths)?;
281    }
282    let paths = repo.resolve_rev_walk_pathspecs(&walk.paths)?;
283    let (min_parents, max_parents) = merge_filter_to_parent_bounds(walk.merges);
284    let graph = walk.graph;
285    Ok(RevListOptions {
286        first_parent: walk.first_parent,
287        max_count: walk.limit,
288        skip: walk.skip,
289        since_cutoff: walk.since,
290        until_cutoff: walk.until,
291        reverse: walk.reverse,
292        min_parents,
293        max_parents,
294        all_refs,
295        paths,
296        count,
297        ordering: if graph {
298            OrderingMode::Topo
299        } else {
300            OrderingMode::Default
301        },
302        preserve_simplify_merges_graph_merges: graph,
303        path_graph_reorder: graph,
304        parent_rewrite: graph && !walk.paths.is_empty(),
305        use_commit_graph: true,
306        use_commit_graph_bloom: !walk.paths.is_empty(),
307        paths_resolved_from_argv: !walk.paths.is_empty(),
308        symmetric_left: parsed.symmetric_left,
309        symmetric_right: parsed.symmetric_right,
310        commit_filter: walk.commit_filter.clone(),
311        pickaxe: walk.pickaxe.clone(),
312        retain_commit_data: walk.retain_commit_data,
313        left_right: walk.left_right || walk.cherry_mark,
314        cherry_mark: walk.cherry_mark,
315        follow: walk.follow,
316        full_history: walk.full_history,
317        ..RevListOptions::default()
318    })
319}
320
321/// Parse a user date string into a Unix committer cutoff (Git `--since` / `--until`).
322///
323/// Uses Git's `parse_date_basic` rules with `now_sec` for relative dates.
324///
325/// # Errors
326///
327/// Returns [`Error::Message`] when the string cannot be parsed.
328pub fn committer_cutoff_from_date(
329    date: &str,
330    now_sec: i64,
331) -> std::result::Result<i64, DateParseError> {
332    parse_date_cutoff(date.trim(), now_sec).map_err(|_| DateParseError)
333}
334
335/// Positive tips and hide tips for a default-order reachable walk (no path filter).
336///
337/// Used by [`crate::log_walk::LogWalkQuery`] to mirror `rev-list` tip resolution.
338///
339/// # Errors
340///
341/// Propagates rev-parse errors from resolving revision tokens and ref globs.
342pub fn resolve_log_walk_tips_and_hide(
343    repo: &Repository,
344    tokens: &[&str],
345    walk: &RevWalkListOptions,
346) -> Result<(Vec<ObjectId>, Vec<ObjectId>)> {
347    let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, walk)?;
348    let mut include = crate::rev_list::resolve_revision_specs_to_commits(repo, &positive)?;
349    if all_refs {
350        include.extend(crate::rev_list::all_ref_commit_tips_for_walk(repo)?);
351    }
352    let exclude = crate::rev_list::resolve_revision_specs_to_commits(repo, &parsed.negative)?;
353    Ok((include, exclude))
354}
355
356/// Commit OIDs reachable from `tokens`, preferring pack bitmap acceleration when compatible.
357///
358/// Used by author shortlog-style counts where walk order does not matter.
359///
360/// # Errors
361///
362/// Propagates rev-parse and rev-list errors.
363pub fn rev_list_commits_for_author_group(
364    repo: &Repository,
365    tokens: &[&str],
366    walk: &RevWalkListOptions,
367) -> Result<Vec<ObjectId>> {
368    let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, walk)?;
369    let mut opts = rev_list_options_from_walk(repo, walk, &parsed, all_refs, false)?;
370    opts.use_bitmap_index = true;
371    let result = rev_list(repo, &positive, &parsed.negative, &opts)?;
372    Ok(result.commits)
373}
374
375pub(crate) fn positive_specs_for_walk(
376    repo: &Repository,
377    tokens: &[&str],
378    walk: &RevWalkListOptions,
379) -> Result<(RevWalkParsedSpecs, Vec<String>, bool)> {
380    let parsed = parse_rev_walk_tokens(repo, tokens)?;
381    let (all_refs, mut positive) = expand_ref_selections(repo, &walk.refs)?;
382    positive.extend(parsed.positive.iter().cloned());
383    Ok((parsed, positive, all_refs))
384}
385
386fn single_symmetric_range(
387    tokens: &[&str],
388    parsed: &RevWalkParsedSpecs,
389) -> Result<(ObjectId, ObjectId)> {
390    match (tokens, parsed.symmetric_left, parsed.symmetric_right) {
391        ([_], Some(left), Some(right)) => Ok((left, right)),
392        _ => Err(Error::RevList(RevListError::SymmetricRangeRequired)),
393    }
394}
395
396fn commit_count_from_result(result: RevListResult) -> Result<usize> {
397    Ok(result.commit_count())
398}
399
400fn walk_list_is_single_positive_bounded_page(walk: &RevWalkListOptions) -> bool {
401    walk.skip == 0
402        && matches!(walk.limit, Some(1) | Some(2))
403        && !walk.reverse
404        && walk.merges == MergeFilter::Any
405        && !walk.first_parent
406        && walk.paths.is_empty()
407        && walk.refs.is_empty()
408        && walk.since.is_none()
409        && walk.until.is_none()
410        && walk.pickaxe.is_none()
411        && walk.commit_filter.as_ref().is_none_or(|f| !f.is_active())
412        && !walk.graph
413}
414
415/// Resolve one positive tip when `-n 1` lists exactly that commit (no range tokens).
416fn try_single_positive_bounded_page(
417    repo: &Repository,
418    tokens: &[&str],
419    walk: &RevWalkListOptions,
420) -> Result<Option<RevListResult>> {
421    if !walk_list_is_single_positive_bounded_page(walk) || tokens.len() != 1 {
422        return Ok(None);
423    }
424    let parsed = parse_rev_walk_tokens(repo, tokens)?;
425    if !parsed.negative.is_empty() || parsed.symmetric_left.is_some() || parsed.positive.len() != 1
426    {
427        return Ok(None);
428    }
429    let spec = &parsed.positive[0];
430    if spec.contains("..") {
431        return Ok(None);
432    }
433    let oid = resolve_revision_for_range_end(repo, spec)?;
434    let Some(commit_tip) = try_peel_to_commit_for_merge_base(repo, oid)? else {
435        return Ok(None);
436    };
437    let max_fetch = walk.limit.unwrap_or(1);
438    if max_fetch >= 2 {
439        let parents = crate::rev_parse::commit_parents_for_navigation(repo, commit_tip)?;
440        if parents.len() > 1 {
441            return Ok(None);
442        }
443    }
444    Ok(Some(
445        crate::rev_list::single_tip_bounded_page_with_parent_probe(
446            repo,
447            commit_tip,
448            max_fetch,
449            walk.retain_commit_data,
450        )?,
451    ))
452}
453
454/// Whether [`RevWalk::commits_stream`] can serve `walk` instead of a full [`RevWalk::rev_list_result`].
455///
456/// Returns false for path limits, ordering tweaks, filters, symmetric diff markers, and other
457/// options that need the full rev-list engine.
458#[must_use]
459pub fn rev_walk_list_supports_stream(walk: &RevWalkListOptions) -> bool {
460    !walk.graph
461        && walk.paths.is_empty()
462        && !walk.reverse
463        && !walk.first_parent
464        && walk.merges == MergeFilter::Any
465        && walk.commit_filter.as_ref().is_none_or(|f| !f.is_active())
466        && walk.pickaxe.is_none()
467        && !walk.left_right
468        && !walk.cherry_mark
469}
470
471/// Lazy commit walk over the same tokens and [`RevWalkListOptions`] as [`RevWalk::rev_list_result`],
472/// for plain default-order reachable history.
473pub type RevWalkCommitStream<'repo> = CommitStream<'repo>;
474
475/// One commit from [`RevWalk::commits_stream`].
476pub type RevWalkCommitStreamItem = CommitStreamItem;
477
478impl<'repo> RevWalk<'repo> {
479    /// Lazy commit walk for plain default-order history (same tokens and [`RevWalkListOptions`] as
480    /// [`Self::rev_list_result`], without object/cherry side tables).
481    ///
482    /// Returns [`None`] when [`rev_walk_list_supports_stream`] is false; use [`Self::rev_list_result`]
483    /// for path limits, `--reverse`, filters, `--graph`, and symmetric diff markers.
484    ///
485    /// # Errors
486    ///
487    /// Propagates rev-parse and walk setup errors.
488    pub fn commits_stream(
489        &self,
490        tokens: &[&str],
491        walk: &RevWalkListOptions,
492    ) -> Result<Option<RevWalkCommitStream<'repo>>> {
493        if !rev_walk_list_supports_stream(walk) {
494            return Ok(None);
495        }
496        let (tips, hide) = resolve_log_walk_tips_and_hide(self.repository(), tokens, walk)?;
497        let params = CommitStreamParams {
498            skip: walk.skip,
499            limit: walk.limit,
500            since: walk.since,
501            until: walk.until,
502            first_parent: walk.first_parent,
503            retain_commit_bytes: walk.retain_commit_data,
504            retained_commit_extent: walk.retained_commit_extent,
505        };
506        Ok(Some(open_commit_stream(
507            self.repository(),
508            &tips,
509            &hide,
510            &params,
511        )?))
512    }
513
514    /// List commit object ids reachable from `tokens` in default `rev-list` order.
515    ///
516    /// Each token is a plain tip, `^tip`, `A..B`, or `A...B` (see [`parse_rev_walk_tokens`]);
517    /// put ref selections in [`RevWalkListOptions::refs`] and path limits in
518    /// [`RevWalkListOptions::paths`]. For a single spec with the full engine options, see
519    /// [`RevWalk::walk`].
520    ///
521    /// # Errors
522    ///
523    /// Propagates rev-parse and rev-list errors.
524    pub fn commits(&self, tokens: &[&str], options: &RevWalkListOptions) -> Result<Vec<ObjectId>> {
525        Ok(self.rev_list_result(tokens, options)?.commits)
526    }
527
528    /// Like [`Self::commits`], but retains bounded `--graph` linkage when available, and the
529    /// side and patch-id pairing of each commit with [`RevWalkListOptions::left_right`] or
530    /// [`RevWalkListOptions::cherry_mark`].
531    ///
532    /// # Errors
533    ///
534    /// Returns [`RevListError::SymmetricRangeRequired`] when `left_right` or `cherry_mark`
535    /// is set and `tokens` is not exactly one `A...B` range. Propagates rev-parse and
536    /// rev-list errors.
537    pub fn rev_list_result(
538        &self,
539        tokens: &[&str],
540        options: &RevWalkListOptions,
541    ) -> Result<crate::rev_list::RevListResult> {
542        let repo = self.repository();
543        let sides = options.left_right || options.cherry_mark;
544        if !sides {
545            if let Some(result) = try_single_positive_bounded_page(repo, tokens, options)? {
546                return Ok(result);
547            }
548        }
549        let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, options)?;
550        if sides {
551            single_symmetric_range(tokens, &parsed)?;
552        }
553        let opts = rev_list_options_from_walk(repo, options, &parsed, all_refs, false)?;
554        rev_list(repo, &positive, &parsed.negative, &opts)
555    }
556
557    /// Count reachable commits without collecting them, using the commit-graph when loaded.
558    ///
559    /// # Errors
560    ///
561    /// Propagates rev-parse and rev-list errors.
562    pub fn count(&self, tokens: &[&str], options: &RevWalkListOptions) -> Result<usize> {
563        let repo = self.repository();
564        let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, options)?;
565        if parsed.negative.is_empty()
566            && positive.len() == 1
567            && options.skip == 0
568            && options.limit.is_some()
569            && (options.since.is_some() || options.until.is_some())
570            && options.paths.is_empty()
571            && options.refs.is_empty()
572            && !options.reverse
573            && options.merges == MergeFilter::Any
574            && !options.first_parent
575            && options
576                .commit_filter
577                .as_ref()
578                .is_none_or(|f| !f.is_active())
579            && options.pickaxe.is_none()
580        {
581            let tip = crate::rev_parse::resolve_revision_for_range_end(repo, &positive[0])?;
582            let tip = crate::rev_parse::peel_to_commit_for_merge_base(repo, tip)?;
583            let need = options.limit.unwrap_or(usize::MAX);
584            let cutoffs = crate::merge_base::ReachableWalkCommitterCutoffs {
585                since: options.since,
586                until: options.until,
587            };
588            if let Some(list) =
589                crate::merge_base::try_linear_chain_commits_with_cutoffs(repo, tip, need, cutoffs)?
590            {
591                return Ok(list.len());
592            }
593        }
594        if !parsed.negative.is_empty()
595            && parsed.symmetric_left.is_none()
596            && options.paths.is_empty()
597            && options.refs.is_empty()
598            && options.skip == 0
599            && options.limit.is_none()
600            && !options.reverse
601            && options
602                .commit_filter
603                .as_ref()
604                .is_none_or(|f| !f.is_active())
605            && options.pickaxe.is_none()
606            && !options.graph
607        {
608            let (include, exclude) = resolve_log_walk_tips_and_hide(repo, tokens, options)?;
609            let (min_parents, max_parents) = merge_filter_to_parent_bounds(options.merges);
610            let filter = ReachableCountFilter {
611                min_parents,
612                max_parents,
613                first_parent_only: options.first_parent,
614                since: options.since,
615                until: options.until,
616            };
617            return count_commits_reachable_excluding_ancestors_of(
618                repo, &include, &exclude, filter,
619            );
620        }
621        let opts = rev_list_options_from_walk(repo, options, &parsed, all_refs, true)?;
622        let result = rev_list(repo, &positive, &parsed.negative, &opts)?;
623        commit_count_from_result(result)
624    }
625
626    /// Left and right tips of `tokens` when it is exactly one symmetric range (`A...B`).
627    ///
628    /// The same check [`Self::left_right_counts`] and [`Self::rev_list_result`] (with
629    /// [`RevWalkListOptions::left_right`] or [`RevWalkListOptions::cherry_mark`]) apply, for
630    /// callers that must validate a range without walking it (for example a zero limit).
631    ///
632    /// # Errors
633    ///
634    /// Returns [`RevListError::SymmetricRangeRequired`] when `tokens` is not exactly one `A...B`
635    /// range, or rev-parse / merge-base errors from resolving the tips.
636    pub fn symmetric_range_tips(&self, tokens: &[&str]) -> Result<(ObjectId, ObjectId)> {
637        if tokens.len() != 1 {
638            return Err(Error::RevList(RevListError::SymmetricRangeRequired));
639        }
640        let parsed = parse_rev_walk_tokens(self.repository(), tokens)?;
641        single_symmetric_range(tokens, &parsed)
642    }
643
644    /// Left/right commit counts for a single symmetric range token (`A...B`).
645    ///
646    /// Matches `git rev-list --left-right --count` for the same range.
647    ///
648    /// # Errors
649    ///
650    /// Returns [`RevListError::SymmetricRangeRequired`] when `tokens` is not exactly one `A...B`
651    /// range, or rev-parse / merge-base errors from resolving the tips.
652    pub fn left_right_counts(
653        &self,
654        tokens: &[&str],
655        options: &RevWalkListOptions,
656    ) -> Result<LeftRightCounts> {
657        let repo = self.repository();
658        let (parsed, positive, all_refs) = positive_specs_for_walk(repo, tokens, options)?;
659        let (left, right) = single_symmetric_range(tokens, &parsed)?;
660
661        if options.paths.is_empty()
662            && options.refs.is_empty()
663            && options.skip == 0
664            && options.limit.is_none()
665            && !options.reverse
666            && options
667                .commit_filter
668                .as_ref()
669                .is_none_or(|f| !f.is_active())
670            && options.pickaxe.is_none()
671        {
672            let (min_parents, max_parents) = merge_filter_to_parent_bounds(options.merges);
673            let filter = ReachableCountFilter {
674                min_parents,
675                max_parents,
676                first_parent_only: options.first_parent,
677                since: options.since,
678                until: options.until,
679            };
680            let left_count =
681                count_commits_reachable_excluding_ancestors_of(repo, &[left], &[right], filter)?;
682            let right_count =
683                count_commits_reachable_excluding_ancestors_of(repo, &[right], &[left], filter)?;
684            return Ok(LeftRightCounts {
685                left: left_count,
686                right: right_count,
687            });
688        }
689
690        let mut opts = rev_list_options_from_walk(repo, options, &parsed, all_refs, true)?;
691        // `--count` must not apply `-n`/`--max-count` per side; Git counts the full
692        // left and right sets even when a list limit is also set.
693        opts.max_count = None;
694        opts.left_right = true;
695        let left_count = {
696            let mut left_opts = opts.clone();
697            left_opts.left_only = true;
698            rev_list(repo, &positive, &parsed.negative, &left_opts)?.commit_count()
699        };
700        let mut right_opts = opts;
701        right_opts.right_only = true;
702        let right_count = rev_list(repo, &positive, &parsed.negative, &right_opts)?.commit_count();
703        Ok(LeftRightCounts {
704            left: left_count,
705            right: right_count,
706        })
707    }
708
709    /// Merge bases of two revisions (`git merge-base`, or `--all` when `all` is true).
710    ///
711    /// # Errors
712    ///
713    /// Propagates rev-parse, peel, and merge-base errors.
714    pub fn merge_bases(&self, a: &str, b: &str, all: bool) -> Result<Vec<ObjectId>> {
715        let repo = self.repository();
716        let a_tip = resolve_revision(repo, a)?;
717        let b_tip = resolve_revision(repo, b)?;
718        let a_commit = peel_to_commit_for_merge_base(repo, a_tip)?;
719        let b_commit = peel_to_commit_for_merge_base(repo, b_tip)?;
720        if all {
721            merge_bases_all(repo, &[a_commit, b_commit])
722        } else {
723            merge_bases_first_vs_rest(repo, a_commit, &[b_commit])
724        }
725    }
726
727    /// Whether `ancestor` is reachable from `descendant` (`git merge-base --is-ancestor`).
728    ///
729    /// # Errors
730    ///
731    /// Propagates rev-parse, peel, and merge-base errors.
732    pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
733        let repo = self.repository();
734        let ancestor_tip = resolve_revision(repo, ancestor)?;
735        let descendant_tip = resolve_revision(repo, descendant)?;
736        let ancestor_commit = peel_to_commit_for_merge_base(repo, ancestor_tip)?;
737        let descendant_commit = peel_to_commit_for_merge_base(repo, descendant_tip)?;
738        merge_base_is_ancestor(repo, ancestor_commit, descendant_commit)
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn split_double_dot_token_without_repo() {
748        let (pos, neg) = crate::rev_list::split_revision_token("main..feature");
749        assert_eq!(pos, vec!["feature".to_owned()]);
750        assert_eq!(neg, vec!["main".to_owned()]);
751    }
752
753    #[test]
754    fn merge_filter_parent_bounds() {
755        assert_eq!(
756            merge_filter_to_parent_bounds(MergeFilter::OnlyMerges),
757            (Some(2), None)
758        );
759        assert_eq!(
760            merge_filter_to_parent_bounds(MergeFilter::NoMerges),
761            (None, Some(1))
762        );
763    }
764
765    fn repo_with_commits(count: usize) -> (tempfile::TempDir, Repository) {
766        use crate::ident::Signature;
767        use crate::porcelain::commit::{create_commit, CommitRequest};
768        use crate::progress::{NeverCancel, NullProgress};
769        use crate::repo::{InitOptions, Repository};
770        let dir = tempfile::tempdir().expect("tempdir");
771        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
772        for i in 0..count {
773            let mut req = CommitRequest::new(format!("commit {i}"));
774            req.allow_empty = true;
775            req.author =
776                Some(Signature::new("A", "a@example.com", repo.identity_now()).expect("author"));
777            req.committer =
778                Some(Signature::new("C", "c@example.com", repo.identity_now()).expect("committer"));
779            create_commit(&repo, &req, &mut NullProgress, &NeverCancel).expect("commit");
780        }
781        (dir, repo)
782    }
783
784    #[test]
785    fn commits_stream_matches_rev_list_for_all_refs() {
786        let (_dir, repo) = repo_with_commits(4);
787        let walk = RevWalkListOptions {
788            refs: vec![RefSelection::All],
789            ..RevWalkListOptions::default()
790        };
791        let tokens = ["HEAD"];
792        let listed = repo
793            .rev_walk()
794            .rev_list_result(&tokens, &walk)
795            .expect("list")
796            .commits;
797        let streamed: Vec<_> = repo
798            .rev_walk()
799            .commits_stream(&tokens, &walk)
800            .expect("stream")
801            .expect("some")
802            .map(|item| item.expect("walk").oid)
803            .collect();
804        assert_eq!(streamed, listed);
805    }
806
807    #[test]
808    fn commits_stream_keeps_commits_through_their_subjects() {
809        use crate::ident::Signature;
810        use crate::porcelain::commit::{create_commit, CommitRequest};
811        use crate::progress::{NeverCancel, NullProgress};
812        use crate::repo::InitOptions;
813        let dir = tempfile::tempdir().expect("tempdir");
814        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
815        for i in 0..4 {
816            let mut req =
817                CommitRequest::new(format!("subject {i}\n\n{}", "body line\n".repeat(50)));
818            req.allow_empty = true;
819            req.author =
820                Some(Signature::new("A", "a@example.com", repo.identity_now()).expect("author"));
821            req.committer =
822                Some(Signature::new("C", "c@example.com", repo.identity_now()).expect("committer"));
823            create_commit(&repo, &req, &mut NullProgress, &NeverCancel).expect("commit");
824        }
825        let stream = |extent| -> Vec<(ObjectId, Vec<u8>)> {
826            let walk = RevWalkListOptions {
827                retain_commit_data: true,
828                retained_commit_extent: extent,
829                ..RevWalkListOptions::default()
830            };
831            repo.rev_walk()
832                .commits_stream(&["HEAD"], &walk)
833                .expect("stream")
834                .expect("some")
835                .map(|item| {
836                    let item = item.expect("walk");
837                    (item.oid, item.commit_bytes.expect("kept bytes"))
838                })
839                .collect()
840        };
841        let whole = stream(CommitExtent::Whole);
842        let short = stream(CommitExtent::ThroughSubject);
843        assert_eq!(whole.len(), 4);
844        for ((oid, full), (short_oid, prefix)) in whole.iter().zip(&short) {
845            assert_eq!(oid, short_oid);
846            assert_eq!(*full, repo.odb().read(oid).expect("read").data);
847            assert!(prefix.len() < full.len() && full.starts_with(prefix));
848            assert_eq!(
849                *prefix,
850                repo.odb().read_commit_through_subject(oid).expect("prefix")
851            );
852            assert!(!prefix.windows(9).any(|w| w == b"body line"));
853            assert_eq!(
854                crate::objects::read_commit_subject_line(prefix).expect("subject"),
855                crate::objects::read_commit_subject_line(full).expect("subject")
856            );
857        }
858    }
859
860    #[test]
861    fn commits_stream_honors_limit_without_full_walk() {
862        let (_dir, repo) = repo_with_commits(5);
863        let walk = RevWalkListOptions {
864            limit: Some(2),
865            ..RevWalkListOptions::default()
866        };
867        let mut stream = repo
868            .rev_walk()
869            .commits_stream(&["HEAD"], &walk)
870            .expect("stream")
871            .expect("some");
872        let collected: Vec<_> = stream.by_ref().take(2).map(|r| r.unwrap().oid).collect();
873        assert_eq!(collected.len(), 2);
874        drop(stream);
875        let all = repo
876            .rev_walk()
877            .rev_list_result(&["HEAD"], &RevWalkListOptions::default())
878            .expect("list")
879            .commits;
880        assert_eq!(all.len(), 5);
881    }
882}