Skip to main content

lattice_vcs/
bisect.rs

1use crate::{Repository, Result, VcsError};
2
3/// Bisect operations — start, good, bad, skip, reset, and reading the
4/// state of a bisect already in progress.
5///
6/// Uses the git CLI. Every mutating call is a local operation; none
7/// touches the network.
8pub struct Bisect;
9
10/// A bisect in progress.
11///
12/// Produced by [`Bisect::state`], which returns `None` when no bisect
13/// is running.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct BisectState {
16    /// How many revisions are left to test *after* the one currently
17    /// checked out — `bisect_nr`, the number git prints in its own
18    /// "Bisecting: N revisions left to test after this" line.
19    ///
20    /// `None` until both ends of the range are known: with only a bad
21    /// commit marked there is no range yet, and reporting `0` there
22    /// would read as "done" when nothing has been narrowed at all.
23    pub revisions_left: Option<usize>,
24    /// Roughly how many more marks are needed — git's `bisect_steps`,
25    /// the parenthesised half of the same line.
26    pub steps: Option<usize>,
27    /// The ref the bisect started from, so `reset` has something to
28    /// name. Empty when `.git/BISECT_START` is unreadable.
29    pub start_ref: String,
30}
31
32/// NC.6: what one bisect step did, read from git's own report.
33///
34/// A mark's whole result is in what git prints: the commit it checked
35/// out next, or the culprit. Discarding that output — which `mark` did
36/// until NC.6 — left the caller unable to say "found it", which is the
37/// one message a bisect exists to produce.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum BisectStep {
40    /// git checked out another commit to test.
41    Testing {
42        /// Full sha, as git printed it.
43        commit: String,
44        subject: String,
45        /// "N revisions left to test after this".
46        revisions_left: Option<usize>,
47        /// "(roughly N steps)".
48        steps: Option<usize>,
49    },
50    /// The search is over: this is the first bad commit.
51    Found { commit: String, subject: String },
52    /// Anything else git said — a range still missing an end, only
53    /// skipped commits left, a contradictory mark. The first
54    /// meaningful line, verbatim.
55    Other(String),
56}
57
58/// Parse the stdout of `git bisect start|good|bad|skip`.
59///
60/// Pure, so each shape git prints is pinned without a repository. The
61/// shapes are git's porcelain messages, which have been stable for
62/// years; an unrecognised one falls to [`BisectStep::Other`] rather
63/// than being misread.
64pub fn parse_bisect_step(out: &str) -> BisectStep {
65    let lines: Vec<&str> = out
66        .lines()
67        .map(str::trim)
68        .filter(|l| !l.is_empty())
69        .collect();
70    // Two spellings. Newer git quotes the term — `is the first 'bad' commit`
71    // — because the term is configurable (`git bisect terms`); older git
72    // printed it bare. Recognising only the bare one left a finished bisect
73    // reported as `Other` on a current git.
74    if let Some(found) = lines.iter().find_map(|l| {
75        l.strip_suffix(" is the first bad commit")
76            .or_else(|| l.strip_suffix(" is the first 'bad' commit"))
77    }) {
78        // `git show`-style block follows: the subject is the first
79        // indented line after the headers, which trimming flattened —
80        // so it is the first line after `Date:`.
81        let subject = lines
82            .iter()
83            .skip_while(|l| !l.starts_with("Date:"))
84            .nth(1)
85            .map(|l| l.to_string())
86            .unwrap_or_default();
87        return BisectStep::Found {
88            commit: found.trim().to_string(),
89            subject,
90        };
91    }
92    if let Some(i) = lines.iter().position(|l| l.starts_with("Bisecting: ")) {
93        let head = lines[i];
94        let number_after = |marker: &str| -> Option<usize> {
95            head.split(marker)
96                .nth(1)?
97                .split_whitespace()
98                .next()?
99                .parse()
100                .ok()
101        };
102        let checked_out = lines.get(i + 1).and_then(|l| {
103            let rest = l.strip_prefix('[')?;
104            let (sha, subject) = rest.split_once(']')?;
105            Some((sha.to_string(), subject.trim().to_string()))
106        });
107        if let Some((commit, subject)) = checked_out {
108            return BisectStep::Testing {
109                commit,
110                subject,
111                revisions_left: number_after("Bisecting: "),
112                steps: number_after("(roughly "),
113            };
114        }
115    }
116    BisectStep::Other(lines.first().map(|l| l.to_string()).unwrap_or_default())
117}
118
119/// Parse `git rev-list --bisect-vars` output into
120/// `(revisions_left, steps)`.
121///
122/// **This is plumbing git provides precisely so callers do not
123/// reimplement the bisection arithmetic.** An earlier attempt here
124/// computed "revisions left" as `count(rev-list bad ^good) - 1`, which
125/// is wrong and quietly so: for eight commits it said 6 where git says
126/// 3. Git does not report the size of the remaining range — it reports
127/// the size of the worst-case half *after* the midpoint it just chose,
128/// which is the bisection algorithm, not a subtraction. Showing a
129/// number that disagrees with what `git bisect` prints in the same
130/// terminal is worse than showing none.
131///
132/// The format is `key='value'` or `key=value`, one per line.
133pub fn parse_bisect_vars(out: &str) -> (Option<usize>, Option<usize>) {
134    let get = |key: &str| -> Option<usize> {
135        out.lines()
136            .find_map(|l| l.trim().strip_prefix(key)?.strip_prefix('='))
137            .map(|v| v.trim().trim_matches('\''))
138            .and_then(|v| v.parse::<usize>().ok())
139    };
140    (get("bisect_nr"), get("bisect_steps"))
141}
142
143impl Bisect {
144    /// Whether a bisect is currently running.
145    ///
146    /// A file-existence check, deliberately: this is called from the
147    /// transient builder on the actor thread to decide which rows to
148    /// show, and spawning `git` there to answer a yes/no question
149    /// would be process-spawn latency on a keystroke path.
150    /// `.git/BISECT_LOG` is what git itself creates on `bisect start`
151    /// and removes on `bisect reset`.
152    pub fn in_progress(repo: &Repository) -> bool {
153        repo.gitdir().join("BISECT_LOG").exists()
154    }
155
156    /// The state of the bisect in progress, or `None` if none is.
157    pub fn state(repo: &Repository) -> Result<Option<BisectState>> {
158        if !Self::in_progress(repo) {
159            return Ok(None);
160        }
161        let start_ref = std::fs::read_to_string(repo.gitdir().join("BISECT_START"))
162            .map(|s| s.trim().to_string())
163            .unwrap_or_default();
164        let (revisions_left, steps) = Self::progress(repo);
165        Ok(Some(BisectState {
166            revisions_left,
167            steps,
168            start_ref,
169        }))
170    }
171
172    /// Ask git how far the bisect has narrowed.
173    ///
174    /// `(None, None)` when the range is not yet established (a bad end
175    /// marked but no good one) or when git refuses the walk. A failed
176    /// count must not fail the whole state read: the headerline still
177    /// has "a bisect is running" to say, which is the part that
178    /// matters.
179    fn progress(repo: &Repository) -> (Option<usize>, Option<usize>) {
180        let Ok(goods) =
181            repo.run_git_lines(["for-each-ref", "--format=%(refname)", "refs/bisect/good-*"])
182        else {
183            return (None, None);
184        };
185        if goods.is_empty() {
186            return (None, None);
187        }
188        let mut args: Vec<String> = vec![
189            "rev-list".into(),
190            "--bisect-vars".into(),
191            "refs/bisect/bad".into(),
192        ];
193        for good in &goods {
194            args.push(format!("^{good}"));
195        }
196        match repo.run_git_str(args) {
197            Ok(out) => parse_bisect_vars(&out),
198            Err(_) => (None, None),
199        }
200    }
201
202    /// Start a bisect.
203    ///
204    /// `bad` and `good` are optional: `git bisect start` with neither
205    /// begins an unbounded bisect the user then narrows with `good` /
206    /// `bad`, which is git's own behaviour and worth preserving.
207    pub fn start(repo: &Repository, bad: Option<&str>, good: Option<&str>) -> Result<BisectStep> {
208        let mut args: Vec<String> = vec!["bisect".into(), "start".into()];
209        // Order matters to git: bad first, then good.
210        if let Some(bad) = bad {
211            args.push(bad.to_string());
212        }
213        if let Some(good) = good {
214            args.push(good.to_string());
215        }
216        repo.run_git_str(args)
217            .map(|out| parse_bisect_step(&out))
218            .map_err(|e| VcsError::Bisect(format!("bisect start: {}", e)))
219    }
220
221    /// Mark a revision good (`None` = the one checked out).
222    pub fn good(repo: &Repository, rev: Option<&str>) -> Result<BisectStep> {
223        Self::mark(repo, "good", rev)
224    }
225
226    /// Mark a revision bad (`None` = the one checked out).
227    pub fn bad(repo: &Repository, rev: Option<&str>) -> Result<BisectStep> {
228        Self::mark(repo, "bad", rev)
229    }
230
231    /// Skip a revision that cannot be tested (`None` = the one checked
232    /// out).
233    pub fn skip(repo: &Repository, rev: Option<&str>) -> Result<BisectStep> {
234        Self::mark(repo, "skip", rev)
235    }
236
237    fn mark(repo: &Repository, verb: &str, rev: Option<&str>) -> Result<BisectStep> {
238        let mut args: Vec<String> = vec!["bisect".into(), verb.to_string()];
239        if let Some(rev) = rev {
240            args.push(rev.to_string());
241        }
242        repo.run_git_str(args)
243            .map(|out| parse_bisect_step(&out))
244            .map_err(|e| VcsError::Bisect(format!("bisect {}: {}", verb, e)))
245    }
246
247    /// End the bisect and return to the ref it started from.
248    pub fn reset(repo: &Repository) -> Result<()> {
249        repo.run_git(["bisect", "reset"])
250            .map(|_| ())
251            .map_err(|e| VcsError::Bisect(format!("bisect reset: {}", e)))
252    }
253
254    /// The bisect log — every mark made so far, in git's replayable
255    /// format.
256    pub fn log(repo: &Repository) -> Result<String> {
257        repo.run_git_str(["bisect", "log"])
258            .map_err(|e| VcsError::Bisect(format!("bisect log: {}", e)))
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    /// Verbatim `git rev-list --bisect-vars` output, from a real
267    /// eight-commit bisect. Git printed "Bisecting: 3 revisions left
268    /// to test after this (roughly 2 steps)" for this same state.
269    const REAL_VARS: &str = "bisect_rev='4ffe4a2f5796f503ef813ff4603b30b752732432'\n\
270                             bisect_nr=3\n\
271                             bisect_good=3\n\
272                             bisect_bad=2\n\
273                             bisect_all=7\n\
274                             bisect_steps=2\n";
275
276    /// Verbatim, from git 2.39 on an eight-commit history.
277    const REAL_TESTING: &str = "Bisecting: 1 revision left to test after this (roughly 1 step)\n\
278                                [e73283741f49bb9fa2ad3edd7564e3fbedff4383] c6\n";
279    const REAL_FOUND: &str = "4408d81987933a1275554215cacb0eb9b26df0ce is the first bad commit\n\
280                              commit 4408d81987933a1275554215cacb0eb9b26df0ce\n\
281                              Author: t <t@t>\n\
282                              Date:   Thu Sep 17 10:24:13 2026 +0530\n\
283                              \n    c5\n\n f | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n";
284
285    #[test]
286    fn a_step_that_checks_out_the_next_commit_is_testing() {
287        assert_eq!(
288            parse_bisect_step(REAL_TESTING),
289            BisectStep::Testing {
290                commit: "e73283741f49bb9fa2ad3edd7564e3fbedff4383".into(),
291                subject: "c6".into(),
292                revisions_left: Some(1),
293                steps: Some(1),
294            }
295        );
296    }
297
298    #[test]
299    fn the_last_step_names_the_culprit_and_its_subject() {
300        assert_eq!(
301            parse_bisect_step(REAL_FOUND),
302            BisectStep::Found {
303                commit: "4408d81987933a1275554215cacb0eb9b26df0ce".into(),
304                subject: "c5".into(),
305            }
306        );
307    }
308
309    /// Newer git quotes the term. Same block otherwise, so the same answer.
310    #[test]
311    fn the_quoted_spelling_of_the_last_step_is_recognised_too() {
312        let quoted = REAL_FOUND.replace("is the first bad commit", "is the first 'bad' commit");
313        assert_eq!(
314            parse_bisect_step(&quoted),
315            BisectStep::Found {
316                commit: "4408d81987933a1275554215cacb0eb9b26df0ce".into(),
317                subject: "c5".into(),
318            }
319        );
320    }
321
322    #[test]
323    fn anything_else_is_passed_through_not_misread() {
324        let skipped = "There are only 'skip'ped commits left to test.\n\
325                       The first bad commit could be any of:\nabc\ndef\n";
326        assert_eq!(
327            parse_bisect_step(skipped),
328            BisectStep::Other("There are only 'skip'ped commits left to test.".into())
329        );
330        assert_eq!(
331            parse_bisect_step("status: waiting for good commit(s), bad commit known\n"),
332            BisectStep::Other("status: waiting for good commit(s), bad commit known".into())
333        );
334    }
335
336    #[test]
337    fn the_parsed_numbers_are_the_ones_git_prints() {
338        assert_eq!(parse_bisect_vars(REAL_VARS), (Some(3), Some(2)));
339    }
340
341    #[test]
342    fn a_quoted_value_parses_the_same_as_a_bare_one() {
343        assert_eq!(parse_bisect_vars("bisect_nr='7'\n"), (Some(7), None));
344        assert_eq!(parse_bisect_vars("bisect_nr=7\n"), (Some(7), None));
345    }
346
347    #[test]
348    fn a_prefix_collision_does_not_match_the_wrong_key() {
349        // `bisect_nr` must not be read out of `bisect_nrx=...`, and
350        // `bisect_bad` must not satisfy a lookup for `bisect_b`.
351        assert_eq!(parse_bisect_vars("bisect_nrx=9\n").0, None);
352    }
353
354    #[test]
355    fn missing_or_unparseable_vars_yield_nothing_rather_than_zero() {
356        // Not `Some(0)`: "0 left" reads as finished, and nothing has
357        // been narrowed when git said nothing at all.
358        assert_eq!(parse_bisect_vars(""), (None, None));
359        assert_eq!(parse_bisect_vars("bisect_nr=\n"), (None, None));
360        assert_eq!(parse_bisect_vars("bisect_nr=lots\n"), (None, None));
361    }
362}