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::collections::VecDeque;
19use std::fmt;
20use std::iter;
21use std::pin::pin;
22use std::sync::Arc;
23
24use futures::TryStreamExt as _;
25use thiserror::Error;
26
27use crate::backend::BackendError;
28use crate::backend::CommitId;
29use crate::commit::Commit;
30use crate::repo::Repo;
31use crate::revset::ResolvedRevsetExpression;
32use crate::revset::Revset;
33use crate::revset::RevsetEvaluationError;
34use crate::revset::RevsetExpression;
35use crate::revset::RevsetStreamExt as _;
36
37/// An error that occurred while bisecting
38#[derive(Error, Debug)]
39pub enum BisectionError {
40    /// Failed to read data from the backend
41    #[error("Failed to read data from the backend involved in bisection")]
42    BackendError(#[from] BackendError),
43    /// Failed to evaluate a revset
44    #[error("Failed to evaluate a revset involved in bisection")]
45    RevsetEvaluationError(#[from] RevsetEvaluationError),
46}
47
48/// Indicates whether a given commit was good, bad, or if it could not be
49/// determined.
50#[derive(Debug, PartialEq, Eq, Clone)]
51pub enum Evaluation {
52    /// The commit was good
53    Good,
54    /// The commit was bad
55    Bad,
56    /// It could not be determined whether the commit was good or bad
57    Skip,
58    /// The commit caused an abort
59    Abort,
60}
61
62impl Evaluation {
63    /// Maps the current evaluation to its inverse.
64    ///
65    /// Maps `Good`->`Bad`, `Bad`->`Good`, and keeps `Skip` as is.
66    pub fn invert(self) -> Self {
67        use Evaluation::*;
68        match self {
69            Good => Bad,
70            Bad => Good,
71            Skip => Skip,
72            Abort => Abort,
73        }
74    }
75
76    /// Returns the inverted evaluation if `condition` is true.
77    pub fn invert_if(self, condition: bool) -> Self {
78        if condition { self.invert() } else { self }
79    }
80}
81impl fmt::Display for Evaluation {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(
84            f,
85            "{}",
86            match self {
87                Self::Good => "good",
88                Self::Bad => "bad",
89                Self::Skip => "skipped",
90                Self::Abort => "aborted",
91            }
92        )
93    }
94}
95
96/// Performs bisection to find the first bad commit in a range.
97pub struct Bisector<'repo> {
98    repo: &'repo dyn Repo,
99    input_range: Arc<ResolvedRevsetExpression>,
100    good_commits: HashSet<CommitId>,
101    bad_commits: HashSet<CommitId>,
102    skipped_commits: HashSet<CommitId>,
103    aborted: bool,
104    verifications: Vec<(CommitId, Evaluation)>,
105}
106
107/// The result of bisection.
108#[derive(Debug, PartialEq, Eq, Clone)]
109pub enum BisectionResult {
110    /// Found the first bad commit(s). It should be exactly one unless the input
111    /// range had multiple disjoint heads.
112    Found(Vec<Commit>),
113    /// The bisection frontier is blurred by the presence of skipped commits
114    FoundDespiteSkips {
115        /// The first commit(s) that did evaluate as bad
116        bad_commits: Vec<Commit>,
117        /// Skipped commits sandwiched between good and bad commits
118        /// Are they good or bad? We couldn't tell.
119        possibly_bad: Vec<Commit>,
120    },
121    /// Could not determine the first bad commit because it was in a
122    /// skipped range.
123    Indeterminate,
124    /// Bisection was aborted.
125    Abort,
126    /// Bisection preconditions were not met.
127    VerificationFailed,
128}
129
130/// The next bisection step.
131#[derive(Debug, PartialEq, Eq, Clone)]
132pub enum NextStep {
133    /// The commit must evaluate a certain way.
134    Verify {
135        /// The commit being verified.
136        commit: Commit,
137        /// If the commit does not evaluate to this, bisection isn't possible.
138        expected_evaluation: Evaluation,
139    },
140    /// The commit must be evaluated.
141    Evaluate(Commit),
142    /// Bisection is complete.
143    Done(BisectionResult),
144}
145
146impl<'repo> Bisector<'repo> {
147    /// Create a new bisector. The range's heads are assumed to be bad.
148    /// Parents of the range's roots are assumed to be good.
149    /// If `verify_preconditions` is true, these assumptions are verified.
150    pub async fn new(
151        repo: &'repo dyn Repo,
152        input_range: Arc<ResolvedRevsetExpression>,
153        verify_preconditions: bool,
154    ) -> Result<Self, BisectionError> {
155        let bad_commits: Vec<CommitId> = input_range
156            .heads()
157            .evaluate(repo)?
158            .stream()
159            .try_collect()
160            .await?;
161        let good_commits: Vec<CommitId> = input_range
162            .connected()
163            .parents()
164            .minus(&input_range.connected())
165            .evaluate(repo)?
166            .stream()
167            .try_collect()
168            .await?;
169
170        let verifications: Vec<(CommitId, Evaluation)> = if verify_preconditions {
171            // these will be run in reverse order
172            iter::empty()
173                .chain(
174                    good_commits
175                        .iter()
176                        .map(|commit_id| (commit_id.clone(), Evaluation::Good)),
177                )
178                .chain(
179                    bad_commits
180                        .iter()
181                        .map(|commit_id| (commit_id.clone(), Evaluation::Bad)),
182                )
183                .collect()
184        } else {
185            vec![]
186        };
187
188        Ok(Self {
189            repo,
190            input_range,
191            verifications,
192            bad_commits: HashSet::from_iter(bad_commits),
193            good_commits: HashSet::new(),
194            skipped_commits: HashSet::new(),
195            aborted: false,
196        })
197    }
198
199    /// Mark a commit good.
200    pub fn mark_good(&mut self, id: CommitId) {
201        assert!(!self.bad_commits.contains(&id));
202        assert!(!self.skipped_commits.contains(&id));
203        assert!(!self.aborted);
204        self.good_commits.insert(id);
205    }
206
207    /// Mark a commit bad.
208    pub fn mark_bad(&mut self, id: CommitId) {
209        assert!(!self.good_commits.contains(&id));
210        assert!(!self.skipped_commits.contains(&id));
211        assert!(!self.aborted);
212        self.bad_commits.insert(id);
213    }
214
215    /// Mark a commit as skipped (cannot be determined if it's good or bad).
216    pub fn mark_skipped(&mut self, id: CommitId) {
217        assert!(!self.good_commits.contains(&id));
218        assert!(!self.bad_commits.contains(&id));
219        assert!(!self.aborted);
220        self.skipped_commits.insert(id);
221    }
222
223    /// Mark a commit as causing an abort
224    pub fn mark_abort(&mut self, id: CommitId) {
225        // TODO: Right now, we only use this state for triggering an abort.
226        // A potential improvement would be to make the CLI print out the revset with
227        // the current status of each change, making it possible for a user
228        // to restart an aborted bisect in progress.
229        assert!(!self.good_commits.contains(&id));
230        assert!(!self.bad_commits.contains(&id));
231        assert!(!self.skipped_commits.contains(&id));
232        self.aborted = true;
233    }
234
235    /// Mark a commit as good, bad, or skipped, according to the outcome in
236    /// `evaluation`.
237    pub fn mark(&mut self, id: CommitId, evaluation: Evaluation) {
238        match evaluation {
239            Evaluation::Good => self.mark_good(id),
240            Evaluation::Bad => self.mark_bad(id),
241            Evaluation::Skip => self.mark_skipped(id),
242            Evaluation::Abort => self.mark_abort(id),
243        }
244    }
245
246    /// The commits that were marked good.
247    pub fn good_commits(&self) -> &HashSet<CommitId> {
248        &self.good_commits
249    }
250
251    /// The commits that were marked bad.
252    pub fn bad_commits(&self) -> &HashSet<CommitId> {
253        &self.bad_commits
254    }
255
256    /// The commits that were skipped.
257    pub fn skipped_commits(&self) -> &HashSet<CommitId> {
258        &self.skipped_commits
259    }
260
261    fn candidates(&self) -> Arc<ResolvedRevsetExpression> {
262        let good_expr = RevsetExpression::commits(self.good_commits.iter().cloned().collect());
263        let bad_expr = RevsetExpression::commits(self.bad_commits.iter().cloned().collect());
264        let skipped_expr =
265            RevsetExpression::commits(self.skipped_commits.iter().cloned().collect());
266
267        self.input_range
268            .intersection(&good_expr.heads().range(&bad_expr.roots()))
269            .minus(&bad_expr)
270            .minus(&skipped_expr)
271    }
272
273    /// Returns the evaluated revset representing the remaining candidate
274    /// commits. Can be used for getting an estimate of how many commits are
275    /// left to evaluate.
276    pub async fn remaining_revset(&self) -> Result<Box<dyn Revset>, BisectionError> {
277        Ok(self.candidates().evaluate(self.repo)?)
278    }
279
280    /// Find the next commit to evaluate, or determine that there are no more
281    /// steps.
282    pub async fn next_step(&mut self) -> Result<NextStep, BisectionError> {
283        if self.aborted {
284            return Ok(NextStep::Done(BisectionResult::Abort));
285        }
286        // Intersect the input range with the current bad range and then bisect it to
287        // find the next commit to evaluate.
288        // Skipped revisions are simply subtracted from the set.
289        // TODO: Handle long ranges of skipped revisions better
290        let to_evaluate_expr = self.candidates().bisect().latest(1);
291        let to_evaluate_set = to_evaluate_expr.evaluate(self.repo)?;
292        if let Some(verification) = self.verifications.pop() {
293            let commit = self.repo.store().get_commit_async(&verification.0).await?;
294            Ok(NextStep::Verify {
295                commit,
296                expected_evaluation: verification.1,
297            })
298        } else if let Some(commit_id) = pin!(to_evaluate_set.stream()).try_next().await? {
299            let commit = self.repo.store().get_commit_async(&commit_id).await?;
300            Ok(NextStep::Evaluate(commit))
301        } else {
302            let bad_expr = RevsetExpression::commits(self.bad_commits.iter().cloned().collect());
303            let bad_roots = bad_expr.roots().evaluate(self.repo)?;
304            let bad_commits: Vec<_> = bad_roots
305                .stream()
306                .commits(self.repo.store())
307                .try_collect()
308                .await?;
309            if bad_commits.is_empty() {
310                Ok(NextStep::Done(BisectionResult::Indeterminate))
311            } else {
312                // were any commits skipped that could also be bad?
313                let mut todo: VecDeque<Commit> = VecDeque::from(bad_commits.clone());
314                let mut possibly_bad: Vec<Commit> = Vec::new();
315                while let Some(commit) = todo.pop_front() {
316                    for parent in commit.parents().await? {
317                        if self.skipped_commits.contains(parent.id()) {
318                            possibly_bad.push(parent.clone());
319                            todo.push_back(parent);
320                        }
321                    }
322                }
323                if possibly_bad.is_empty() {
324                    Ok(NextStep::Done(BisectionResult::Found(bad_commits)))
325                } else {
326                    Ok(NextStep::Done(BisectionResult::FoundDespiteSkips {
327                        bad_commits,
328                        possibly_bad,
329                    }))
330                }
331            }
332        }
333    }
334}