Skip to main content

mkit_cli/commands/
bisect.rs

1//! `mkit bisect start|good|bad|reset|skip|run` — binary-search a history
2//! for the commit that introduced a regression. Backing state + search
3//! logic live in `mkit_core::ops::bisect`.
4
5use 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    /// Begin a bisect session at HEAD.
38    Start,
39    /// Mark a commit (or HEAD) as good.
40    Good { commit: Option<String> },
41    /// Mark a commit (or HEAD) as bad.
42    Bad { commit: Option<String> },
43    /// Skip the current candidate.
44    Skip,
45    /// End the session and restore the original HEAD.
46    Reset,
47    /// Automatically bisect: run `<cmd> [args…]` at each candidate,
48    /// classifying by exit status (0=good, 125=skip, 1–127 else=bad,
49    /// ≥128=abort) until the first bad commit is found.
50    Run {
51        /// The command to run, followed by its arguments.
52        #[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    // Determine the current midpoint to skip.
158    let current_mid = match next_step(store, &state) {
159        Ok(BisectStep::Testing { hash, .. }) => hash,
160        Ok(_) => {
161            // Nothing to skip: either already found or not enough data.
162            // User error (skip invoked when bisect has no current
163            // candidate); USAGE rather than OK so scripts see the
164            // failure.
165            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    // Add the current midpoint to the exclusion set, then advance.
170    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(&current_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
203/// How a `bisect run` command's exit status classifies the candidate.
204enum Verdict {
205    Good,
206    Bad,
207    Skip,
208    Abort,
209}
210
211/// Map a child exit code to a verdict, following git's `bisect run`
212/// contract: `0` good, `125` skip, `1`–`127` (except `125`) bad, `>=128`
213/// or signal-killed (`code() == None`) abort.
214fn 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
223/// Drive the bisection automatically (git's `bisect run`): check out each
224/// candidate, run `<program> [cmd_args…]`, classify by exit status, and
225/// converge on the first bad commit.
226///
227/// mkit's bisect is otherwise print-candidate (no auto-checkout); `run`
228/// checks out each candidate transiently so the command tests real code,
229/// then restores the original HEAD once it converges — it *prints* the
230/// first bad commit rather than parking the worktree there (the intentional
231/// divergence that keeps bisect's overall model print-candidate). The
232/// candidate is also exported as `MKIT_BISECT_COMMIT` for commands that
233/// prefer it over the worktree.
234fn 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    // clap's `required = true` guarantees at least the program name.
239    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    // Each iteration records an endpoint (good/bad) or a skip, all of which
248    // strictly shrink the candidate set, so the loop terminates; the guard
249    // is a backstop against a misbehaving `next_step`.
250    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                // Converged: restore the original HEAD, then report. The
270                // hash goes to stdout (data), the prose to stderr.
271                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                // Only skipped commits remain: like git, report that the
283                // first bad commit is ambiguous rather than guessing `bad`.
284                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        // Check out the candidate so the command tests its actual tree.
298        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
341/// Restore the worktree + HEAD to where bisect started, re-exec'ing
342/// `mkit checkout` on the original branch (or detached original HEAD).
343fn 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
351/// Re-exec this same binary as `mkit checkout --force <target>` to
352/// materialize a commit into the worktree, reusing checkout's full
353/// safety/index handling and discarding the test command's tracked-file
354/// scribbles. On failure prints the child's diagnostics and returns a code.
355///
356/// This is deliberately its own small self-exec rather than reusing
357/// `mcp::run_subprocess`: that helper wraps the result in an MCP
358/// `CallOutcome`, whereas here we only need the raw exit status plus stderr
359/// passthrough. Sparse caveat: the child checkout does not re-apply a
360/// persisted sparse cone (checkout only honors an explicit `--sparse`), so
361/// in a sparse repo each candidate materializes the full tree — a
362/// pre-existing checkout limitation, documented on `bisect run` in
363/// `docs/CLI.md`.
364fn 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        // `--force`: discard the test command's scribbles on tracked files
376        // so the next candidate materializes cleanly (git bisect resets the
377        // worktree between candidates).
378        .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            // Progress prose to stderr; the candidate hash itself to
408            // stdout so `H=$(mkit bisect good)` keeps working.
409            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            // The "found" result is genuinely a data point — emit the
418            // hash on stdout and the prose on stderr.
419            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
434/// Report an ambiguous result (only skipped commits remain), like git's
435/// "The first bad commit could be any of …". The suspect hashes go to
436/// stdout (data), the prose to stderr.
437fn 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    // `bad` is the known-bad endpoint and also a suspect.
449    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        // git's contract: 0=good, 125=skip, 1-127 (except 125)=bad,
461        // >=128 or signal-killed (None) = abort.
462        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        // Signal-killed children report no code.
471        assert!(matches!(classify(None), Verdict::Abort));
472    }
473}