264 lines · 9.5 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 a 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 evaluation command. You can then run/// manual tests in the shell and make sure to exit the shell with appropriate/// error code depending on the outcome (e.g. `exit 0` to mark the revision as/// good in Bash or Fish).////// Example: To run `cargo test` with the changes from revision `xyz` applied:////// `jj bisect --range v1.0..main -- bash -c "jj duplicate -r xyz -B @ && cargo/// test"`#[derive(clap::Args, Clone, Debug)]pub(crate) struct BisectRunArgs {    /// Range of revisions to bisect    ///    /// 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.    #[arg(        long,        short,        value_name = "REVSETS",        required = true,        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 whether the bug is present    ///    /// 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>,     /// Whether to find the first good revision instead    ///    /// Inverts the interpretation of exit statuses (excluding special exit    /// statuses).    #[arg(long, value_name = "TARGET", default_value = "false")]    find_good: bool,} #[instrument(skip_all)]pub(crate) fn cmd_bisect_run(    ui: &mut Ui,    command: &CommandHelper,    args: &BisectRunArgs,) -> Result<(), CommandError> {    let mut workspace_command = command.workspace_helper(ui)?;     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)?;    let bisection_result = loop {        match bisector.next_step()? {            jj_lib::bisect::NextStep::Evaluate(commit) => {                {                    let mut formatter = ui.stdout_formatter();                    // TODO: Show a graph of the current range instead?                    // TODO: Say how many commits are left and estimate the number of iterations.                    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)?;                 {                    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."                        }                    };                    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)?;            }            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::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()    }} 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"),    )?;     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) => {                return Err(user_error(                    "Evaluation command returned 127 (command not found) - aborting bisection.",                ));            }            _ => Evaluation::Bad,        }    };     Ok(evaluation)}