mirror of
https://github.com/martinvonz/jj.git
synced 2025-05-15 12:14:27 +00:00
During the transition to using more async code, I keep running into https://github.com/rust-lang/futures-rs/issues/2090. Right now, I want to convert `MergedTree::diff()` into a `Stream`. I don't want to update all call sites at once, so instead I'm adding a `MergedTree::diff_stream()` method, which just wraps `MergedTree::diff()` in a `Stream. However, since the iterator is synchronous, it needs to block on the async `Backend::read_tree()` calls. If we then also block on the `Stream` in the CLI, we run into the panic.
526 lines
16 KiB
Rust
526 lines
16 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.
|
|
|
|
mod builtin;
|
|
mod external;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use config::ConfigError;
|
|
use jj_lib::backend::MergedTreeId;
|
|
use jj_lib::conflicts::extract_as_single_hunk;
|
|
use jj_lib::gitignore::GitIgnoreFile;
|
|
use jj_lib::matchers::Matcher;
|
|
use jj_lib::merged_tree::MergedTree;
|
|
use jj_lib::repo_path::RepoPath;
|
|
use jj_lib::settings::{ConfigResultExt as _, UserSettings};
|
|
use jj_lib::working_copy::SnapshotError;
|
|
use pollster::FutureExt;
|
|
use thiserror::Error;
|
|
|
|
use self::builtin::{edit_diff_builtin, edit_merge_builtin, BuiltinToolError};
|
|
use self::external::{edit_diff_external, DiffCheckoutError, ExternalToolError};
|
|
pub use self::external::{generate_diff, ExternalMergeTool};
|
|
use crate::config::CommandNameAndArgs;
|
|
use crate::ui::Ui;
|
|
|
|
const BUILTIN_EDITOR_NAME: &str = ":builtin";
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum DiffEditError {
|
|
#[error(transparent)]
|
|
InternalTool(#[from] Box<BuiltinToolError>),
|
|
#[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)]
|
|
InternalTool(#[from] Box<BuiltinToolError>),
|
|
#[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),
|
|
}
|
|
|
|
pub fn run_mergetool(
|
|
ui: &Ui,
|
|
tree: &MergedTree,
|
|
repo_path: &RepoPath,
|
|
settings: &UserSettings,
|
|
) -> Result<MergedTreeId, ConflictResolveError> {
|
|
let conflict = match tree.path_value(repo_path).into_resolved() {
|
|
Err(conflict) => conflict,
|
|
Ok(Some(_)) => return Err(ConflictResolveError::NotAConflict(repo_path.clone())),
|
|
Ok(None) => return Err(ConflictResolveError::PathNotFound(repo_path.clone())),
|
|
};
|
|
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.num_sides() > 2 {
|
|
return Err(ConflictResolveError::ConflictTooComplicated {
|
|
path: repo_path.clone(),
|
|
sides: file_merge.num_sides(),
|
|
});
|
|
};
|
|
let content = extract_as_single_hunk(&file_merge, tree.store(), repo_path).block_on();
|
|
|
|
let editor = get_merge_tool_from_settings(ui, settings)?;
|
|
match editor {
|
|
MergeTool::Builtin => {
|
|
let tree_id = edit_merge_builtin(tree, repo_path, content).map_err(Box::new)?;
|
|
Ok(tree_id)
|
|
}
|
|
MergeTool::External(editor) => external::run_mergetool_external(
|
|
&editor, file_merge, content, repo_path, conflict, tree,
|
|
),
|
|
}
|
|
}
|
|
|
|
pub fn edit_diff(
|
|
ui: &Ui,
|
|
left_tree: &MergedTree,
|
|
right_tree: &MergedTree,
|
|
matcher: &dyn Matcher,
|
|
instructions: &str,
|
|
base_ignores: Arc<GitIgnoreFile>,
|
|
settings: &UserSettings,
|
|
) -> Result<MergedTreeId, DiffEditError> {
|
|
// Start a diff editor on the two directories.
|
|
let editor = get_diff_editor_from_settings(ui, settings)?;
|
|
match editor {
|
|
MergeTool::Builtin => {
|
|
let tree_id = edit_diff_builtin(left_tree, right_tree, matcher).map_err(Box::new)?;
|
|
Ok(tree_id)
|
|
}
|
|
MergeTool::External(editor) => edit_diff_external(
|
|
editor,
|
|
left_tree,
|
|
right_tree,
|
|
matcher,
|
|
instructions,
|
|
base_ignores,
|
|
settings,
|
|
),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum MergeTool {
|
|
Builtin,
|
|
External(ExternalMergeTool),
|
|
}
|
|
|
|
/// 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 = BUILTIN_EDITOR_NAME;
|
|
writeln!(
|
|
ui.hint(),
|
|
"Using default editor '{default_editor}'; you can change this by setting {key}"
|
|
)
|
|
.map_err(ExternalToolError::Io)?;
|
|
Ok(default_editor.into())
|
|
}
|
|
}
|
|
|
|
/// Loads merge tool options from `[merge-tools.<name>]`.
|
|
pub fn get_tool_config(
|
|
settings: &UserSettings,
|
|
name: &str,
|
|
) -> Result<Option<MergeTool>, ConfigError> {
|
|
if name == BUILTIN_EDITOR_NAME {
|
|
return Ok(Some(MergeTool::Builtin));
|
|
}
|
|
|
|
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: ExternalMergeTool = 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(MergeTool::External(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::External(ExternalMergeTool::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 mergetool = get_tool_config_from_args(settings, &args)?
|
|
.unwrap_or_else(|| MergeTool::External(ExternalMergeTool::with_merge_args(&args)));
|
|
match mergetool {
|
|
MergeTool::External(mergetool) if mergetool.merge_args.is_empty() => {
|
|
Err(ExternalToolError::MergeArgsNotConfigured {
|
|
tool_name: args.to_string(),
|
|
})
|
|
}
|
|
mergetool => Ok(mergetool),
|
|
}
|
|
}
|
|
|
|
#[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(), @"Builtin");
|
|
|
|
// Just program name, edit_args are filled by default
|
|
insta::assert_debug_snapshot!(get(r#"ui.diff-editor = "my-diff""#).unwrap(), @r###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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(), @"Builtin");
|
|
|
|
// 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###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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###"
|
|
External(
|
|
ExternalMergeTool {
|
|
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());
|
|
}
|
|
}
|