Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/vedit-cli/src/cmd.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod bisect;
pub mod branch;
pub mod branches;
pub mod checkout;
Expand Down
120 changes: 120 additions & 0 deletions crates/vedit-cli/src/cmd/bisect.rs
Original file line number Diff line number Diff line change
@@ -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<Repo> {
let cwd = std::env::current_dir()?;
Repo::discover(&cwd)
}

fn run_predicate(predicate: &[String], candidate: &str) -> Result<BisectVerdict> {
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<BisectSession> {
let path = session_path(repo);
let bytes = std::fs::read(&path).with_context(|| {
format!(
"reading {}; run `vedit bisect start --good <ref> --bad <ref>` 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()
}
46 changes: 46 additions & 0 deletions crates/vedit-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use vedit_core::bisect::BisectVerdict;

mod author;
mod cmd;
Expand Down Expand Up @@ -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,
Expand All @@ -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<String>,
},
}

fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Expand All @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions crates/vedit-core/src/bisect.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub first_bad: Option<String>,
pub remaining: usize,
}

impl BisectSession {
pub fn start(repo: &Repo, good: &str, bad: &str) -> Result<Self> {
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<Self> {
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<BisectSession> {
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<Vec<String>> {
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();
}
}
1 change: 1 addition & 0 deletions crates/vedit-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod atomic;
pub mod bisect;
pub mod commit;
pub mod diff;
pub mod merge;
Expand Down
77 changes: 77 additions & 0 deletions crates/vedit-core/tests/bisect.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading