641 lines · 22.0 KB · Rust
RawDownload
use std::collections::HashMap;use std::fs::File;use std::io::{self, Write};use std::path::{Path, PathBuf};use std::process::{Command, ExitStatus, Stdio};use std::sync::Arc; use futures::StreamExt;use itertools::Itertools;use jj_lib::backend::{FileId, MergedTreeId, TreeValue};use jj_lib::conflicts::{self, materialize_merge_result};use jj_lib::fsmonitor::FsmonitorKind;use jj_lib::gitignore::GitIgnoreFile;use jj_lib::local_working_copy::{TreeState, TreeStateError};use jj_lib::matchers::Matcher;use jj_lib::merge::{Merge, MergedTreeValue};use jj_lib::merged_tree::{MergedTree, MergedTreeBuilder};use jj_lib::repo_path::{RepoPath, RepoPathBuf};use jj_lib::store::Store;use jj_lib::working_copy::{CheckoutError, SnapshotOptions};use pollster::FutureExt;use regex::{Captures, Regex};use tempfile::TempDir;use thiserror::Error; use super::{ConflictResolveError, DiffEditError, DiffGenerateError};use crate::config::CommandNameAndArgs;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>,    /// Arguments to pass to the program when editing diffs.    /// `$left` and `$right` are replaced with the corresponding directories.    pub edit_args: Vec<String>,    /// 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>,    /// 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 JJ's conflict markers. After the    /// merge tool is done, any remaining conflict markers in the    /// file parsed and taken to mean that the conflict was only partially    /// resolved.    // TODO: Instead of a boolean, this could denote the flavor of conflict markers to put in    // the file (`jj` or `diff3` for example).    pub merge_tool_edits_conflict_markers: bool,} impl Default for ExternalMergeTool {    fn default() -> Self {        Self {            program: String::new(),            diff_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),            edit_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),            merge_args: vec![],            merge_tool_edits_conflict_markers: false,        }    }} 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("{}", format_tool_aborted(.exit_status))]    ToolAborted { exit_status: ExitStatus },    #[error("I/O error")]    Io(#[source] std::io::Error),} #[derive(Debug, Error)]pub enum DiffCheckoutError {    #[error("Failed to write directories to diff")]    Checkout(#[from] CheckoutError),    #[error("Error setting up temporary directory")]    SetUpDir(#[source] std::io::Error),    #[error(transparent)]    TreeState(#[from] TreeStateError),} struct DiffWorkingCopies {    _temp_dir: TempDir,    left_tree_state: TreeState,    right_tree_state: TreeState,    output_tree_state: Option<TreeState>,} impl DiffWorkingCopies {    fn left_working_copy_path(&self) -> &Path {        self.left_tree_state.working_copy_path()    }     fn right_working_copy_path(&self) -> &Path {        self.right_tree_state.working_copy_path()    }     fn output_working_copy_path(&self) -> Option<&Path> {        self.output_tree_state            .as_ref()            .map(|state| state.working_copy_path())    }     fn to_command_variables(&self) -> HashMap<&'static str, &str> {        let left_wc_dir = self.left_working_copy_path();        let right_wc_dir = self.right_working_copy_path();        let mut result = maplit::hashmap! {            "left" => left_wc_dir.to_str().expect("temp_dir should be valid utf-8"),            "right" => right_wc_dir.to_str().expect("temp_dir should be valid utf-8"),        };        if let Some(output_wc_dir) = self.output_working_copy_path() {            result.insert(                "output",                output_wc_dir                    .to_str()                    .expect("temp_dir should be valid utf-8"),            );        }        result    }} fn check_out(    store: Arc<Store>,    wc_dir: PathBuf,    state_dir: PathBuf,    tree: &MergedTree,    sparse_patterns: Vec<RepoPathBuf>,) -> Result<TreeState, DiffCheckoutError> {    std::fs::create_dir(&wc_dir).map_err(DiffCheckoutError::SetUpDir)?;    std::fs::create_dir(&state_dir).map_err(DiffCheckoutError::SetUpDir)?;    let mut tree_state = TreeState::init(store, wc_dir, state_dir)?;    tree_state.set_sparse_patterns(sparse_patterns)?;    tree_state.check_out(tree)?;    Ok(tree_state)} fn new_utf8_temp_dir(prefix: &str) -> io::Result<TempDir> {    let temp_dir = tempfile::Builder::new().prefix(prefix).tempdir()?;    if temp_dir.path().to_str().is_none() {        // Not using .display() as we know the path contains unprintable character        let message = format!("path {:?} is not valid UTF-8", temp_dir.path());        return Err(io::Error::new(io::ErrorKind::InvalidData, message));    }    Ok(temp_dir)} fn set_readonly_recursively(path: &Path) -> Result<(), std::io::Error> {    // Directory permission is unchanged since files under readonly directory cannot    // be removed.    let metadata = path.symlink_metadata()?;    if metadata.is_dir() {        for entry in path.read_dir()? {            set_readonly_recursively(&entry?.path())?;        }        Ok(())    } else if metadata.is_file() {        let mut perms = metadata.permissions();        perms.set_readonly(true);        std::fs::set_permissions(path, perms)    } else {        Ok(())    }} #[derive(Debug, Clone, Copy)]enum DiffSide {    // Left,    Right,} /// Check out the two trees in temporary directories. Only include changed files/// in the sparse checkout patterns.fn check_out_trees(    store: &Arc<Store>,    left_tree: &MergedTree,    right_tree: &MergedTree,    matcher: &dyn Matcher,    output_is: Option<DiffSide>,) -> Result<DiffWorkingCopies, DiffCheckoutError> {    let changed_files: Vec<_> = left_tree        .diff_stream(right_tree, matcher)        .map(|(path, _diff)| path)        .collect()        .block_on();     let temp_dir = new_utf8_temp_dir("jj-diff-").map_err(DiffCheckoutError::SetUpDir)?;    let left_wc_dir = temp_dir.path().join("left");    let left_state_dir = temp_dir.path().join("left_state");    let right_wc_dir = temp_dir.path().join("right");    let right_state_dir = temp_dir.path().join("right_state");    let left_tree_state = check_out(        store.clone(),        left_wc_dir,        left_state_dir,        left_tree,        changed_files.clone(),    )?;    let right_tree_state = check_out(        store.clone(),        right_wc_dir,        right_state_dir,        right_tree,        changed_files.clone(),    )?;    let output_tree_state = output_is        .map(|output_side| {            let output_wc_dir = temp_dir.path().join("output");            let output_state_dir = temp_dir.path().join("output_state");            check_out(                store.clone(),                output_wc_dir,                output_state_dir,                match output_side {                    // DiffSide::Left => left_tree,                    DiffSide::Right => right_tree,                },                changed_files,            )        })        .transpose()?;    Ok(DiffWorkingCopies {        _temp_dir: temp_dir,        left_tree_state,        right_tree_state,        output_tree_state,    })} pub fn run_mergetool_external(    editor: &ExternalMergeTool,    file_merge: Merge<Option<FileId>>,    content: Merge<jj_lib::files::ContentHunk>,    repo_path: &RepoPath,    conflict: MergedTreeValue,    tree: &MergedTree,) -> Result<MergedTreeId, ConflictResolveError> {    let initial_output_content: Vec<u8> = if editor.merge_tool_edits_conflict_markers {        let mut materialized_conflict = vec![];        materialize_merge_result(&content, &mut materialized_conflict)            .expect("Writing to an in-memory buffer should never fail");        materialized_conflict    } else {        vec![]    };    assert_eq!(content.num_sides(), 2);    let files: HashMap<&str, &[u8]> = maplit::hashmap! {        "base" => content.get_remove(0).unwrap().0.as_slice(),        "left" => content.get_add(0).unwrap().0.as_slice(),        "right" => content.get_add(1).unwrap().0.as_slice(),        "output" => initial_output_content.as_slice(),    };     let temp_dir = new_utf8_temp_dir("jj-resolve-").map_err(ExternalToolError::SetUpDir)?;    let suffix = repo_path        .components()        .last()        .map(|filename| format!("_{}", filename.as_str()))        // The default case below should never actually trigger, but we support it just in case        // resolving the root path ever makes sense.        .unwrap_or_default();    let paths: 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()?;     let mut cmd = Command::new(&editor.program);    cmd.args(interpolate_variables(&editor.merge_args, &paths));    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,        })?;    if !exit_status.success() {        return Err(ConflictResolveError::from(ExternalToolError::ToolAborted {            exit_status,        }));    }     let output_file_contents: Vec<u8> =        std::fs::read(paths.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 {        conflicts::update_from_content(            &file_merge,            tree.store(),            repo_path,            output_file_contents.as_slice(),        )        .block_on()?    } else {        let new_file_id = tree            .store()            .write_file(repo_path, &mut output_file_contents.as_slice())?;        Merge::normal(new_file_id)    };    let new_tree_value = match new_file_ids.into_resolved() {        Ok(new_file_id) => Merge::normal(TreeValue::File {            id: new_file_id.unwrap(),            executable: false,        }),        Err(new_file_ids) => conflict.with_new_file_ids(&new_file_ids),    };    let mut tree_builder = MergedTreeBuilder::new(tree.id());    tree_builder.set_or_remove(repo_path.to_owned(), new_tree_value);    let new_tree = tree_builder.write_tree(tree.store())?;    Ok(new_tree)} // Not interested in $UPPER_CASE_VARIABLESstatic VARIABLE_REGEX: once_cell::sync::Lazy<Regex> =    once_cell::sync::Lazy::new(|| Regex::new(r"\$([a-z0-9_]+)\b").unwrap()); fn interpolate_variables<V: AsRef<str>>(    args: &[String],    variables: &HashMap<&str, V>,) -> Vec<String> {    args.iter()        .map(|arg| {            VARIABLE_REGEX                .replace_all(arg, |caps: &Captures| {                    let name = &caps[1];                    if let Some(subst) = variables.get(name) {                        subst.as_ref().to_owned()                    } else {                        caps[0].to_owned()                    }                })                .into_owned()        })        .collect()} /// Return all variable names found in the args, without the dollar signfn find_all_variables(args: &[String]) -> impl Iterator<Item = &str> {    let regex = &*VARIABLE_REGEX;    args.iter()        .flat_map(|arg| regex.find_iter(arg))        .map(|single_match| {            let s = single_match.as_str();            &s[1..]        })} pub fn edit_diff_external(    editor: &ExternalMergeTool,    left_tree: &MergedTree,    right_tree: &MergedTree,    matcher: &dyn Matcher,    instructions: Option<&str>,    base_ignores: Arc<GitIgnoreFile>,) -> Result<MergedTreeId, DiffEditError> {    let got_output_field = find_all_variables(&editor.edit_args).contains(&"output");    let store = left_tree.store();    let diff_wc = check_out_trees(        store,        left_tree,        right_tree,        matcher,        got_output_field.then_some(DiffSide::Right),    )?;    set_readonly_recursively(diff_wc.left_working_copy_path())        .map_err(ExternalToolError::SetUpDir)?;    if got_output_field {        set_readonly_recursively(diff_wc.right_working_copy_path())            .map_err(ExternalToolError::SetUpDir)?;    }    let output_wc_path = diff_wc        .output_working_copy_path()        .unwrap_or_else(|| diff_wc.right_working_copy_path());    let instructions_path_to_cleanup = output_wc_path.join("JJ-INSTRUCTIONS");    // In the unlikely event that the file already exists, then the user will simply    // not get any instructions.    let add_instructions = if !instructions_path_to_cleanup.exists() {        instructions    } else {        None    };    if let Some(instructions) = add_instructions {        let mut output_instructions_file =            File::create(&instructions_path_to_cleanup).map_err(ExternalToolError::SetUpDir)?;        if diff_wc.right_working_copy_path() != output_wc_path {            let mut right_instructions_file =                File::create(diff_wc.right_working_copy_path().join("JJ-INSTRUCTIONS"))                    .map_err(ExternalToolError::SetUpDir)?;            right_instructions_file                .write_all(                    b"\The content of this pane should NOT be edited. Any edits will belost. You are using the experimental 3-pane diff editor config. Some ofthe following instructions may have been written with a 2-panediff editing in mind and be a little inaccurate. ",                )                .map_err(ExternalToolError::SetUpDir)?;            right_instructions_file                .write_all(instructions.as_bytes())                .map_err(ExternalToolError::SetUpDir)?;            // Note that some diff tools might not show this message and delete the contents            // of the output dir instead. Meld does show this message.            output_instructions_file                .write_all(                    b"\Please make your edits in this pane. You are using the experimental 3-pane diff editor config. Some ofthe following instructions may have been written with a 2-panediff editing in mind and be a little inaccurate. ",                )                .map_err(ExternalToolError::SetUpDir)?;        }        output_instructions_file            .write_all(instructions.as_bytes())            .map_err(ExternalToolError::SetUpDir)?;    }     let patterns = diff_wc.to_command_variables();    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(),

Lines 1–500 of 641.