Skip to main content

mkit_cli/commands/
bisect.rs

1//! `mkit bisect start|good|bad|reset|skip` — 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;
7
8use mkit_core::hash::Hash;
9use mkit_core::ops::bisect::{
10    BisectState, BisectStep, cleanup_bisect, is_bisect_in_progress, next_step, read_state,
11    write_state,
12};
13use mkit_core::refs::{self, Head};
14use mkit_core::store::ObjectStore;
15
16use clap::{Parser, Subcommand};
17
18use crate::clap_shim;
19use crate::exit;
20use crate::format;
21
22#[derive(Debug, Parser)]
23#[command(
24    name = "mkit bisect",
25    about = "Binary-search for a regression-introducing commit."
26)]
27struct BisectOpts {
28    #[command(subcommand)]
29    sub: BisectCmd,
30}
31
32#[derive(Debug, Subcommand)]
33enum BisectCmd {
34    /// Begin a bisect session at HEAD.
35    Start,
36    /// Mark a commit (or HEAD) as good.
37    Good { commit: Option<String> },
38    /// Mark a commit (or HEAD) as bad.
39    Bad { commit: Option<String> },
40    /// Skip the current candidate.
41    Skip,
42    /// End the session and restore the original HEAD.
43    Reset,
44}
45
46#[must_use]
47pub fn run(args: &[String]) -> u8 {
48    let opts = match clap_shim::parse::<BisectOpts>("mkit bisect", args) {
49        Ok(o) => o,
50        Err(code) => return code,
51    };
52    let cwd = match std::env::current_dir() {
53        Ok(p) => p,
54        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
55    };
56    let store = match ObjectStore::open(&cwd) {
57        Ok(s) => s,
58        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
59    };
60    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
61
62    match opts.sub {
63        BisectCmd::Start => start(&mkit_dir),
64        BisectCmd::Good { commit } => mark(&store, &mkit_dir, commit.as_deref(), true),
65        BisectCmd::Bad { commit } => mark(&store, &mkit_dir, commit.as_deref(), false),
66        BisectCmd::Skip => skip(&store, &mkit_dir),
67        BisectCmd::Reset => reset(&mkit_dir),
68    }
69}
70
71fn start(mkit_dir: &std::path::Path) -> u8 {
72    if is_bisect_in_progress(mkit_dir) {
73        return emit_err(
74            "a bisect is already in progress (use `mkit bisect reset` first)",
75            exit::GENERAL_ERROR,
76        );
77    }
78    let orig_head = match refs::resolve_head(mkit_dir) {
79        Ok(Some(h)) => h,
80        Ok(None) => return emit_err("no commits yet", exit::GENERAL_ERROR),
81        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
82    };
83    let orig_branch = match refs::read_head(mkit_dir) {
84        Ok(Head::Branch(name)) => Some(name),
85        _ => None,
86    };
87    let state = BisectState {
88        orig_head,
89        orig_branch,
90        bad_hash: None,
91        good_hashes: Vec::new(),
92        skipped: BTreeSet::default(),
93    };
94    if let Err(e) = write_state(mkit_dir, &state) {
95        return emit_err(&format!("write state: {e}"), exit::CANTCREAT);
96    }
97    let mut stderr = std::io::stderr().lock();
98    let _ = writeln!(
99        stderr,
100        "bisect started; mark endpoints with `mkit bisect good <hash>` and `mkit bisect bad <hash>`"
101    );
102    exit::OK
103}
104
105fn mark(store: &ObjectStore, mkit_dir: &std::path::Path, arg: Option<&str>, good: bool) -> u8 {
106    if !is_bisect_in_progress(mkit_dir) {
107        return emit_err("no bisect in progress", exit::GENERAL_ERROR);
108    }
109    let mut state = match read_state(mkit_dir) {
110        Ok(s) => s,
111        Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
112    };
113    let hash_: Hash = match arg {
114        Some(s) => match super::revspec::resolve_revision(store, mkit_dir, s) {
115            Ok(h) => h,
116            Err(e) => return emit_err(&format!("bad commit: {e}"), exit::DATAERR),
117        },
118        None => match refs::resolve_head(mkit_dir) {
119            Ok(Some(h)) => h,
120            _ => return emit_err("no HEAD; provide an explicit hash", exit::GENERAL_ERROR),
121        },
122    };
123    if good {
124        state.good_hashes.push(hash_);
125    } else {
126        state.bad_hash = Some(hash_);
127    }
128    if let Err(e) = write_state(mkit_dir, &state) {
129        return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
130    }
131    report_step(store, &state)
132}
133
134fn skip(store: &ObjectStore, mkit_dir: &std::path::Path) -> u8 {
135    if !is_bisect_in_progress(mkit_dir) {
136        return emit_err("no bisect in progress", exit::GENERAL_ERROR);
137    }
138    let mut state = match read_state(mkit_dir) {
139        Ok(s) => s,
140        Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
141    };
142    // Determine the current midpoint to skip.
143    let current_mid = match next_step(store, &state) {
144        Ok(BisectStep::Testing { hash, .. }) => hash,
145        Ok(_) => {
146            // Nothing to skip: either already found or not enough data.
147            // User error (skip invoked when bisect has no current
148            // candidate); USAGE rather than OK so scripts see the
149            // failure.
150            return emit_err("bisect skip: no current candidate to skip", exit::USAGE);
151        }
152        Err(e) => return emit_err(&format!("bisect skip: {e}"), exit::GENERAL_ERROR),
153    };
154    // Add the current midpoint to the exclusion set, then advance.
155    state.skipped.insert(current_mid);
156    if let Err(e) = write_state(mkit_dir, &state) {
157        return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
158    }
159    let mut stderr = std::io::stderr().lock();
160    let _ = writeln!(
161        stderr,
162        "skipped {}; advancing to next candidate",
163        format::short_hash(&current_mid, 12)
164    );
165    drop(stderr);
166    report_step(store, &state)
167}
168
169fn reset(mkit_dir: &std::path::Path) -> u8 {
170    if !is_bisect_in_progress(mkit_dir) {
171        return emit_err("no bisect in progress", exit::GENERAL_ERROR);
172    }
173    let state = match read_state(mkit_dir) {
174        Ok(s) => s,
175        Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
176    };
177    if let Some(branch) = state.orig_branch.as_deref() {
178        let _ = refs::write_head_branch(mkit_dir, branch);
179    } else {
180        let _ = refs::write_head_detached(mkit_dir, &state.orig_head);
181    }
182    let _ = cleanup_bisect(mkit_dir);
183    let mut stderr = std::io::stderr().lock();
184    let _ = writeln!(stderr, "bisect reset");
185    exit::OK
186}
187
188fn report_step(store: &ObjectStore, state: &BisectState) -> u8 {
189    match next_step(store, state) {
190        Ok(BisectStep::NeedMore) => {
191            let mut stderr = std::io::stderr().lock();
192            let _ = writeln!(
193                stderr,
194                "need at least one good and a bad commit to start searching"
195            );
196            exit::OK
197        }
198        Ok(BisectStep::Testing { hash, remaining }) => {
199            // Progress prose to stderr; the candidate hash itself to
200            // stdout so `H=$(mkit bisect good)` keeps working.
201            let mut stderr = std::io::stderr().lock();
202            let _ = writeln!(stderr, "bisect: testing ({remaining} candidates remaining)");
203            drop(stderr);
204            let mut stdout = std::io::stdout().lock();
205            let _ = writeln!(stdout, "{}", format::short_hash(&hash, 12));
206            exit::OK
207        }
208        Ok(BisectStep::Found(h)) => {
209            // The "found" result is genuinely a data point — emit the
210            // hash on stdout and the prose on stderr.
211            let mut stderr = std::io::stderr().lock();
212            let _ = writeln!(stderr, "bisect found first bad commit:");
213            drop(stderr);
214            let mut stdout = std::io::stdout().lock();
215            let _ = writeln!(stdout, "{}", format::short_hash(&h, 12));
216            exit::OK
217        }
218        Err(e) => emit_err(&format!("bisect: {e}"), exit::GENERAL_ERROR),
219    }
220}
221
222fn emit_err(msg: &str, code: u8) -> u8 {
223    let mut stderr = std::io::stderr().lock();
224    let _ = writeln!(stderr, "error: {msg}");
225    code
226}