1use crate::{Repository, Result, VcsError};
2
3pub struct Bisect;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct BisectState {
16 pub revisions_left: Option<usize>,
24 pub steps: Option<usize>,
27 pub start_ref: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum BisectStep {
40 Testing {
42 commit: String,
44 subject: String,
45 revisions_left: Option<usize>,
47 steps: Option<usize>,
49 },
50 Found { commit: String, subject: String },
52 Other(String),
56}
57
58pub 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 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 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
119pub 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 pub fn in_progress(repo: &Repository) -> bool {
153 repo.gitdir().join("BISECT_LOG").exists()
154 }
155
156 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 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 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 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 pub fn good(repo: &Repository, rev: Option<&str>) -> Result<BisectStep> {
223 Self::mark(repo, "good", rev)
224 }
225
226 pub fn bad(repo: &Repository, rev: Option<&str>) -> Result<BisectStep> {
228 Self::mark(repo, "bad", rev)
229 }
230
231 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 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 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 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 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 #[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("ed),
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 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 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}