1use std::collections::BTreeSet;
6use std::io::Write;
7use std::path::Path;
8use std::process::Command;
9
10use mkit_core::hash::Hash;
11use mkit_core::layout::RepoLayout;
12use mkit_core::ops::bisect::{
13 BisectState, BisectStep, cleanup_bisect, is_bisect_in_progress, next_step, read_state,
14 write_state,
15};
16use mkit_core::refs::{self, Head};
17use mkit_core::store::ObjectStore;
18
19use clap::{Parser, Subcommand};
20
21use crate::clap_shim;
22use crate::exit;
23use crate::format;
24
25#[derive(Debug, Parser)]
26#[command(
27 name = "mkit bisect",
28 about = "Binary-search for a regression-introducing commit."
29)]
30struct BisectOpts {
31 #[command(subcommand)]
32 sub: BisectCmd,
33}
34
35#[derive(Debug, Subcommand)]
36enum BisectCmd {
37 Start,
39 Good { commit: Option<String> },
41 Bad { commit: Option<String> },
43 Skip,
45 Reset,
47 Run {
51 #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
53 argv: Vec<String>,
54 },
55}
56
57#[must_use]
58pub fn run(args: &[String]) -> u8 {
59 let opts = match clap_shim::parse::<BisectOpts>("mkit bisect", args) {
60 Ok(o) => o,
61 Err(code) => return code,
62 };
63 let cwd = match std::env::current_dir() {
64 Ok(p) => p,
65 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
66 };
67 let layout = match super::resolve_layout(&cwd) {
68 Ok(layout) => layout,
69 Err(code) => return code,
70 };
71 let store = match ObjectStore::open(&layout) {
72 Ok(s) => s,
73 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
74 };
75
76 match opts.sub {
77 BisectCmd::Start => start(&layout),
78 BisectCmd::Good { commit } => mark(&store, &layout, commit.as_deref(), true),
79 BisectCmd::Bad { commit } => mark(&store, &layout, commit.as_deref(), false),
80 BisectCmd::Skip => skip(&store, &layout),
81 BisectCmd::Reset => reset(&layout),
82 BisectCmd::Run { argv } => run_automated(&store, &cwd, &layout, &argv),
83 }
84}
85
86fn start(layout: &RepoLayout) -> u8 {
87 if is_bisect_in_progress(layout) {
88 return emit_err(
89 "a bisect is already in progress (use `mkit bisect reset` first)",
90 exit::GENERAL_ERROR,
91 );
92 }
93 let orig_head = match refs::resolve_head(layout) {
94 Ok(Some(h)) => h,
95 Ok(None) => return emit_err("no commits yet", exit::GENERAL_ERROR),
96 Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
97 };
98 let orig_branch = match refs::read_head(layout) {
99 Ok(Head::Branch(name)) => Some(name),
100 _ => None,
101 };
102 let state = BisectState {
103 orig_head,
104 orig_branch,
105 bad_hash: None,
106 good_hashes: Vec::new(),
107 skipped: BTreeSet::default(),
108 };
109 if let Err(e) = write_state(layout, &state) {
110 return emit_err(&format!("write state: {e}"), exit::CANTCREAT);
111 }
112 let mut stderr = std::io::stderr().lock();
113 let _ = writeln!(
114 stderr,
115 "bisect started; mark endpoints with `mkit bisect good <hash>` and `mkit bisect bad <hash>`"
116 );
117 exit::OK
118}
119
120fn mark(store: &ObjectStore, layout: &RepoLayout, arg: Option<&str>, good: bool) -> u8 {
121 if !is_bisect_in_progress(layout) {
122 return emit_err("no bisect in progress", exit::GENERAL_ERROR);
123 }
124 let mut state = match read_state(layout) {
125 Ok(s) => s,
126 Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
127 };
128 let hash_: Hash = match arg {
129 Some(s) => match super::revspec::resolve_revision(store, layout, s) {
130 Ok(h) => h,
131 Err(e) => return emit_err(&format!("bad commit: {e}"), exit::DATAERR),
132 },
133 None => match refs::resolve_head(layout) {
134 Ok(Some(h)) => h,
135 _ => return emit_err("no HEAD; provide an explicit hash", exit::GENERAL_ERROR),
136 },
137 };
138 if good {
139 state.good_hashes.push(hash_);
140 } else {
141 state.bad_hash = Some(hash_);
142 }
143 if let Err(e) = write_state(layout, &state) {
144 return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
145 }
146 report_step(store, &state)
147}
148
149fn skip(store: &ObjectStore, layout: &RepoLayout) -> u8 {
150 if !is_bisect_in_progress(layout) {
151 return emit_err("no bisect in progress", exit::GENERAL_ERROR);
152 }
153 let mut state = match read_state(layout) {
154 Ok(s) => s,
155 Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
156 };
157 let current_mid = match next_step(store, &state) {
159 Ok(BisectStep::Testing { hash, .. }) => hash,
160 Ok(_) => {
161 return emit_err("bisect skip: no current candidate to skip", exit::USAGE);
166 }
167 Err(e) => return emit_err(&format!("bisect skip: {e}"), exit::GENERAL_ERROR),
168 };
169 state.skipped.insert(current_mid);
171 if let Err(e) = write_state(layout, &state) {
172 return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
173 }
174 let mut stderr = std::io::stderr().lock();
175 let _ = writeln!(
176 stderr,
177 "skipped {}; advancing to next candidate",
178 format::short_hash(¤t_mid, 12)
179 );
180 drop(stderr);
181 report_step(store, &state)
182}
183
184fn reset(layout: &RepoLayout) -> u8 {
185 if !is_bisect_in_progress(layout) {
186 return emit_err("no bisect in progress", exit::GENERAL_ERROR);
187 }
188 let state = match read_state(layout) {
189 Ok(s) => s,
190 Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
191 };
192 if let Some(branch) = state.orig_branch.as_deref() {
193 let _ = refs::write_head_branch(layout, branch);
194 } else {
195 let _ = refs::write_head_detached(layout, &state.orig_head);
196 }
197 let _ = cleanup_bisect(layout);
198 let mut stderr = std::io::stderr().lock();
199 let _ = writeln!(stderr, "bisect reset");
200 exit::OK
201}
202
203enum Verdict {
205 Good,
206 Bad,
207 Skip,
208 Abort,
209}
210
211fn classify(code: Option<i32>) -> Verdict {
215 match code {
216 Some(0) => Verdict::Good,
217 Some(125) => Verdict::Skip,
218 Some(c) if (1..=127).contains(&c) => Verdict::Bad,
219 _ => Verdict::Abort,
220 }
221}
222
223fn run_automated(store: &ObjectStore, cwd: &Path, layout: &RepoLayout, argv: &[String]) -> u8 {
235 if !is_bisect_in_progress(layout) {
236 return emit_err("no bisect in progress", exit::GENERAL_ERROR);
237 }
238 let Some((program, cmd_args)) = argv.split_first() else {
240 return emit_err("bisect run: missing command", exit::USAGE);
241 };
242 let mut state = match read_state(layout) {
243 Ok(s) => s,
244 Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
245 };
246
247 let mut guard = 0u32;
251 loop {
252 guard += 1;
253 if guard > 1_000_000 {
254 let _ = restore_head(cwd, &state);
255 return emit_err("bisect run: did not converge", exit::GENERAL_ERROR);
256 }
257
258 let hash = match next_step(store, &state) {
259 Ok(BisectStep::Testing { hash, remaining }) => {
260 let mut stderr = std::io::stderr().lock();
261 let _ = writeln!(
262 stderr,
263 "bisect run: testing {} ({remaining} candidates remaining)",
264 format::short_hash(&hash, 12)
265 );
266 hash
267 }
268 Ok(BisectStep::Found(h)) => {
269 if let Err(code) = restore_head(cwd, &state) {
272 return code;
273 }
274 let mut stderr = std::io::stderr().lock();
275 let _ = writeln!(stderr, "bisect found first bad commit:");
276 drop(stderr);
277 let mut stdout = std::io::stdout().lock();
278 let _ = writeln!(stdout, "{}", format::short_hash(&h, 12));
279 return exit::OK;
280 }
281 Ok(BisectStep::Ambiguous { bad, skipped }) => {
282 let _ = restore_head(cwd, &state);
285 report_ambiguous(bad, &skipped);
286 return exit::GENERAL_ERROR;
287 }
288 Ok(BisectStep::NeedMore) => {
289 return emit_err(
290 "bisect run: need at least one good and one bad commit first",
291 exit::USAGE,
292 );
293 }
294 Err(e) => return emit_err(&format!("bisect run: {e}"), exit::GENERAL_ERROR),
295 };
296
297 if let Err(code) = checkout(cwd, &format::hex_hash(&hash)) {
299 let _ = restore_head(cwd, &state);
300 return code;
301 }
302
303 let status = Command::new(program)
304 .args(cmd_args)
305 .current_dir(cwd)
306 .env("MKIT_BISECT_COMMIT", format::hex_hash(&hash))
307 .status();
308 let code = match status {
309 Ok(s) => s.code(),
310 Err(e) => {
311 let _ = restore_head(cwd, &state);
312 return emit_err(
313 &format!("bisect run: failed to run `{program}`: {e}"),
314 exit::GENERAL_ERROR,
315 );
316 }
317 };
318
319 match classify(code) {
320 Verdict::Good => state.good_hashes.push(hash),
321 Verdict::Bad => state.bad_hash = Some(hash),
322 Verdict::Skip => {
323 state.skipped.insert(hash);
324 }
325 Verdict::Abort => {
326 let _ = restore_head(cwd, &state);
327 let shown = code.map_or_else(|| "signal".to_string(), |c| c.to_string());
328 return emit_err(
329 &format!("bisect run: command aborted (exit {shown})"),
330 exit::GENERAL_ERROR,
331 );
332 }
333 }
334 if let Err(e) = write_state(layout, &state) {
335 let _ = restore_head(cwd, &state);
336 return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
337 }
338 }
339}
340
341fn restore_head(cwd: &Path, state: &BisectState) -> Result<(), u8> {
344 let target = match state.orig_branch.as_deref() {
345 Some(branch) => branch.to_string(),
346 None => format::hex_hash(&state.orig_head),
347 };
348 checkout(cwd, &target)
349}
350
351fn checkout(cwd: &Path, target: &str) -> Result<(), u8> {
365 let exe = match std::env::current_exe() {
366 Ok(p) => p,
367 Err(e) => {
368 return Err(emit_err(
369 &format!("cannot locate mkit binary: {e}"),
370 exit::GENERAL_ERROR,
371 ));
372 }
373 };
374 let out = Command::new(exe)
375 .args(["checkout", "--force", target])
379 .current_dir(cwd)
380 .env("NO_COLOR", "1")
381 .output();
382 match out {
383 Ok(o) if o.status.success() => Ok(()),
384 Ok(o) => {
385 let mut stderr = std::io::stderr().lock();
386 let _ = stderr.write_all(&o.stderr);
387 Err(exit::GENERAL_ERROR)
388 }
389 Err(e) => Err(emit_err(
390 &format!("checkout {target}: {e}"),
391 exit::GENERAL_ERROR,
392 )),
393 }
394}
395
396fn report_step(store: &ObjectStore, state: &BisectState) -> u8 {
397 match next_step(store, state) {
398 Ok(BisectStep::NeedMore) => {
399 let mut stderr = std::io::stderr().lock();
400 let _ = writeln!(
401 stderr,
402 "need at least one good and a bad commit to start searching"
403 );
404 exit::OK
405 }
406 Ok(BisectStep::Testing { hash, remaining }) => {
407 let mut stderr = std::io::stderr().lock();
410 let _ = writeln!(stderr, "bisect: testing ({remaining} candidates remaining)");
411 drop(stderr);
412 let mut stdout = std::io::stdout().lock();
413 let _ = writeln!(stdout, "{}", format::short_hash(&hash, 12));
414 exit::OK
415 }
416 Ok(BisectStep::Found(h)) => {
417 let mut stderr = std::io::stderr().lock();
420 let _ = writeln!(stderr, "bisect found first bad commit:");
421 drop(stderr);
422 let mut stdout = std::io::stdout().lock();
423 let _ = writeln!(stdout, "{}", format::short_hash(&h, 12));
424 exit::OK
425 }
426 Ok(BisectStep::Ambiguous { bad, skipped }) => {
427 report_ambiguous(bad, &skipped);
428 exit::GENERAL_ERROR
429 }
430 Err(e) => emit_err(&format!("bisect: {e}"), exit::GENERAL_ERROR),
431 }
432}
433
434fn report_ambiguous(bad: Hash, skipped: &[Hash]) {
438 let mut stderr = std::io::stderr().lock();
439 let _ = writeln!(
440 stderr,
441 "there are only skipped commits left to test; the first bad commit could be any of:"
442 );
443 drop(stderr);
444 let mut stdout = std::io::stdout().lock();
445 for h in skipped {
446 let _ = writeln!(stdout, "{}", format::short_hash(h, 12));
447 }
448 let _ = writeln!(stdout, "{}", format::short_hash(&bad, 12));
450}
451
452use super::error as emit_err;
453
454#[cfg(test)]
455mod tests {
456 use super::{Verdict, classify};
457
458 #[test]
459 fn classify_matches_git_bisect_run_contract() {
460 assert!(matches!(classify(Some(0)), Verdict::Good));
463 assert!(matches!(classify(Some(125)), Verdict::Skip));
464 assert!(matches!(classify(Some(1)), Verdict::Bad));
465 assert!(matches!(classify(Some(124)), Verdict::Bad));
466 assert!(matches!(classify(Some(126)), Verdict::Bad));
467 assert!(matches!(classify(Some(127)), Verdict::Bad));
468 assert!(matches!(classify(Some(128)), Verdict::Abort));
469 assert!(matches!(classify(Some(255)), Verdict::Abort));
470 assert!(matches!(classify(None), Verdict::Abort));
472 }
473}