use std::collections::BTreeSet;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use mkit_core::hash::Hash;
use mkit_core::layout::RepoLayout;
use mkit_core::ops::bisect::{
BisectState, BisectStep, cleanup_bisect, is_bisect_in_progress, next_step, read_state,
write_state,
};
use mkit_core::refs::{self, Head};
use mkit_core::store::ObjectStore;
use clap::{Parser, Subcommand};
use crate::clap_shim;
use crate::exit;
use crate::format;
#[derive(Debug, Parser)]
#[command(
name = "mkit bisect",
about = "Binary-search for a regression-introducing commit."
)]
struct BisectOpts {
#[command(subcommand)]
sub: BisectCmd,
}
#[derive(Debug, Subcommand)]
enum BisectCmd {
Start,
Good { commit: Option<String> },
Bad { commit: Option<String> },
Skip,
Reset,
Run {
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
argv: Vec<String>,
},
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<BisectOpts>("mkit bisect", args) {
Ok(o) => o,
Err(code) => return code,
};
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
let store = match ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
match opts.sub {
BisectCmd::Start => start(&layout),
BisectCmd::Good { commit } => mark(&store, &layout, commit.as_deref(), true),
BisectCmd::Bad { commit } => mark(&store, &layout, commit.as_deref(), false),
BisectCmd::Skip => skip(&store, &layout),
BisectCmd::Reset => reset(&layout),
BisectCmd::Run { argv } => run_automated(&store, &cwd, &layout, &argv),
}
}
fn start(layout: &RepoLayout) -> u8 {
if is_bisect_in_progress(layout) {
return emit_err(
"a bisect is already in progress (use `mkit bisect reset` first)",
exit::GENERAL_ERROR,
);
}
let orig_head = match refs::resolve_head(layout) {
Ok(Some(h)) => h,
Ok(None) => return emit_err("no commits yet", exit::GENERAL_ERROR),
Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
};
let orig_branch = match refs::read_head(layout) {
Ok(Head::Branch(name)) => Some(name),
_ => None,
};
let state = BisectState {
orig_head,
orig_branch,
bad_hash: None,
good_hashes: Vec::new(),
skipped: BTreeSet::default(),
};
if let Err(e) = write_state(layout, &state) {
return emit_err(&format!("write state: {e}"), exit::CANTCREAT);
}
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"bisect started; mark endpoints with `mkit bisect good <hash>` and `mkit bisect bad <hash>`"
);
exit::OK
}
fn mark(store: &ObjectStore, layout: &RepoLayout, arg: Option<&str>, good: bool) -> u8 {
if !is_bisect_in_progress(layout) {
return emit_err("no bisect in progress", exit::GENERAL_ERROR);
}
let mut state = match read_state(layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
};
let hash_: Hash = match arg {
Some(s) => match super::revspec::resolve_revision(store, layout, s) {
Ok(h) => h,
Err(e) => return emit_err(&format!("bad commit: {e}"), exit::DATAERR),
},
None => match refs::resolve_head(layout) {
Ok(Some(h)) => h,
_ => return emit_err("no HEAD; provide an explicit hash", exit::GENERAL_ERROR),
},
};
if good {
state.good_hashes.push(hash_);
} else {
state.bad_hash = Some(hash_);
}
if let Err(e) = write_state(layout, &state) {
return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
}
report_step(store, &state)
}
fn skip(store: &ObjectStore, layout: &RepoLayout) -> u8 {
if !is_bisect_in_progress(layout) {
return emit_err("no bisect in progress", exit::GENERAL_ERROR);
}
let mut state = match read_state(layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
};
let current_mid = match next_step(store, &state) {
Ok(BisectStep::Testing { hash, .. }) => hash,
Ok(_) => {
return emit_err("bisect skip: no current candidate to skip", exit::USAGE);
}
Err(e) => return emit_err(&format!("bisect skip: {e}"), exit::GENERAL_ERROR),
};
state.skipped.insert(current_mid);
if let Err(e) = write_state(layout, &state) {
return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
}
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"skipped {}; advancing to next candidate",
format::short_hash(¤t_mid, 12)
);
drop(stderr);
report_step(store, &state)
}
fn reset(layout: &RepoLayout) -> u8 {
if !is_bisect_in_progress(layout) {
return emit_err("no bisect in progress", exit::GENERAL_ERROR);
}
let state = match read_state(layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
};
if let Some(branch) = state.orig_branch.as_deref() {
let _ = refs::write_head_branch(layout, branch);
} else {
let _ = refs::write_head_detached(layout, &state.orig_head);
}
let _ = cleanup_bisect(layout);
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "bisect reset");
exit::OK
}
enum Verdict {
Good,
Bad,
Skip,
Abort,
}
fn classify(code: Option<i32>) -> Verdict {
match code {
Some(0) => Verdict::Good,
Some(125) => Verdict::Skip,
Some(c) if (1..=127).contains(&c) => Verdict::Bad,
_ => Verdict::Abort,
}
}
fn run_automated(store: &ObjectStore, cwd: &Path, layout: &RepoLayout, argv: &[String]) -> u8 {
if !is_bisect_in_progress(layout) {
return emit_err("no bisect in progress", exit::GENERAL_ERROR);
}
let Some((program, cmd_args)) = argv.split_first() else {
return emit_err("bisect run: missing command", exit::USAGE);
};
let mut state = match read_state(layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("read state: {e}"), exit::GENERAL_ERROR),
};
let mut guard = 0u32;
loop {
guard += 1;
if guard > 1_000_000 {
let _ = restore_head(cwd, &state);
return emit_err("bisect run: did not converge", exit::GENERAL_ERROR);
}
let hash = match next_step(store, &state) {
Ok(BisectStep::Testing { hash, remaining }) => {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"bisect run: testing {} ({remaining} candidates remaining)",
format::short_hash(&hash, 12)
);
hash
}
Ok(BisectStep::Found(h)) => {
if let Err(code) = restore_head(cwd, &state) {
return code;
}
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "bisect found first bad commit:");
drop(stderr);
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", format::short_hash(&h, 12));
return exit::OK;
}
Ok(BisectStep::Ambiguous { bad, skipped }) => {
let _ = restore_head(cwd, &state);
report_ambiguous(bad, &skipped);
return exit::GENERAL_ERROR;
}
Ok(BisectStep::NeedMore) => {
return emit_err(
"bisect run: need at least one good and one bad commit first",
exit::USAGE,
);
}
Err(e) => return emit_err(&format!("bisect run: {e}"), exit::GENERAL_ERROR),
};
if let Err(code) = checkout(cwd, &format::hex_hash(&hash)) {
let _ = restore_head(cwd, &state);
return code;
}
let status = Command::new(program)
.args(cmd_args)
.current_dir(cwd)
.env("MKIT_BISECT_COMMIT", format::hex_hash(&hash))
.status();
let code = match status {
Ok(s) => s.code(),
Err(e) => {
let _ = restore_head(cwd, &state);
return emit_err(
&format!("bisect run: failed to run `{program}`: {e}"),
exit::GENERAL_ERROR,
);
}
};
match classify(code) {
Verdict::Good => state.good_hashes.push(hash),
Verdict::Bad => state.bad_hash = Some(hash),
Verdict::Skip => {
state.skipped.insert(hash);
}
Verdict::Abort => {
let _ = restore_head(cwd, &state);
let shown = code.map_or_else(|| "signal".to_string(), |c| c.to_string());
return emit_err(
&format!("bisect run: command aborted (exit {shown})"),
exit::GENERAL_ERROR,
);
}
}
if let Err(e) = write_state(layout, &state) {
let _ = restore_head(cwd, &state);
return emit_err(&format!("persist state: {e}"), exit::CANTCREAT);
}
}
}
fn restore_head(cwd: &Path, state: &BisectState) -> Result<(), u8> {
let target = match state.orig_branch.as_deref() {
Some(branch) => branch.to_string(),
None => format::hex_hash(&state.orig_head),
};
checkout(cwd, &target)
}
fn checkout(cwd: &Path, target: &str) -> Result<(), u8> {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
return Err(emit_err(
&format!("cannot locate mkit binary: {e}"),
exit::GENERAL_ERROR,
));
}
};
let out = Command::new(exe)
.args(["checkout", "--force", target])
.current_dir(cwd)
.env("NO_COLOR", "1")
.output();
match out {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let mut stderr = std::io::stderr().lock();
let _ = stderr.write_all(&o.stderr);
Err(exit::GENERAL_ERROR)
}
Err(e) => Err(emit_err(
&format!("checkout {target}: {e}"),
exit::GENERAL_ERROR,
)),
}
}
fn report_step(store: &ObjectStore, state: &BisectState) -> u8 {
match next_step(store, state) {
Ok(BisectStep::NeedMore) => {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"need at least one good and a bad commit to start searching"
);
exit::OK
}
Ok(BisectStep::Testing { hash, remaining }) => {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "bisect: testing ({remaining} candidates remaining)");
drop(stderr);
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", format::short_hash(&hash, 12));
exit::OK
}
Ok(BisectStep::Found(h)) => {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "bisect found first bad commit:");
drop(stderr);
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", format::short_hash(&h, 12));
exit::OK
}
Ok(BisectStep::Ambiguous { bad, skipped }) => {
report_ambiguous(bad, &skipped);
exit::GENERAL_ERROR
}
Err(e) => emit_err(&format!("bisect: {e}"), exit::GENERAL_ERROR),
}
}
fn report_ambiguous(bad: Hash, skipped: &[Hash]) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"there are only skipped commits left to test; the first bad commit could be any of:"
);
drop(stderr);
let mut stdout = std::io::stdout().lock();
for h in skipped {
let _ = writeln!(stdout, "{}", format::short_hash(h, 12));
}
let _ = writeln!(stdout, "{}", format::short_hash(&bad, 12));
}
use super::error as emit_err;
#[cfg(test)]
mod tests {
use super::{Verdict, classify};
#[test]
fn classify_matches_git_bisect_run_contract() {
assert!(matches!(classify(Some(0)), Verdict::Good));
assert!(matches!(classify(Some(125)), Verdict::Skip));
assert!(matches!(classify(Some(1)), Verdict::Bad));
assert!(matches!(classify(Some(124)), Verdict::Bad));
assert!(matches!(classify(Some(126)), Verdict::Bad));
assert!(matches!(classify(Some(127)), Verdict::Bad));
assert!(matches!(classify(Some(128)), Verdict::Abort));
assert!(matches!(classify(Some(255)), Verdict::Abort));
assert!(matches!(classify(None), Verdict::Abort));
}
}