285 lines · 10.7 KB · Rust
RawDownload
// Copyright 2025 The Jujutsu Authors//// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License. use clap_complete::ArgValueCompleter;use jj_lib::bisect::BisectionResult;use jj_lib::bisect::Bisector;use jj_lib::bisect::Evaluation;use jj_lib::commit::Commit;use jj_lib::object_id::ObjectId as _;use tracing::instrument; use crate::cli_util::CommandHelper;use crate::cli_util::RevisionArg;use crate::cli_util::WorkspaceCommandHelper;use crate::cli_util::short_operation_hash;use crate::command_error::CommandError;use crate::command_error::cli_error;use crate::command_error::internal_error_with_message;use crate::command_error::user_error;use crate::command_error::user_error_with_message;use crate::complete;use crate::config::CommandNameAndArgs;use crate::ui::Ui; /// Run a given command to find the first bad revision////// Uses binary search to find the first "bad" revision. Revisions are evaluated/// by running the given command (see the documentation for `<COMMAND>` for/// details).////// It is assumed that if a given revision is "bad", then all its descendants/// in the input range are also "bad".////// The target of the bisection can be inverted to look for the first "good"/// revision by passing `--find-good`.////// Hint: You can pass your shell as the command, then run manual tests in a/// script. When you're done, make sure to exit the shell with an appropriate/// error code depending on the desired outcome (e.g., `exit 0` to mark the/// revision as "good").////// Example: To run `cargo test` with the changes from revision `xyz` applied:/// ```shell/// jj bisect run --range v1.0..main -- bash -c "jj duplicate xyz -B @ &&/// cargo test"/// ```#[derive(clap::Args, Clone, Debug)]#[command(verbatim_doc_comment)]pub(crate) struct BisectRunArgs {    /// Range of revisions to bisect (can be repeated)    ///    /// This is typically a range like `v1.0..main`. The heads of the range are    /// assumed to be bad. Ancestors of the range that are not also in the range    /// are assumed to be good.    ///    /// The union of all given ranges are used as the input for the bisection.    #[arg(long, short, value_name = "REVSETS", required = true)]    #[arg(add = ArgValueCompleter::new(complete::revset_expression_all))]    range: Vec<RevisionArg>,     /// Deprecated. Use positional arguments instead.    #[arg(        long = "command",        value_name = "COMMAND",        hide = true,        conflicts_with = "command"    )]    legacy_command: Option<CommandNameAndArgs>,     /// Command to run to determine the status of a revision    ///    /// Each revision being checked will be directly edited (will become the    /// current working copy) before running this command. The exit status of    /// the command will be used to mark revisions as "good" or "bad": status 0    /// means "good", 125 means to skip the revision, 127 (command not found)    /// will abort the bisection, and any other non-zero exit status means the    /// revision is "bad".    ///    /// The target's commit ID is available to the command in the    /// `$JJ_BISECT_TARGET` environment variable.    #[arg(value_name = "COMMAND")]    command: Option<String>,     /// Arguments to pass to the command    ///    /// Hint: Use a `--` separator to allow passing arguments starting with `-`.    /// For example: `jj bisect run --range=... -- test -f some-file`.    #[arg(value_name = "ARGS")]    args: Vec<String>,     /// Find the first good revision instead    ///    /// The interpretation of exit statuses will be inverted (excluding special    /// exit statuses), so status 0 means bad and other non-zero statuses mean    /// good.    #[arg(long, value_name = "TARGET", default_value_t = false)]    find_good: bool,} #[instrument(skip_all)]pub(crate) async fn cmd_bisect_run(    ui: &mut Ui,    command: &CommandHelper,    args: &BisectRunArgs,) -> Result<(), CommandError> {    let mut workspace_command = command.workspace_helper(ui).await?;     if let Some(command) = &args.legacy_command {        writeln!(            ui.warning_default(),            "`--command` is deprecated; use positional arguments instead: `jj bisect run \             --range=... -- {command}`"        )?;    } else if args.command.is_none() {        return Err(cli_error("Command argument is required"));    }     let input_range = workspace_command        .parse_union_revsets(ui, &args.range)?        .resolve()?;     let initial_repo = workspace_command.repo().clone();     let mut bisector = Bisector::new(initial_repo.as_ref(), input_range).await?;    let bisection_result = loop {        match bisector.next_step().await? {            jj_lib::bisect::NextStep::Evaluate(commit) => {                {                    let mut formatter = ui.stdout_formatter();                    let (lower, upper) = bisector.remaining_revset().await?.count_estimate()?;                    let lower_steps = ((lower + 1) as f64).log2().ceil() as usize;                    if upper == Some(lower) {                        writeln!(                            formatter,                            "Bisecting: {lower} revisions left to test after this (roughly \                             {lower_steps} steps)"                        )?;                    } else {                        writeln!(                            formatter,                            "Bisecting: at least {lower} revisions left to test after this (at \                             least roughly {lower_steps} steps)"                        )?;                    }                    // TODO: Show a graph of the current range instead?                    let commit_template = workspace_command.commit_summary_template();                    write!(formatter, "Now evaluating: ")?;                    commit_template.format(&commit, formatter.as_mut())?;                    writeln!(formatter)?;                }                 let cmd = get_command(args);                let evaluation = evaluate_commit(ui, &mut workspace_command, cmd, &commit).await?;                 {                    let mut formatter = ui.stdout_formatter();                    let message = match evaluation {                        Evaluation::Good => "The revision is good.",                        Evaluation::Bad => "The revision is bad.",                        Evaluation::Skip => {                            "It could not be determined if the revision is good or bad."                        }                        Evaluation::Abort => {                            "Evaluation command returned 127 (command not found) - aborting \                             bisection."                        }                    };                    writeln!(formatter, "{message}")?;                    writeln!(formatter)?;                }                 if args.find_good {                    // If we're looking for the first good revision,                    // invert the evaluation result.                    bisector.mark(commit.id().clone(), evaluation.invert());                } else {                    bisector.mark(commit.id().clone(), evaluation);                }                 // Reload the workspace because the evaluation command may run `jj` commands.                workspace_command = command.workspace_helper(ui).await?;            }            jj_lib::bisect::NextStep::Done(bisection_result) => {                break bisection_result;            }        }    };     let mut formatter = ui.stdout_formatter();    writeln!(        formatter,        "Search complete. To discard any revisions created during search, run:"    )?;    writeln!(        formatter,        "  jj op restore {}",        short_operation_hash(initial_repo.op_id())    )?;     let target = if args.find_good { "good" } else { "bad" };    match bisection_result {        BisectionResult::Abort => {            return Err(user_error("Bisection aborted"));        }        BisectionResult::Indeterminate => {            return Err(user_error(format!(                "Could not find the first {target} revision. Was the input range empty?"            )));        }        BisectionResult::Found(first_target_commits) => {            let commit_template = workspace_command.commit_summary_template();            if let [first_target_commit] = first_target_commits.as_slice() {                write!(formatter, "The first {target} revision is: ")?;                commit_template.format(first_target_commit, formatter.as_mut())?;                writeln!(formatter)?;            } else {                writeln!(formatter, "The first {target} revisions are:")?;                for first_target_commit in first_target_commits {                    commit_template.format(&first_target_commit, formatter.as_mut())?;                    writeln!(formatter)?;                }            }        }    }     Ok(())} fn get_command(args: &BisectRunArgs) -> std::process::Command {    if let Some(command) = &args.command {        let mut cmd = std::process::Command::new(command);        cmd.args(&args.args);        cmd    } else {        args.legacy_command.as_ref().unwrap().to_command()    }} async fn evaluate_commit(    ui: &mut Ui,    workspace_command: &mut WorkspaceCommandHelper,    mut cmd: std::process::Command,    commit: &Commit,) -> Result<Evaluation, CommandError> {    let mut tx = workspace_command.start_transaction();    let commit_id_hex = commit.id().hex();    tx.check_out(commit)?;    tx.finish(        ui,        format!("Updated to revision {commit_id_hex} for bisection"),    )    .await?;     let jj_executable_path = std::env::current_exe().map_err(|err| {        internal_error_with_message("Could not get path for the jj executable", err)    })?;    tracing::info!(?cmd, "running bisection evaluation command");    let status = cmd        .env("JJ_EXECUTABLE_PATH", jj_executable_path)        .env("JJ_BISECT_TARGET", &commit_id_hex)        .status()        .map_err(|err| user_error_with_message("Failed to run evaluation command", err))?;    let evaluation = if status.success() {        Evaluation::Good    } else {        match status.code() {            Some(125) => Evaluation::Skip,            Some(127) => Evaluation::Abort,            _ => Evaluation::Bad,        }    };     Ok(evaluation)}