jj/cli/src/merge_tools.rs
Martin von Zweigbergk a995c66635 merge: move some methods back to conflicts as free functions
I think I moved way too many functions onto `Merge<Option<TreeValue>>`
in 82883e648d. This effectively reverts almost all of that
commit. The `Merge<T>` type is simple container and it seems like it
should be at fairly low level in the dependency graph. By moving
functions off of it, we can get rid of the back-depdencies from the
`merge` module to the `conflict` module that I introduced when I moved
`Merge` to the `merge` module. I'm thinking the `conflict` module can
focus on materialized conflicts.
2023-08-11 21:11:25 +00:00

978 lines
32 KiB
Rust

// Copyright 2020 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 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 config::ConfigError;
use itertools::Itertools;
use jj_lib::backend::{TreeId, TreeValue};
use jj_lib::conflicts;
use jj_lib::conflicts::{extract_as_single_hunk, materialize_merge_result};
use jj_lib::gitignore::GitIgnoreFile;
use jj_lib::matchers::{EverythingMatcher, Matcher};
use jj_lib::repo_path::RepoPath;
use jj_lib::settings::{ConfigResultExt as _, UserSettings};
use jj_lib::store::Store;
use jj_lib::tree::Tree;
use jj_lib::working_copy::{
CheckoutError, SnapshotError, SnapshotOptions, TreeState, TreeStateError,
};
use regex::{Captures, Regex};
use tempfile::TempDir;
use thiserror::Error;
use crate::config::CommandNameAndArgs;
use crate::ui::Ui;
#[derive(Debug, Error)]
pub enum ExternalToolError {
#[error("Invalid config: {0}")]
Config(#[from] ConfigError),
#[error(
"To use `{tool_name}` as a merge tool, the config `merge-tools.{tool_name}.merge-args` \
must be defined (see docs for details)"
)]
MergeArgsNotConfigured { tool_name: String },
#[error("Error setting up temporary directory: {0}")]
SetUpDir(#[source] std::io::Error),
// TODO: Remove the "(run with --verbose to see the exact invocation)"
// from this and other errors. Print it as a hint but only if --verbose is *not* set.
#[error(
"Error executing '{tool_binary}' (run with --verbose to see the exact invocation). \
{source}"
)]
FailedToExecute {
tool_binary: String,
#[source]
source: std::io::Error,
},
#[error("{}", format_tool_aborted(.exit_status))]
ToolAborted { exit_status: ExitStatus },
#[error("I/O error: {0}")]
Io(#[source] std::io::Error),
}
#[derive(Debug, Error)]
pub enum DiffCheckoutError {
#[error("Failed to write directories to diff: {0}")]
Checkout(#[from] CheckoutError),
#[error("Error setting up temporary directory: {0}")]
SetUpDir(#[source] std::io::Error),
#[error(transparent)]
TreeState(#[from] TreeStateError),
}
#[derive(Debug, Error)]
pub enum DiffEditError {
#[error(transparent)]
ExternalTool(#[from] ExternalToolError),
#[error(transparent)]
DiffCheckoutError(#[from] DiffCheckoutError),
#[error("Failed to snapshot changes: {0}")]
Snapshot(#[from] SnapshotError),
#[error(transparent)]
Config(#[from] config::ConfigError),
}
#[derive(Debug, Error)]
pub enum DiffGenerateError {
#[error(transparent)]
ExternalTool(#[from] ExternalToolError),
#[error(transparent)]
DiffCheckoutError(#[from] DiffCheckoutError),
}
#[derive(Debug, Error)]
pub enum ConflictResolveError {
#[error(transparent)]
ExternalTool(#[from] ExternalToolError),
#[error("Couldn't find the path {0:?} in this revision")]
PathNotFound(RepoPath),
#[error("Couldn't find any conflicts at {0:?} in this revision")]
NotAConflict(RepoPath),
#[error(
"Only conflicts that involve normal files (not symlinks, not executable, etc.) are \
supported. Conflict summary for {0:?}:\n{1}"
)]
NotNormalFiles(RepoPath, String),
#[error("The conflict at {path:?} has {sides} sides. At most 2 sides are supported.")]
ConflictTooComplicated { path: RepoPath, sides: usize },
#[error(
"The output file is either unchanged or empty after the editor quit (run with --verbose \
to see the exact invocation)."
)]
EmptyOrUnchanged,
#[error("Backend error: {0}")]
Backend(#[from] jj_lib::backend::BackendError),
}
struct DiffWorkingCopies {
_temp_dir: TempDir,
left_tree_state: TreeState,
right_tree_state: 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 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();
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"),
}
}
}
/// 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: &Tree,
right_tree: &Tree,
matcher: &dyn Matcher,
) -> Result<DiffWorkingCopies, DiffCheckoutError> {
let changed_files = left_tree
.diff(right_tree, matcher)
.map(|(path, _value)| path)
.collect_vec();
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,
)?;
Ok(DiffWorkingCopies {
_temp_dir: temp_dir,
left_tree_state,
right_tree_state,
})
}
fn check_out(
store: Arc<Store>,
wc_dir: PathBuf,
state_dir: PathBuf,
tree: &Tree,
sparse_patterns: Vec<RepoPath>,
) -> 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.
if path.is_dir() {
for entry in path.read_dir()? {
set_readonly_recursively(&entry?.path())?;
}
Ok(())
} else {
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_readonly(true);
std::fs::set_permissions(path, perms)
}
}
// TODO: Rearrange the functions. This should be on the bottom, options should
// be on the top.
pub fn run_mergetool(
ui: &Ui,
tree: &Tree,
repo_path: &RepoPath,
settings: &UserSettings,
) -> Result<TreeId, ConflictResolveError> {
let conflict_id = match tree.path_value(repo_path) {
Some(TreeValue::Conflict(id)) => id,
Some(_) => return Err(ConflictResolveError::NotAConflict(repo_path.clone())),
None => return Err(ConflictResolveError::PathNotFound(repo_path.clone())),
};
let conflict = tree.store().read_conflict(repo_path, &conflict_id)?;
let file_merge = conflict.to_file_merge().ok_or_else(|| {
let mut summary_bytes: Vec<u8> = vec![];
conflict
.describe(&mut summary_bytes)
.expect("Writing to an in-memory buffer should never fail");
ConflictResolveError::NotNormalFiles(
repo_path.clone(),
String::from_utf8_lossy(summary_bytes.as_slice()).to_string(),
)
})?;
// We only support conflicts with 2 sides (3-way conflicts)
if file_merge.adds().len() > 2 {
return Err(ConflictResolveError::ConflictTooComplicated {
path: repo_path.clone(),
sides: file_merge.adds().len(),
});
};
let content = extract_as_single_hunk(&file_merge, tree.store(), repo_path);
let editor = get_merge_tool_from_settings(ui, settings)?;
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![]
};
let (mut removes, mut adds) = content.take();
let files: HashMap<&str, _> = maplit::hashmap! {
"base" => removes.pop().unwrap().0,
"right" => adds.pop().unwrap().0,
"left" => adds.pop().unwrap().0,
"output" => initial_output_content.clone(),
};
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 mut new_tree_value: Option<TreeValue> = None;
if editor.merge_tool_edits_conflict_markers {
if let Some(new_conflict) = conflicts::update_from_content(
&conflict,
tree.store(),
repo_path,
output_file_contents.as_slice(),
)? {
let new_conflict_id = tree.store().write_conflict(repo_path, &new_conflict)?;
new_tree_value = Some(TreeValue::Conflict(new_conflict_id));
}
}
let new_tree_value = new_tree_value.unwrap_or({
let new_file_id = tree
.store()
.write_file(repo_path, &mut output_file_contents.as_slice())?;
TreeValue::File {
id: new_file_id,
executable: false,
}
});
let mut tree_builder = tree.store().tree_builder(tree.id().clone());
tree_builder.set(repo_path.clone(), new_tree_value);
Ok(tree_builder.write_tree())
}
fn interpolate_variables<V: AsRef<str>>(
args: &[String],
variables: &HashMap<&str, V>,
) -> Vec<String> {
// Not interested in $UPPER_CASE_VARIABLES
let re = Regex::new(r"\$([a-z0-9_]+)\b").unwrap();
args.iter()
.map(|arg| {
re.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()
}
pub fn edit_diff(
ui: &Ui,
left_tree: &Tree,
right_tree: &Tree,
instructions: &str,
base_ignores: Arc<GitIgnoreFile>,
settings: &UserSettings,
) -> Result<TreeId, DiffEditError> {
let store = left_tree.store();
let diff_wc = check_out_trees(store, left_tree, right_tree, &EverythingMatcher)?;
set_readonly_recursively(diff_wc.left_working_copy_path())
.map_err(ExternalToolError::SetUpDir)?;
let instructions_path = diff_wc.right_working_copy_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 =
settings.diff_instructions() && !instructions.is_empty() && !instructions_path.exists();
if add_instructions {
// TODO: This can be replaced with std::fs::write. Is this used in other places
// as well?
let mut file = File::create(&instructions_path).map_err(ExternalToolError::SetUpDir)?;
file.write_all(instructions.as_bytes())
.map_err(ExternalToolError::SetUpDir)?;
}
// Start a diff editor on the two directories.
let editor = get_diff_editor_from_settings(ui, settings)?;
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(),
source: e,
})?;
if !exit_status.success() {
return Err(DiffEditError::from(ExternalToolError::ToolAborted {
exit_status,
}));
}
if add_instructions {
std::fs::remove_file(instructions_path).ok();
}
let mut right_tree_state = diff_wc.right_tree_state;
right_tree_state.snapshot(SnapshotOptions {
base_ignores,
fsmonitor_kind: settings.fsmonitor_kind()?,
progress: None,
})?;
Ok(right_tree_state.current_tree_id().clone())
}
/// Generates textual diff by the specified `tool`, and writes into `writer`.
pub fn generate_diff(
ui: &Ui,
writer: &mut dyn Write,
left_tree: &Tree,
right_tree: &Tree,
matcher: &dyn Matcher,
tool: &MergeTool,
) -> Result<(), DiffGenerateError> {
let store = left_tree.store();
let diff_wc = check_out_trees(store, left_tree, right_tree, matcher)?;
set_readonly_recursively(diff_wc.left_working_copy_path())
.map_err(ExternalToolError::SetUpDir)?;
set_readonly_recursively(diff_wc.right_working_copy_path())
.map_err(ExternalToolError::SetUpDir)?;
// TODO: Add support for tools without directory diff functionality?
// TODO: Somehow propagate --color to the external command?
let patterns = diff_wc.to_command_variables();
let mut cmd = Command::new(&tool.program);
cmd.args(interpolate_variables(&tool.diff_args, &patterns));
tracing::info!(?cmd, "Invoking the external diff generator:");
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(ui.stderr_for_child().map_err(ExternalToolError::Io)?)
.spawn()
.map_err(|source| ExternalToolError::FailedToExecute {
tool_binary: tool.program.clone(),
source,
})?;
io::copy(&mut child.stdout.take().unwrap(), writer).map_err(ExternalToolError::Io)?;
// Non-zero exit code isn't an error. For example, the traditional diff command
// will exit with 1 if inputs are different.
let exit_status = child.wait().map_err(ExternalToolError::Io)?;
tracing::info!(?cmd, ?exit_status, "The external diff generator exited:");
if !exit_status.success() {
writeln!(ui.warning(), "{}", format_tool_aborted(&exit_status)).ok();
}
Ok(())
}
/// Merge/diff tool loaded from the settings.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct MergeTool {
/// 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 MergeTool {
fn default() -> Self {
MergeTool {
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 MergeTool {
pub fn with_program(program: impl Into<String>) -> Self {
MergeTool {
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 = MergeTool {
program: name.into_owned(),
..Default::default()
};
if !args.is_empty() {
*get_mut_args(&mut tool) = args.to_vec();
}
tool
}
}
/// Loads merge tool options from `[merge-tools.<name>]`.
pub fn get_tool_config(
settings: &UserSettings,
name: &str,
) -> Result<Option<MergeTool>, ConfigError> {
const TABLE_KEY: &str = "merge-tools";
let tools_table = settings.config().get_table(TABLE_KEY)?;
if let Some(v) = tools_table.get(name) {
let mut result: MergeTool = v
.clone()
.try_deserialize()
// add config key, deserialize error is otherwise unclear
.map_err(|e| ConfigError::Message(format!("{TABLE_KEY}.{name}: {e}")))?;
if result.program.is_empty() {
result.program.clone_from(&name.to_string());
};
Ok(Some(result))
} else {
Ok(None)
}
}
/// Loads merge tool options from `[merge-tools.<name>]` if `args` is of
/// unstructured string type.
pub fn get_tool_config_from_args(
settings: &UserSettings,
args: &CommandNameAndArgs,
) -> Result<Option<MergeTool>, ConfigError> {
match args {
CommandNameAndArgs::String(name) => get_tool_config(settings, name),
CommandNameAndArgs::Vec(_) | CommandNameAndArgs::Structured { .. } => Ok(None),
}
}
fn get_diff_editor_from_settings(
ui: &Ui,
settings: &UserSettings,
) -> Result<MergeTool, ExternalToolError> {
let args = editor_args_from_settings(ui, settings, "ui.diff-editor")?;
let editor = get_tool_config_from_args(settings, &args)?
.unwrap_or_else(|| MergeTool::with_edit_args(&args));
Ok(editor)
}
fn get_merge_tool_from_settings(
ui: &Ui,
settings: &UserSettings,
) -> Result<MergeTool, ExternalToolError> {
let args = editor_args_from_settings(ui, settings, "ui.merge-editor")?;
let editor = get_tool_config_from_args(settings, &args)?
.unwrap_or_else(|| MergeTool::with_merge_args(&args));
if editor.merge_args.is_empty() {
Err(ExternalToolError::MergeArgsNotConfigured {
tool_name: args.to_string(),
})
} else {
Ok(editor)
}
}
/// Finds the appropriate tool for diff editing or merges
fn editor_args_from_settings(
ui: &Ui,
settings: &UserSettings,
key: &str,
) -> Result<CommandNameAndArgs, ExternalToolError> {
// TODO: Make this configuration have a table of possible editors and detect the
// best one here.
if let Some(args) = settings.config().get(key).optional()? {
Ok(args)
} else {
let default_editor = "meld";
writeln!(
ui.hint(),
"Using default editor '{default_editor}'; you can change this by setting {key}"
)
.map_err(ExternalToolError::Io)?;
Ok(default_editor.into())
}
}
fn format_tool_aborted(exit_status: &ExitStatus) -> String {
let code = exit_status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "<unknown>".to_string());
format!(
"Tool exited with a non-zero code (run with --verbose to see the exact invocation). Exit \
code: {code}."
)
}
#[cfg(test)]
mod tests {
use super::*;
fn config_from_string(text: &str) -> config::Config {
config::Config::builder()
// Load defaults to test the default args lookup
.add_source(crate::config::default_config())
.add_source(config::File::from_str(text, config::FileFormat::Toml))
.build()
.unwrap()
}
#[test]
fn test_get_diff_editor() {
let get = |text| {
let config = config_from_string(text);
let ui = Ui::with_config(&config).unwrap();
let settings = UserSettings::from_config(config);
get_diff_editor_from_settings(&ui, &settings)
};
// Default
insta::assert_debug_snapshot!(get("").unwrap(), @r###"
MergeTool {
program: "meld",
diff_args: [
"$left",
"$right",
],
edit_args: [
"$left",
"$right",
],
merge_args: [
"$left",
"$base",
"$right",
"-o",
"$output",
"--auto-merge",
],
merge_tool_edits_conflict_markers: false,
}
"###);
// Just program name, edit_args are filled by default
insta::assert_debug_snapshot!(get(r#"ui.diff-editor = "my-diff""#).unwrap(), @r###"
MergeTool {
program: "my-diff",
diff_args: [
"$left",
"$right",
],
edit_args: [
"$left",
"$right",
],
merge_args: [],
merge_tool_edits_conflict_markers: false,
}
"###);
// String args (with interpolation variables)
insta::assert_debug_snapshot!(
get(r#"ui.diff-editor = "my-diff -l $left -r $right""#).unwrap(), @r###"
MergeTool {
program: "my-diff",
diff_args: [
"$left",
"$right",
],
edit_args: [
"-l",
"$left",
"-r",
"$right",
],
merge_args: [],
merge_tool_edits_conflict_markers: false,
}
"###);
// List args (with interpolation variables)
insta::assert_debug_snapshot!(
get(r#"ui.diff-editor = ["my-diff", "--diff", "$left", "$right"]"#).unwrap(), @r###"
MergeTool {
program: "my-diff",
diff_args: [
"$left",
"$right",
],
edit_args: [
"--diff",
"$left",
"$right",
],
merge_args: [],
merge_tool_edits_conflict_markers: false,
}
"###);
// Pick from merge-tools
insta::assert_debug_snapshot!(get(
r#"
ui.diff-editor = "foo bar"
[merge-tools."foo bar"]
edit-args = ["--edit", "args", "$left", "$right"]
"#,
).unwrap(), @r###"
MergeTool {
program: "foo bar",
diff_args: [
"$left",
"$right",
],
edit_args: [
"--edit",
"args",
"$left",
"$right",
],
merge_args: [],
merge_tool_edits_conflict_markers: false,
}
"###);
// Pick from merge-tools, but no edit-args specified
insta::assert_debug_snapshot!(get(
r#"
ui.diff-editor = "my-diff"
[merge-tools.my-diff]
program = "MyDiff"
"#,
).unwrap(), @r###"
MergeTool {
program: "MyDiff",
diff_args: [
"$left",
"$right",
],
edit_args: [
"$left",
"$right",
],
merge_args: [],
merge_tool_edits_conflict_markers: false,
}
"###);
// List args should never be a merge-tools key, edit_args are filled by default
insta::assert_debug_snapshot!(get(r#"ui.diff-editor = ["meld"]"#).unwrap(), @r###"
MergeTool {
program: "meld",
diff_args: [
"$left",
"$right",
],
edit_args: [
"$left",
"$right",
],
merge_args: [],
merge_tool_edits_conflict_markers: false,
}
"###);
// Invalid type
assert!(get(r#"ui.diff-editor.k = 0"#).is_err());
}
#[test]
fn test_get_merge_tool() {
let get = |text| {
let config = config_from_string(text);
let ui = Ui::with_config(&config).unwrap();
let settings = UserSettings::from_config(config);
get_merge_tool_from_settings(&ui, &settings)
};
// Default
insta::assert_debug_snapshot!(get("").unwrap(), @r###"
MergeTool {
program: "meld",
diff_args: [
"$left",
"$right",
],
edit_args: [
"$left",
"$right",
],
merge_args: [
"$left",
"$base",
"$right",
"-o",
"$output",
"--auto-merge",
],
merge_tool_edits_conflict_markers: false,
}
"###);
// Just program name
insta::assert_debug_snapshot!(get(r#"ui.merge-editor = "my-merge""#).unwrap_err(), @r###"
MergeArgsNotConfigured {
tool_name: "my-merge",
}
"###);
// String args
insta::assert_debug_snapshot!(
get(r#"ui.merge-editor = "my-merge $left $base $right $output""#).unwrap(), @r###"
MergeTool {
program: "my-merge",
diff_args: [
"$left",
"$right",
],
edit_args: [
"$left",
"$right",
],
merge_args: [
"$left",
"$base",
"$right",
"$output",
],
merge_tool_edits_conflict_markers: false,
}
"###);
// List args
insta::assert_debug_snapshot!(
get(
r#"ui.merge-editor = ["my-merge", "$left", "$base", "$right", "$output"]"#,
).unwrap(), @r###"
MergeTool {
program: "my-merge",
diff_args: [
"$left",
"$right",
],
edit_args: [
"$left",
"$right",
],
merge_args: [
"$left",
"$base",
"$right",
"$output",
],
merge_tool_edits_conflict_markers: false,
}
"###);
// Pick from merge-tools
insta::assert_debug_snapshot!(get(
r#"
ui.merge-editor = "foo bar"
[merge-tools."foo bar"]
merge-args = ["$base", "$left", "$right", "$output"]
"#,
).unwrap(), @r###"
MergeTool {
program: "foo bar",
diff_args: [
"$left",
"$right",
],
edit_args: [
"$left",
"$right",
],
merge_args: [
"$base",
"$left",
"$right",
"$output",
],
merge_tool_edits_conflict_markers: false,
}
"###);
// List args should never be a merge-tools key
insta::assert_debug_snapshot!(
get(r#"ui.merge-editor = ["meld"]"#).unwrap_err(), @r###"
MergeArgsNotConfigured {
tool_name: "meld",
}
"###);
// Invalid type
assert!(get(r#"ui.merge-editor.k = 0"#).is_err());
}
#[test]
fn test_interpolate_variables() {
let patterns = maplit::hashmap! {
"left" => "LEFT",
"right" => "RIGHT",
"left_right" => "$left $right",
};
assert_eq!(
interpolate_variables(
&["$left", "$1", "$right", "$2"].map(ToOwned::to_owned),
&patterns
),
["LEFT", "$1", "RIGHT", "$2"],
);
// Option-like
assert_eq!(
interpolate_variables(&["-o$left$right".to_owned()], &patterns),
["-oLEFTRIGHT"],
);
// Sexp-like
assert_eq!(
interpolate_variables(&["($unknown $left $right)".to_owned()], &patterns),
["($unknown LEFT RIGHT)"],
);
// Not a word "$left"
assert_eq!(
interpolate_variables(&["$lefty".to_owned()], &patterns),
["$lefty"],
);
// Patterns in pattern: not expanded recursively
assert_eq!(
interpolate_variables(&["$left_right".to_owned()], &patterns),
["$left $right"],
);
}
}