mirror of
https://github.com/martinvonz/jj.git
synced 2025-01-27 14:47:05 +00:00
c625e9352d
The `ConflictHunk` type doesn't add anything over `Conflict<ContentHunk>`.
804 lines
26 KiB
Rust
804 lines
26 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::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
|
|
use config::ConfigError;
|
|
use itertools::Itertools;
|
|
use jujutsu_lib::backend::{TreeId, TreeValue};
|
|
use jujutsu_lib::conflicts::materialize_merge_result;
|
|
use jujutsu_lib::gitignore::GitIgnoreFile;
|
|
use jujutsu_lib::matchers::EverythingMatcher;
|
|
use jujutsu_lib::repo_path::RepoPath;
|
|
use jujutsu_lib::settings::{ConfigResultExt as _, UserSettings};
|
|
use jujutsu_lib::store::Store;
|
|
use jujutsu_lib::tree::Tree;
|
|
use jujutsu_lib::working_copy::{CheckoutError, SnapshotError, TreeState};
|
|
use regex::{Captures, Regex};
|
|
use thiserror::Error;
|
|
|
|
use crate::config::CommandNameAndArgs;
|
|
use crate::ui::Ui;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ExternalToolError {
|
|
#[error("Invalid config: {0}")]
|
|
ConfigError(#[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:?}")]
|
|
SetUpDirError(#[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(
|
|
"Tool exited with a non-zero code (run with --verbose to see the exact invocation). Exit code: {}.",
|
|
exit_status.code().map(|c| c.to_string()).unwrap_or_else(|| "<unknown>".to_string())
|
|
)]
|
|
ToolAborted {
|
|
exit_status: std::process::ExitStatus,
|
|
},
|
|
#[error("I/O error: {0:?}")]
|
|
IoError(#[from] std::io::Error),
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum DiffEditError {
|
|
#[error(transparent)]
|
|
ExternalToolError(#[from] ExternalToolError),
|
|
#[error("Failed to write directories to diff: {0:?}")]
|
|
CheckoutError(#[from] CheckoutError),
|
|
#[error("Failed to snapshot changes: {0:?}")]
|
|
SnapshotError(#[from] SnapshotError),
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ConflictResolveError {
|
|
#[error(transparent)]
|
|
ExternalToolError(#[from] ExternalToolError),
|
|
#[error("Couldn't find the path {0:?} in this revision")]
|
|
PathNotFoundError(RepoPath),
|
|
#[error("Couldn't find any conflicts at {0:?} in this revision")]
|
|
NotAConflictError(RepoPath),
|
|
#[error(
|
|
"Only conflicts that involve normal files (not symlinks, not executable, etc.) are \
|
|
supported. Conflict summary for {0:?}:\n{1}"
|
|
)]
|
|
NotNormalFilesError(RepoPath, String),
|
|
#[error("The conflict at {path:?} has {sides} sides. At most 2 sides are supported.")]
|
|
ConflictTooComplicatedError { 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:?}")]
|
|
BackendError(#[from] jujutsu_lib::backend::BackendError),
|
|
}
|
|
|
|
impl From<std::io::Error> for DiffEditError {
|
|
fn from(err: std::io::Error) -> Self {
|
|
DiffEditError::ExternalToolError(ExternalToolError::from(err))
|
|
}
|
|
}
|
|
impl From<std::io::Error> for ConflictResolveError {
|
|
fn from(err: std::io::Error) -> Self {
|
|
ConflictResolveError::ExternalToolError(ExternalToolError::from(err))
|
|
}
|
|
}
|
|
|
|
fn check_out(
|
|
store: Arc<Store>,
|
|
wc_dir: PathBuf,
|
|
state_dir: PathBuf,
|
|
tree: &Tree,
|
|
sparse_patterns: Vec<RepoPath>,
|
|
) -> Result<TreeState, DiffEditError> {
|
|
std::fs::create_dir(&wc_dir).map_err(ExternalToolError::SetUpDirError)?;
|
|
std::fs::create_dir(&state_dir).map_err(ExternalToolError::SetUpDirError)?;
|
|
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 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: &mut 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::NotAConflictError(repo_path.clone())),
|
|
None => return Err(ConflictResolveError::PathNotFoundError(repo_path.clone())),
|
|
};
|
|
let conflict = tree.store().read_conflict(repo_path, &conflict_id)?;
|
|
let file_conflict = conflict.to_file_conflict().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::NotNormalFilesError(
|
|
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_conflict.adds().len() > 2 {
|
|
return Err(ConflictResolveError::ConflictTooComplicatedError {
|
|
path: repo_path.clone(),
|
|
sides: file_conflict.adds().len(),
|
|
});
|
|
};
|
|
let content = file_conflict.extract_as_single_hunk(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 = tempfile::Builder::new()
|
|
.prefix("jj-resolve-")
|
|
.tempdir()
|
|
.map_err(ExternalToolError::SetUpDirError)?;
|
|
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::SetUpDirError)?;
|
|
if *role != "output" {
|
|
// TODO: Should actually ignore the error here, or have a warning.
|
|
set_readonly_recursively(&path).map_err(ExternalToolError::SetUpDirError)?;
|
|
}
|
|
Ok((
|
|
*role,
|
|
path.into_os_string()
|
|
.into_string()
|
|
.expect("temp_dir would 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())?;
|
|
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) = conflict.update_from_content(
|
|
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: &mut Ui,
|
|
left_tree: &Tree,
|
|
right_tree: &Tree,
|
|
instructions: &str,
|
|
base_ignores: Arc<GitIgnoreFile>,
|
|
settings: &UserSettings,
|
|
) -> Result<TreeId, DiffEditError> {
|
|
let store = left_tree.store();
|
|
let changed_files = left_tree
|
|
.diff(right_tree, &EverythingMatcher)
|
|
.map(|(path, _value)| path)
|
|
.collect_vec();
|
|
|
|
// Check out the two trees in temporary directories. Only include changed files
|
|
// in the sparse checkout patterns.
|
|
let temp_dir = tempfile::Builder::new()
|
|
.prefix("jj-diff-edit-")
|
|
.tempdir()
|
|
.map_err(ExternalToolError::SetUpDirError)?;
|
|
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");
|
|
check_out(
|
|
store.clone(),
|
|
left_wc_dir.clone(),
|
|
left_state_dir,
|
|
left_tree,
|
|
changed_files.clone(),
|
|
)?;
|
|
set_readonly_recursively(&left_wc_dir).map_err(ExternalToolError::SetUpDirError)?;
|
|
let mut right_tree_state = check_out(
|
|
store.clone(),
|
|
right_wc_dir.clone(),
|
|
right_state_dir,
|
|
right_tree,
|
|
changed_files,
|
|
)?;
|
|
let instructions_path = right_wc_dir.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::SetUpDirError)?;
|
|
file.write_all(instructions.as_bytes())
|
|
.map_err(ExternalToolError::SetUpDirError)?;
|
|
}
|
|
|
|
// Start a diff editor on the two directories.
|
|
let editor = get_diff_editor_from_settings(ui, settings)?;
|
|
let patterns = maplit::hashmap! {
|
|
"left" => left_wc_dir.to_str().expect("temp_dir would be valid utf-8"),
|
|
"right" => right_wc_dir.to_str().expect("temp_dir would be valid utf-8"),
|
|
};
|
|
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();
|
|
}
|
|
|
|
right_tree_state.snapshot(base_ignores, None)?;
|
|
Ok(right_tree_state.current_tree_id().clone())
|
|
}
|
|
|
|
/// Merge/diff tool loaded from the settings.
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
#[serde(default, rename_all = "kebab-case")]
|
|
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 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(),
|
|
edit_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),
|
|
merge_args: vec![],
|
|
merge_tool_edits_conflict_markers: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MergeTool {
|
|
pub fn with_edit_args(command_args: &CommandNameAndArgs) -> Self {
|
|
let (name, args) = command_args.split_name_and_args();
|
|
let mut tool = MergeTool {
|
|
program: name.into_owned(),
|
|
..Default::default()
|
|
};
|
|
if !args.is_empty() {
|
|
tool.edit_args = args.to_vec();
|
|
}
|
|
tool
|
|
}
|
|
|
|
pub fn with_merge_args(command_args: &CommandNameAndArgs) -> Self {
|
|
let (name, args) = command_args.split_name_and_args();
|
|
let mut tool = MergeTool {
|
|
program: name.into_owned(),
|
|
..Default::default()
|
|
};
|
|
if !args.is_empty() {
|
|
tool.merge_args = args.to_vec();
|
|
}
|
|
tool
|
|
}
|
|
}
|
|
|
|
/// Loads merge tool options from `[merge-tools.<name>]`.
|
|
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)
|
|
}
|
|
}
|
|
|
|
fn get_diff_editor_from_settings(
|
|
ui: &mut Ui,
|
|
settings: &UserSettings,
|
|
) -> Result<MergeTool, ExternalToolError> {
|
|
let args = editor_args_from_settings(ui, settings, "ui.diff-editor")?;
|
|
let maybe_editor = match &args {
|
|
CommandNameAndArgs::String(name) => get_tool_config(settings, name)?,
|
|
CommandNameAndArgs::Vec(_) => None,
|
|
CommandNameAndArgs::Structured { .. } => None,
|
|
};
|
|
Ok(maybe_editor.unwrap_or_else(|| MergeTool::with_edit_args(&args)))
|
|
}
|
|
|
|
fn get_merge_tool_from_settings(
|
|
ui: &mut Ui,
|
|
settings: &UserSettings,
|
|
) -> Result<MergeTool, ExternalToolError> {
|
|
let args = editor_args_from_settings(ui, settings, "ui.merge-editor")?;
|
|
let maybe_editor = match &args {
|
|
CommandNameAndArgs::String(name) => get_tool_config(settings, name)?,
|
|
CommandNameAndArgs::Vec(_) => None,
|
|
CommandNameAndArgs::Structured { .. } => None,
|
|
};
|
|
let editor = maybe_editor.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: &mut 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}"
|
|
)?;
|
|
Ok(default_editor.into())
|
|
}
|
|
}
|
|
|
|
#[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 mut ui = Ui::with_config(&config).unwrap();
|
|
let settings = UserSettings::from_config(config);
|
|
get_diff_editor_from_settings(&mut ui, &settings)
|
|
};
|
|
|
|
// Default
|
|
insta::assert_debug_snapshot!(get("").unwrap(), @r###"
|
|
MergeTool {
|
|
program: "meld",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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 mut ui = Ui::with_config(&config).unwrap();
|
|
let settings = UserSettings::from_config(config);
|
|
get_merge_tool_from_settings(&mut ui, &settings)
|
|
};
|
|
|
|
// Default
|
|
insta::assert_debug_snapshot!(get("").unwrap(), @r###"
|
|
MergeTool {
|
|
program: "meld",
|
|
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",
|
|
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",
|
|
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",
|
|
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"],
|
|
);
|
|
}
|
|
}
|