591 lines · 21.9 KB · Rust
RawDownload
use std::collections::HashMap;use std::io;use std::io::Write;use std::path::Path;use std::process::Command;use std::process::ExitStatus;use std::process::Stdio;use std::sync::Arc; use bstr::BString;use itertools::Itertools as _;use jj_lib::backend::CopyId;use jj_lib::backend::TreeValue;use jj_lib::conflicts;use jj_lib::conflicts::ConflictMarkerStyle;use jj_lib::conflicts::ConflictMaterializeOptions;use jj_lib::conflicts::MIN_CONFLICT_MARKER_LEN;use jj_lib::conflicts::choose_materialized_conflict_marker_len;use jj_lib::conflicts::materialize_merge_result_to_bytes;use jj_lib::gitignore::GitIgnoreFile;use jj_lib::matchers::Matcher;use jj_lib::merge::Diff;use jj_lib::merge::Merge;use jj_lib::merged_tree::MergedTree;use jj_lib::merged_tree_builder::MergedTreeBuilder;use jj_lib::repo_path::RepoPathUiConverter;use jj_lib::store::Store;use thiserror::Error; use super::ConflictResolveError;use super::DiffEditError;use super::DiffGenerateError;use super::MergeToolFile;use super::MergeToolPartialResolutionError;use super::diff_working_copies::DiffEditWorkingCopies;use super::diff_working_copies::DiffType;use super::diff_working_copies::check_out_trees;use super::diff_working_copies::new_utf8_temp_dir;use super::diff_working_copies::set_readonly_recursively;use crate::config::CommandNameAndArgs;use crate::config::find_all_variables;use crate::config::interpolate_variables;use crate::ui::Ui; /// Merge/diff tool loaded from the settings.#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]#[serde(default, rename_all = "kebab-case")]pub struct ExternalMergeTool {    /// Program to execute. Must be defined; defaults to the tool name    /// if not specified in the config.    pub program: String,    /// Arguments to pass to the program when generating diffs.    /// `$left` and `$right` are replaced with the corresponding directories.    pub diff_args: Vec<String>,    /// Exit codes to be treated as success when generating diffs.    pub diff_expected_exit_codes: Vec<i32>,    /// Whether to execute the tool with a pair of directories or individual    /// files when generating diffs.    pub diff_invocation_mode: DiffToolMode,    /// Whether to execute the tool in the temporary diff directory    pub diff_do_chdir: bool,    /// Arguments to pass to the program when editing diffs.    /// `$left` and `$right` are replaced with the corresponding directories.    pub edit_args: Vec<String>,    /// Whether to execute the tool with a pair of directories or individual    /// files when editing diffs (e.g. `jj diffedit`, `jj split`).    pub edit_invocation_mode: DiffToolMode,    /// Arguments to pass to the program when resolving 3-way conflicts.    /// `$left`, `$right`, `$base`, and `$output` are replaced with    /// paths to the corresponding files.    pub merge_args: Vec<String>,    /// By default, if a merge tool exits with a non-zero exit code, then the    /// merge will be canceled. Some merge tools allow leaving some conflicts    /// unresolved, in which case they will be left as conflict markers in the    /// output file. In that case, the merge tool may exit with a non-zero exit    /// code to indicate that not all conflicts were resolved. Adding an exit    /// code to this array will tell `jj` to interpret that exit code as    /// indicating that the `$output` file should contain conflict markers.    pub merge_conflict_exit_codes: Vec<i32>,    /// If false (default), the `$output` file starts out empty and is accepted    /// as a full conflict resolution as-is by `jj` after the merge tool is    /// done with it. If true, the `$output` file starts out with the    /// contents of the conflict, with the configured conflict markers. After    /// the merge tool is done, any remaining conflict markers in the    /// file are parsed and taken to mean that the conflict was only partially    /// resolved.    pub merge_tool_edits_conflict_markers: bool,    /// If provided, overrides the normal conflict marker style setting. This is    /// useful if a tool parses conflict markers, and so it requires a specific    /// format, or if a certain format is more readable than another.    pub conflict_marker_style: Option<ConflictMarkerStyle>,} #[derive(serde::Deserialize, Copy, Clone, Debug, Eq, PartialEq)]#[serde(rename_all = "kebab-case")]pub enum DiffToolMode {    /// Invoke the diff tool on a temp directory of the modified files.    Dir,    /// Invoke the diff tool on each of the modified files individually.    FileByFile,} impl Default for ExternalMergeTool {    fn default() -> Self {        Self {            program: String::new(),            // TODO(ilyagr): There should be a way to explicitly specify that a            // certain tool (e.g. vscode as of this writing) cannot be used as a            // diff editor (or a diff tool). A possible TOML syntax would be            // `edit-args = false`, or `edit-args = []`, or `edit = { disabled =            // true }` to go with `edit = { args = [...] }`.            diff_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),            diff_expected_exit_codes: vec![0],            edit_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),            edit_invocation_mode: DiffToolMode::Dir,            merge_args: vec![],            merge_conflict_exit_codes: vec![],            merge_tool_edits_conflict_markers: false,            conflict_marker_style: None,            diff_do_chdir: true,            diff_invocation_mode: DiffToolMode::Dir,        }    }} impl ExternalMergeTool {    pub fn with_program(program: impl Into<String>) -> Self {        Self {            program: program.into(),            ..Default::default()        }    }     pub fn with_diff_args(command_args: &CommandNameAndArgs) -> Self {        Self::with_args_inner(command_args, |tool| &mut tool.diff_args)    }     pub fn with_edit_args(command_args: &CommandNameAndArgs) -> Self {        Self::with_args_inner(command_args, |tool| &mut tool.edit_args)    }     pub fn with_merge_args(command_args: &CommandNameAndArgs) -> Self {        Self::with_args_inner(command_args, |tool| &mut tool.merge_args)    }     fn with_args_inner(        command_args: &CommandNameAndArgs,        get_mut_args: impl FnOnce(&mut Self) -> &mut Vec<String>,    ) -> Self {        let (name, args) = command_args.split_name_and_args();        let mut tool = Self {            program: name.into_owned(),            ..Default::default()        };        if !args.is_empty() {            *get_mut_args(&mut tool) = args.to_vec();        }        tool    }} #[derive(Debug, Error)]pub enum ExternalToolError {    #[error("Error setting up temporary directory")]    SetUpDir(#[source] std::io::Error),    // TODO: Remove the "(run with --debug to see the exact invocation)"    // from this and other errors. Print it as a hint but only if --debug is *not* set.    #[error("Error executing '{tool_binary}' (run with --debug to see the exact invocation)")]    FailedToExecute {        tool_binary: String,        #[source]        source: std::io::Error,    },    #[error("Tool exited with {exit_status} (run with --debug to see the exact invocation)")]    ToolAborted { exit_status: ExitStatus },    #[error(        "Tool exited with {exit_status}, but did not produce valid conflict markers (run with \         --debug to see the exact invocation)"    )]    InvalidConflictMarkers { exit_status: ExitStatus },    #[error("I/O error")]    Io(#[source] std::io::Error),} async fn run_mergetool_external_single_file(    editor: &ExternalMergeTool,    store: &Store,    merge_tool_file: &MergeToolFile,    default_conflict_marker_style: ConflictMarkerStyle,    tree_builder: &mut MergedTreeBuilder,) -> Result<(), ConflictResolveError> {    let MergeToolFile {        repo_path,        conflict,        file,    } = merge_tool_file;     let uses_marker_length = find_all_variables(&editor.merge_args).contains(&"marker_length");     // If the merge tool doesn't get conflict markers pre-populated in the output    // file and doesn't accept "$marker_length", then we should default to accepting    // MIN_CONFLICT_MARKER_LEN since the merge tool can't know about our rules for    // conflict marker length.    let conflict_marker_len = if editor.merge_tool_edits_conflict_markers || uses_marker_length {        choose_materialized_conflict_marker_len(&file.contents)    } else {        MIN_CONFLICT_MARKER_LEN    };    let initial_output_content = if editor.merge_tool_edits_conflict_markers {        let options = ConflictMaterializeOptions {            marker_style: editor                .conflict_marker_style                .unwrap_or(default_conflict_marker_style),            marker_len: Some(conflict_marker_len),            merge: store.merge_options().clone(),        };        materialize_merge_result_to_bytes(&file.contents, &file.labels, &options)    } else {        BString::default()    };    assert_eq!(file.contents.num_sides(), 2);    let files: HashMap<&str, &[u8]> = maplit::hashmap! {        "base" => file.contents.get_remove(0).unwrap().as_slice(),        "left" => file.contents.get_add(0).unwrap().as_slice(),        "right" => file.contents.get_add(1).unwrap().as_slice(),        "output" => initial_output_content.as_slice(),    };     let temp_dir = new_utf8_temp_dir("jj-resolve-").map_err(ExternalToolError::SetUpDir)?;    let suffix = if let Some(filename) = repo_path.components().next_back() {        let name = filename            .to_fs_name()            .map_err(|err| err.with_path(repo_path))?;        format!("_{name}")    } else {        // This should never actually trigger, but we support it just in case        // resolving the root path ever makes sense.        "".to_owned()    };    let mut variables: HashMap<&str, _> = files        .iter()        .map(|(role, contents)| -> Result<_, ConflictResolveError> {            let path = temp_dir.path().join(format!("{role}{suffix}"));            std::fs::write(&path, contents).map_err(ExternalToolError::SetUpDir)?;            if *role != "output" {                // TODO: Should actually ignore the error here, or have a warning.                set_readonly_recursively(&path).map_err(ExternalToolError::SetUpDir)?;            }            Ok((                *role,                path.into_os_string()                    .into_string()                    .expect("temp_dir should be valid utf-8"),            ))        })        .try_collect()?;    variables.insert("marker_length", conflict_marker_len.to_string());    variables.insert("path", repo_path.as_internal_file_string().to_string());     let mut cmd = Command::new(&editor.program);    cmd.args(interpolate_variables(&editor.merge_args, &variables));    tracing::info!(?cmd, "Invoking the external merge tool:");    let exit_status = cmd        .status()        .map_err(|e| ExternalToolError::FailedToExecute {            tool_binary: editor.program.clone(),            source: e,        })?;    tracing::info!(%exit_status);     // Check whether the exit status implies that there should be conflict markers    let exit_status_implies_conflict = exit_status        .code()        .is_some_and(|code| editor.merge_conflict_exit_codes.contains(&code));     if !exit_status.success() && !exit_status_implies_conflict {        return Err(ConflictResolveError::from(ExternalToolError::ToolAborted {            exit_status,        }));    }     let output_file_contents: Vec<u8> =        std::fs::read(variables.get("output").unwrap()).map_err(ExternalToolError::Io)?;    if output_file_contents.is_empty() || output_file_contents == initial_output_content {        return Err(ConflictResolveError::EmptyOrUnchanged);    }     let new_file_ids = if editor.merge_tool_edits_conflict_markers || exit_status_implies_conflict {        tracing::info!(            ?exit_status_implies_conflict,            "jj is reparsing output for conflicts, `merge-tool-edits-conflict-markers = {}` in \             TOML config;",            editor.merge_tool_edits_conflict_markers        );        conflicts::update_from_content(            &file.unsimplified_ids,            store,            repo_path,            output_file_contents.as_slice(),            conflict_marker_len,        )        .await?    } else {        let new_file_id = store            .write_file(repo_path, &mut output_file_contents.as_slice())            .await?;        Merge::normal(new_file_id)    };     // If the exit status indicated there should be conflict markers but there    // weren't any, it's likely that the tool generated invalid conflict markers, so    // we need to inform the user. If we didn't treat this as an error, the user    // might think the conflict was resolved successfully.    if exit_status_implies_conflict && new_file_ids.is_resolved() {        return Err(ConflictResolveError::ExternalTool(            ExternalToolError::InvalidConflictMarkers { exit_status },        ));    }     let new_tree_value = match new_file_ids.into_resolved() {        Ok(file_id) => {            let executable = file.executable.expect("should have been resolved");            Merge::resolved(file_id.map(|id| TreeValue::File {                id,                executable,                copy_id: CopyId::placeholder(),            }))        }        // Update the file ids only, leaving the executable flags unchanged        Err(file_ids) => conflict.with_new_file_ids(&file_ids),    };    tree_builder.set_or_remove(repo_path.to_owned(), new_tree_value);    Ok(())} pub async fn run_mergetool_external(    ui: &Ui,    path_converter: &RepoPathUiConverter,    editor: &ExternalMergeTool,    tree: &MergedTree,    merge_tool_files: &[MergeToolFile],    default_conflict_marker_style: ConflictMarkerStyle,) -> Result<(MergedTree, Option<MergeToolPartialResolutionError>), ConflictResolveError> {    // TODO: add support for "dir" invocation mode, similar to the    // "diff-invocation-mode" config option for diffs    let mut tree_builder = MergedTreeBuilder::new(tree.clone());    let mut partial_resolution_error = None;    for (i, merge_tool_file) in merge_tool_files.iter().enumerate() {        writeln!(            ui.status(),            "Resolving conflicts in: {}",            path_converter.format_file_path(&merge_tool_file.repo_path)        )?;        match run_mergetool_external_single_file(            editor,            tree.store(),            merge_tool_file,            default_conflict_marker_style,            &mut tree_builder,        )        .await        {            Ok(()) => {}            Err(err) if i == 0 => {                // If the first resolution fails, just return the error normally                return Err(err);            }            Err(err) => {                // Some conflicts were already resolved, so we should return an error with the                // partially-resolved tree so that the caller can save the resolved files.                partial_resolution_error = Some(MergeToolPartialResolutionError {                    source: err,                    resolved_count: i,                });                break;            }        }    }    let new_tree = tree_builder.write_tree().await?;    Ok((new_tree, partial_resolution_error))} pub async fn edit_diff_external(    editor: &ExternalMergeTool,    trees: Diff<&MergedTree>,    matcher: &dyn Matcher,    instructions: Option<&str>,    base_ignores: Arc<GitIgnoreFile>,    default_conflict_marker_style: ConflictMarkerStyle,) -> Result<MergedTree, DiffEditError> {    let conflict_marker_style = editor        .conflict_marker_style        .unwrap_or(default_conflict_marker_style);     let got_output_field = find_all_variables(&editor.edit_args).contains(&"output");    let diff_type = if got_output_field {        DiffType::ThreeWay    } else {        DiffType::TwoWay    };    let diffedit_wc = DiffEditWorkingCopies::check_out(        trees,        matcher,        diff_type,        instructions,        conflict_marker_style,    )    .await?;     let invoke = |patterns: &HashMap<&str, String>| -> Result<(), DiffEditError> {        let mut cmd = Command::new(&editor.program);        cmd.args(interpolate_variables(&editor.edit_args, patterns));        tracing::info!(?cmd, "Invoking the external diff editor:");        let exit_status = cmd            .status()            .map_err(|e| ExternalToolError::FailedToExecute {                tool_binary: editor.program.clone(),                source: e,            })?;        if !exit_status.success() {            return Err(DiffEditError::from(ExternalToolError::ToolAborted {                exit_status,            }));        }        Ok(())    };     match editor.edit_invocation_mode {        DiffToolMode::Dir => {            let patterns = diffedit_wc.working_copies.to_command_variables(false);            invoke(&patterns)?;        }        DiffToolMode::FileByFile => {            let working_copies = &diffedit_wc.working_copies;            for repo_path in working_copies.checked_out_files() {                let patterns = working_copies.to_command_variables_for_file(repo_path, false);                invoke(&patterns)?;            }        }    }     diffedit_wc.snapshot_results(base_ignores).await} /// Generates textual diff by the specified `tool` and writes into `writer`.pub async fn generate_diff(    ui: &Ui,    writer: &mut dyn Write,    trees: Diff<&MergedTree>,    matcher: &dyn Matcher,    tool: &ExternalMergeTool,    default_conflict_marker_style: ConflictMarkerStyle,    width: usize,) -> Result<(), DiffGenerateError> {    let conflict_marker_style = tool        .conflict_marker_style        .unwrap_or(default_conflict_marker_style);    let diff_wc = check_out_trees(trees, matcher, DiffType::TwoWay, conflict_marker_style).await?;    diff_wc.set_left_readonly()?;    diff_wc.set_right_readonly()?;    let mut patterns = diff_wc.to_command_variables(true);    patterns.insert("width", width.to_string());    invoke_external_diff(ui, writer, tool, diff_wc.temp_dir(), &patterns)} /// Invokes the specified `tool` directing its output into `writer`.pub fn invoke_external_diff(    ui: &Ui,    writer: &mut dyn Write,    tool: &ExternalMergeTool,    diff_dir: &Path,    patterns: &HashMap<&str, String>,) -> Result<(), DiffGenerateError> {    // TODO: Somehow propagate --color to the external command?    let mut cmd = Command::new(&tool.program);    let mut patterns = patterns.clone();    if !tool.diff_do_chdir {        let absolute_left_path = Path::new(diff_dir).join(&patterns["left"]);        let absolute_right_path = Path::new(diff_dir).join(&patterns["right"]);        patterns.insert(            "left",            absolute_left_path                .into_os_string()                .into_string()                .expect("temp_dir should be valid utf-8"),        );        patterns.insert(            "right",            absolute_right_path                .into_os_string()                .into_string()                .expect("temp_dir should be valid utf-8"),        );    } else {        cmd.current_dir(diff_dir);    }    cmd.args(interpolate_variables(&tool.diff_args, &patterns));     tracing::info!(?cmd, "Invoking the external diff generator:");    let mut child = cmd

Lines 1–500 of 591.