Skip to main content

jj_lib/
bisect.rs

1// Copyright 2025 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Bisect a range of commits.
16
17use std::collections::HashSet;
18use std::sync::Arc;
19
20use itertools::Itertools as _;
21use thiserror::Error;
22
23use crate::backend::CommitId;
24use crate::commit::Commit;
25use crate::repo::Repo;
26use crate::revset::ResolvedRevsetExpression;
27use crate::revset::RevsetEvaluationError;
28use crate::revset::RevsetExpression;
29use crate::revset::RevsetIteratorExt as _;
30
31/// An error that occurred while bisecting
32#[derive(Error, Debug)]
33pub enum BisectionError {
34    /// Failed to evaluate a revset
35    #[error("Failed to evaluate a revset involved in bisection")]
36    RevsetEvaluationError(#[from] RevsetEvaluationError),
37}
38
39/// Indicates whether a given commit was good, bad, or if it could not be
40/// determined.
41#[derive(Debug)]
42pub enum Evaluation {
43    /// The commit was good
44    Good,
45    /// The commit was bad
46    Bad,
47    /// It could not be determined whether the commit was good or bad
48    Skip,
49    /// The commit caused an abort
50    Abort,
51}
52
53impl Evaluation {
54    /// Maps the current evaluation to its inverse.
55    ///
56    /// Maps `Good`->`Bad`, `Bad`->`Good`, and keeps `Skip` as is.
57    pub fn invert(self) -> Self {
58        use Evaluation::*;
59        match self {
60            Good => Bad,
61            Bad => Good,
62            Skip => Skip,
63            Abort => Abort,
64        }
65    }
66}
67
68/// Performs bisection to find the first bad commit in a range.
69pub struct Bisector<'repo> {
70    repo: &'repo dyn Repo,
71    input_range: Arc<ResolvedRevsetExpression>,
72    good_commits: HashSet<CommitId>,
73    bad_commits: HashSet<CommitId>,
74    skipped_commits: HashSet<CommitId>,
75    aborted: bool,
76}
77
78/// The result of bisection.
79#[derive(Debug, PartialEq, Eq, Clone)]
80pub enum BisectionResult {
81    /// Found the first bad commit(s). It should be exactly one unless the input
82    /// range had multiple disjoint heads.
83    Found(Vec<Commit>),
84    /// Could not determine the first bad commit because it was in a
85    /// skipped range.
86    Indeterminate,
87    /// Bisection was aborted.
88    Abort,
89}
90
91/// The next bisection step.
92#[derive(Debug, PartialEq, Eq, Clone)]
93pub enum NextStep {
94    /// The commit must be evaluated.
95    Evaluate(Commit),
96    /// Bisection is complete.
97    Done(BisectionResult),
98}
99
100impl<'repo> Bisector<'repo> {
101    /// Create a new bisector. The range's heads are assumed to be bad.
102    /// Parents of the range's roots are assumed to be good.
103    pub fn new(
104        repo: &'repo dyn Repo,
105        input_range: Arc<ResolvedRevsetExpression>,
106    ) -> Result<Self, BisectionError> {
107        let bad_commits = input_range.heads().evaluate(repo)?.iter().try_collect()?;
108        Ok(Self {
109            repo,
110            input_range,
111            bad_commits,
112            good_commits: HashSet::new(),
113            skipped_commits: HashSet::new(),
114            aborted: false,
115        })
116    }
117
118    /// Mark a commit good.
119    pub fn mark_good(&mut self, id: CommitId) {
120        assert!(!self.bad_commits.contains(&id));
121        assert!(!self.skipped_commits.contains(&id));
122        assert!(!self.aborted);
123        self.good_commits.insert(id);
124    }
125
126    /// Mark a commit bad.
127    pub fn mark_bad(&mut self, id: CommitId) {
128        assert!(!self.good_commits.contains(&id));
129        assert!(!self.skipped_commits.contains(&id));
130        assert!(!self.aborted);
131        self.bad_commits.insert(id);
132    }
133
134    /// Mark a commit as skipped (cannot be determined if it's good or bad).
135    pub fn mark_skipped(&mut self, id: CommitId) {
136        assert!(!self.good_commits.contains(&id));
137        assert!(!self.bad_commits.contains(&id));
138        assert!(!self.aborted);
139        self.skipped_commits.insert(id);
140    }
141
142    /// Mark a commit as causing an abort
143    pub fn mark_abort(&mut self, id: CommitId) {
144        // TODO: Right now, we only use this state for triggering an abort.
145        // A potential improvement would be to make the CLI print out the revset with
146        // the current status of each change, making it possible for a user
147        // to restart an aborted bisect in progress.
148        assert!(!self.good_commits.contains(&id));
149        assert!(!self.bad_commits.contains(&id));
150        assert!(!self.skipped_commits.contains(&id));
151        self.aborted = true;
152    }
153
154    /// Mark a commit as good, bad, or skipped, according to the outcome in
155    /// `evaluation`.
156    pub fn mark(&mut self, id: CommitId, evaluation: Evaluation) {
157        match evaluation {
158            Evaluation::Good => self.mark_good(id),
159            Evaluation::Bad => self.mark_bad(id),
160            Evaluation::Skip => self.mark_skipped(id),
161            Evaluation::Abort => self.mark_abort(id),
162        }
163    }
164
165    /// The commits that were marked good.
166    pub fn good_commits(&self) -> &HashSet<CommitId> {
167        &self.good_commits
168    }
169
170    /// The commits that were marked bad.
171    pub fn bad_commits(&self) -> &HashSet<CommitId> {
172        &self.bad_commits
173    }
174
175    /// The commits that were skipped.
176    pub fn skipped_commits(&self) -> &HashSet<CommitId> {
177        &self.skipped_commits
178    }
179
180    /// Find the next commit to evaluate, or determine that there are no more
181    /// steps.
182    pub fn next_step(&mut self) -> Result<NextStep, BisectionError> {
183        if self.aborted {
184            return Ok(NextStep::Done(BisectionResult::Abort));
185        }
186        let good_expr = RevsetExpression::commits(self.good_commits.iter().cloned().collect());
187        let bad_expr = RevsetExpression::commits(self.bad_commits.iter().cloned().collect());
188        let skipped_expr =
189            RevsetExpression::commits(self.skipped_commits.iter().cloned().collect());
190        // Intersect the input range with the current bad range and then bisect it to
191        // find the next commit to evaluate.
192        // Skipped revisions are simply subtracted from the set.
193        // TODO: Handle long ranges of skipped revisions better
194        let to_evaluate_expr = self
195            .input_range
196            .intersection(&good_expr.heads().range(&bad_expr.roots()))
197            .minus(&bad_expr)
198            .minus(&skipped_expr)
199            .bisect()
200            .latest(1);
201        let to_evaluate_set = to_evaluate_expr.evaluate(self.repo)?;
202        if let Some(commit) = to_evaluate_set
203            .iter()
204            .commits(self.repo.store())
205            .next()
206            .transpose()?
207        {
208            Ok(NextStep::Evaluate(commit))
209        } else {
210            let bad_roots = bad_expr.roots().evaluate(self.repo)?;
211            let bad_commits: Vec<_> = bad_roots.iter().commits(self.repo.store()).try_collect()?;
212            if bad_commits.is_empty() {
213                Ok(NextStep::Done(BisectionResult::Indeterminate))
214            } else {
215                Ok(NextStep::Done(BisectionResult::Found(bad_commits)))
216            }
217        }
218    }
219}