From 4d1b20f61ed173d4372285911faa86d51b4b5c11 Mon Sep 17 00:00:00 2001 From: Tadiwa Mbuwayesango Date: Thu, 21 May 2026 12:48:59 -0500 Subject: [PATCH] Add vedit bisect command --- crates/vedit-cli/src/cmd.rs | 1 + crates/vedit-cli/src/cmd/bisect.rs | 120 +++++++++++++++++++++++++++++ crates/vedit-cli/src/main.rs | 46 +++++++++++ crates/vedit-core/src/bisect.rs | 87 +++++++++++++++++++++ crates/vedit-core/src/lib.rs | 1 + crates/vedit-core/tests/bisect.rs | 77 ++++++++++++++++++ 6 files changed, 332 insertions(+) create mode 100644 crates/vedit-cli/src/cmd/bisect.rs create mode 100644 crates/vedit-core/src/bisect.rs create mode 100644 crates/vedit-core/tests/bisect.rs diff --git a/crates/vedit-cli/src/cmd.rs b/crates/vedit-cli/src/cmd.rs index 1d9a159..3fe4bd9 100644 --- a/crates/vedit-cli/src/cmd.rs +++ b/crates/vedit-cli/src/cmd.rs @@ -1,3 +1,4 @@ +pub mod bisect; pub mod branch; pub mod branches; pub mod checkout; diff --git a/crates/vedit-cli/src/cmd/bisect.rs b/crates/vedit-cli/src/cmd/bisect.rs new file mode 100644 index 0000000..e523bd4 --- /dev/null +++ b/crates/vedit-cli/src/cmd/bisect.rs @@ -0,0 +1,120 @@ +use anyhow::{Context, Result, bail}; +use std::path::PathBuf; +use std::process::Command; +use vedit_core::bisect::{BisectSession, BisectVerdict}; +use vedit_core::object; +use vedit_core::repo::Repo; + +pub fn start(good: &str, bad: &str) -> Result<()> { + let repo = discover_repo()?; + let session = BisectSession::start(&repo, good, bad)?; + write_session(&repo, &session)?; + print_session(&session); + Ok(()) +} + +pub fn mark(verdict: BisectVerdict) -> Result<()> { + let repo = discover_repo()?; + let session = read_session(&repo)?; + let session = session.record(&repo, verdict)?; + if session.first_bad.is_some() { + remove_session(&repo)?; + } else { + write_session(&repo, &session)?; + } + print_session(&session); + Ok(()) +} + +pub fn reset() -> Result<()> { + let repo = discover_repo()?; + remove_session(&repo)?; + println!("Cleared bisect state."); + Ok(()) +} + +pub fn run(good: &str, bad: &str, predicate: &[String]) -> Result<()> { + if predicate.is_empty() { + bail!("predicate command is required"); + } + let repo = discover_repo()?; + let mut session = BisectSession::start(&repo, good, bad)?; + + while let Some(candidate) = session.current.clone() { + let verdict = run_predicate(predicate, &candidate)?; + println!("{} is {:?}", short(&candidate), verdict); + session = session.record(&repo, verdict)?; + } + + print_session(&session); + Ok(()) +} + +fn discover_repo() -> Result { + let cwd = std::env::current_dir()?; + Repo::discover(&cwd) +} + +fn run_predicate(predicate: &[String], candidate: &str) -> Result { + let mut command = Command::new(&predicate[0]); + command.args(&predicate[1..]); + command.env("VEDIT_BISECT_COMMIT", candidate); + let status = command + .status() + .with_context(|| format!("running predicate command {:?}", predicate))?; + if status.success() { + Ok(BisectVerdict::Good) + } else { + Ok(BisectVerdict::Bad) + } +} + +fn read_session(repo: &Repo) -> Result { + let path = session_path(repo); + let bytes = std::fs::read(&path).with_context(|| { + format!( + "reading {}; run `vedit bisect start --good --bad ` first", + path.display() + ) + })?; + serde_json::from_slice(&bytes).with_context(|| format!("parsing {}", path.display())) +} + +fn write_session(repo: &Repo, session: &BisectSession) -> Result<()> { + let path = session_path(repo); + let bytes = serde_json::to_vec_pretty(session)?; + std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display())) +} + +fn remove_session(repo: &Repo) -> Result<()> { + let path = session_path(repo); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).with_context(|| format!("removing {}", path.display())), + } +} + +fn session_path(repo: &Repo) -> PathBuf { + repo.root.join("BISECT") +} + +fn print_session(session: &BisectSession) { + if let Some(first_bad) = &session.first_bad { + println!("First bad commit: {}", short(first_bad)); + return; + } + if let Some(current) = &session.current { + println!("Bisecting: test {}", short(current)); + println!("Then run `vedit bisect good` or `vedit bisect bad`."); + println!( + "Remaining candidate commits after this: {}", + session.remaining + ); + } +} + +fn short(hash: &str) -> String { + let body = hash.strip_prefix(object::HASH_PREFIX).unwrap_or(hash); + body.chars().take(7).collect() +} diff --git a/crates/vedit-cli/src/main.rs b/crates/vedit-cli/src/main.rs index 3774be1..207b126 100644 --- a/crates/vedit-cli/src/main.rs +++ b/crates/vedit-cli/src/main.rs @@ -1,6 +1,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use std::path::PathBuf; +use vedit_core::bisect::BisectVerdict; mod author; mod cmd; @@ -92,6 +93,11 @@ enum Cmd { #[arg(long)] dry_run: bool, }, + /// Binary-search history to find the first bad timeline commit. + Bisect { + #[command(subcommand)] + cmd: BisectCmd, + }, /// Watch an OTIO file and auto-commit on change. /// /// Polls the file's mtime + size, debounces with a settling window, @@ -117,6 +123,35 @@ enum Cmd { }, } +#[derive(Subcommand)] +enum BisectCmd { + /// Start an interactive bisect between a known good and known bad ref. + Start { + #[arg(long)] + good: String, + #[arg(long)] + bad: String, + }, + /// Mark the current candidate as good and print the next candidate. + Good, + /// Mark the current candidate as bad and print the next candidate. + Bad, + /// Clear saved bisect state. + Reset, + /// Run a predicate command until the first bad commit is found. + /// + /// The command gets VEDIT_BISECT_COMMIT set to the candidate hash. + /// Exit 0 means good; any non-zero exit status means bad. + Run { + #[arg(long)] + good: String, + #[arg(long)] + bad: String, + #[arg(trailing_var_arg = true, required = true)] + predicate: Vec, + }, +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { @@ -137,6 +172,17 @@ fn main() -> Result<()> { message, dry_run, } => cmd::merge::run(&target, cmd::merge::MergeOptions { message, dry_run }), + Cmd::Bisect { cmd: subcmd } => match subcmd { + BisectCmd::Start { good, bad } => cmd::bisect::start(&good, &bad), + BisectCmd::Good => cmd::bisect::mark(BisectVerdict::Good), + BisectCmd::Bad => cmd::bisect::mark(BisectVerdict::Bad), + BisectCmd::Reset => cmd::bisect::reset(), + BisectCmd::Run { + good, + bad, + predicate, + } => cmd::bisect::run(&good, &bad, &predicate), + }, Cmd::Watch { timeline, interval, diff --git a/crates/vedit-core/src/bisect.rs b/crates/vedit-core/src/bisect.rs new file mode 100644 index 0000000..a7c11b7 --- /dev/null +++ b/crates/vedit-core/src/bisect.rs @@ -0,0 +1,87 @@ +use crate::repo::Repo; +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BisectVerdict { + Good, + Bad, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BisectSession { + pub good: String, + pub bad: String, + pub current: Option, + pub first_bad: Option, + pub remaining: usize, +} + +impl BisectSession { + pub fn start(repo: &Repo, good: &str, bad: &str) -> Result { + let good = repo.resolve(good)?; + let bad = repo.resolve(bad)?; + session_for_bounds(repo, good, bad) + } + + pub fn record(self, repo: &Repo, verdict: BisectVerdict) -> Result { + let Some(current) = self.current else { + bail!("bisect is already complete"); + }; + + let (good, bad) = match verdict { + BisectVerdict::Good => (current, self.bad), + BisectVerdict::Bad => (self.good, current), + }; + session_for_bounds(repo, good, bad) + } +} + +fn session_for_bounds(repo: &Repo, good: String, bad: String) -> Result { + if good == bad { + bail!("good and bad refs resolve to the same commit"); + } + + let path = first_parent_path(repo, &bad, &good)?; + if path.len() < 2 { + bail!("{good} is not an ancestor of {bad}"); + } + if path.last() != Some(&good) { + bail!("{good} is not an ancestor of {bad}"); + } + + if path.len() == 2 { + return Ok(BisectSession { + good, + bad: bad.clone(), + current: None, + first_bad: Some(bad), + remaining: 0, + }); + } + + let candidate_index = path.len() / 2; + Ok(BisectSession { + good, + bad, + current: Some(path[candidate_index].clone()), + first_bad: None, + remaining: path.len().saturating_sub(3), + }) +} + +fn first_parent_path(repo: &Repo, bad: &str, good: &str) -> Result> { + let mut out = Vec::new(); + let mut cursor = bad.to_string(); + loop { + out.push(cursor.clone()); + if cursor == good { + return Ok(out); + } + let commit = repo.read_commit(&cursor)?; + let Some(parent) = commit.parents.first() else { + return Ok(out); + }; + cursor = parent.clone(); + } +} diff --git a/crates/vedit-core/src/lib.rs b/crates/vedit-core/src/lib.rs index 5609dd4..a615419 100644 --- a/crates/vedit-core/src/lib.rs +++ b/crates/vedit-core/src/lib.rs @@ -1,4 +1,5 @@ mod atomic; +pub mod bisect; pub mod commit; pub mod diff; pub mod merge; diff --git a/crates/vedit-core/tests/bisect.rs b/crates/vedit-core/tests/bisect.rs new file mode 100644 index 0000000..94177c2 --- /dev/null +++ b/crates/vedit-core/tests/bisect.rs @@ -0,0 +1,77 @@ +use serde_json::json; +use tempfile::tempdir; +use vedit_core::bisect::{BisectSession, BisectVerdict}; +use vedit_core::commit::Author; +use vedit_core::repo::Repo; + +fn author() -> Author { + Author { + name: "tester".to_string(), + email: "test@example.com".to_string(), + } +} + +fn commit_named(repo: &Repo, name: &str) -> String { + let timeline = json!({ "OTIO_SCHEMA": "Timeline.1", "name": name }); + let timeline_hash = repo.write_timeline(&timeline).unwrap(); + repo.commit(&timeline_hash, author(), name).unwrap() +} + +#[test] +fn bisect_start_selects_middle_candidate() { + let dir = tempdir().unwrap(); + let repo = Repo::init(dir.path()).unwrap(); + let good = commit_named(&repo, "c1"); + let _c2 = commit_named(&repo, "c2"); + let expected_middle = commit_named(&repo, "c3"); + let _c4 = commit_named(&repo, "c4"); + let bad = commit_named(&repo, "c5"); + + let session = BisectSession::start(&repo, &good, &bad).unwrap(); + + assert_eq!(session.good, good); + assert_eq!(session.bad, bad); + assert_eq!(session.current.as_deref(), Some(expected_middle.as_str())); + assert_eq!(session.remaining, 2); +} + +#[test] +fn bisect_converges_on_first_bad_commit() { + let dir = tempdir().unwrap(); + let repo = Repo::init(dir.path()).unwrap(); + let good = commit_named(&repo, "c1"); + let _c2 = commit_named(&repo, "c2"); + let _c3 = commit_named(&repo, "c3"); + let first_bad = commit_named(&repo, "c4"); + let bad = commit_named(&repo, "c5"); + + let session = BisectSession::start(&repo, &good, &bad).unwrap(); + let session = session + .record(&repo, BisectVerdict::Good) + .expect("c3 is good"); + assert_eq!(session.current.as_deref(), Some(first_bad.as_str())); + + let session = session + .record(&repo, BisectVerdict::Bad) + .expect("c4 is bad"); + assert_eq!(session.current, None); + assert_eq!(session.first_bad.as_deref(), Some(first_bad.as_str())); +} + +#[test] +fn bisect_rejects_good_ref_that_is_not_an_ancestor_of_bad_ref() { + let dir = tempdir().unwrap(); + let repo = Repo::init(dir.path()).unwrap(); + let base = commit_named(&repo, "base"); + repo.create_branch("alt", "HEAD").unwrap(); + let bad = commit_named(&repo, "main-bad"); + repo.switch_branch("alt").unwrap(); + let unrelated_tip = commit_named(&repo, "alt-good"); + + let err = BisectSession::start(&repo, &unrelated_tip, &bad).unwrap_err(); + assert!( + err.to_string().contains("is not an ancestor"), + "unexpected error: {err:#}" + ); + assert!(BisectSession::start(&repo, &base, &bad).is_ok()); +}