Skip to main content

jj_cli/merge_tools/
mod.rs

1// Copyright 2020 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15mod builtin;
16mod diff_working_copies;
17mod external;
18
19use std::sync::Arc;
20
21use futures::future::try_join_all;
22use jj_lib::backend::BackendError;
23use jj_lib::backend::CopyId;
24use jj_lib::backend::MergedTreeValue;
25use jj_lib::backend::MergedTreeValueExt as _;
26use jj_lib::backend::TreeValue;
27use jj_lib::config::ConfigGetError;
28use jj_lib::config::ConfigGetResultExt as _;
29use jj_lib::config::ConfigNamePathBuf;
30use jj_lib::conflicts::ConflictMarkerStyle;
31use jj_lib::conflicts::MaterializedFileConflictValue;
32use jj_lib::conflicts::try_materialize_file_conflict_value;
33use jj_lib::gitignore::GitIgnoreFile;
34use jj_lib::matchers::Matcher;
35use jj_lib::merge::Diff;
36use jj_lib::merge::Merge;
37use jj_lib::merged_tree::MergedTree;
38use jj_lib::merged_tree_builder::MergedTreeBuilder;
39use jj_lib::repo_path::InvalidRepoPathError;
40use jj_lib::repo_path::RepoPath;
41use jj_lib::repo_path::RepoPathBuf;
42use jj_lib::settings::UserSettings;
43use jj_lib::ui_path::RepoPathUiConverter;
44use jj_lib::working_copy::SnapshotError;
45use thiserror::Error;
46
47use self::builtin::BuiltinToolError;
48use self::builtin::edit_diff_builtin;
49use self::builtin::edit_merge_builtin;
50use self::diff_working_copies::DiffCheckoutError;
51pub(crate) use self::diff_working_copies::new_utf8_temp_dir;
52pub use self::external::DiffToolMode;
53pub use self::external::ExternalMergeTool;
54use self::external::ExternalToolError;
55use self::external::edit_diff_external;
56pub use self::external::generate_diff;
57pub use self::external::invoke_external_diff;
58use crate::config::CommandNameAndArgs;
59use crate::ui::Ui;
60
61const BUILTIN_EDITOR_NAME: &str = ":builtin";
62const OURS_TOOL_NAME: &str = ":ours";
63const THEIRS_TOOL_NAME: &str = ":theirs";
64
65#[derive(Debug, Error)]
66pub enum DiffEditError {
67    #[error(transparent)]
68    InternalTool(#[from] Box<BuiltinToolError>),
69    #[error(transparent)]
70    ExternalTool(#[from] ExternalToolError),
71    #[error(transparent)]
72    DiffCheckoutError(#[from] DiffCheckoutError),
73    #[error("Failed to snapshot changes")]
74    Snapshot(#[from] SnapshotError),
75    #[error(transparent)]
76    Config(#[from] ConfigGetError),
77}
78
79#[derive(Debug, Error)]
80pub enum DiffGenerateError {
81    #[error(transparent)]
82    ExternalTool(#[from] ExternalToolError),
83    #[error(transparent)]
84    DiffCheckoutError(#[from] DiffCheckoutError),
85}
86
87#[derive(Debug, Error)]
88pub enum ConflictResolveError {
89    #[error(transparent)]
90    InternalTool(#[from] Box<BuiltinToolError>),
91    #[error(transparent)]
92    ExternalTool(#[from] ExternalToolError),
93    #[error(transparent)]
94    InvalidRepoPath(#[from] InvalidRepoPathError),
95    #[error("Couldn't find the path {0:?} in this revision")]
96    PathNotFound(RepoPathBuf),
97    #[error("Couldn't find any conflicts at {0:?} in this revision")]
98    NotAConflict(RepoPathBuf),
99    #[error(
100        "Only conflicts that involve normal files (not symlinks, etc.) are supported. Conflict \
101         summary for {path:?}:\n{summary}",
102        summary = summary.trim_end()
103    )]
104    NotNormalFiles { path: RepoPathBuf, summary: String },
105    #[error("The conflict at {path:?} has {sides} sides. At most 2 sides are supported.")]
106    ConflictTooComplicated { path: RepoPathBuf, sides: usize },
107    #[error("{path:?} has conflicts in executable bit\n{summary}", summary = summary.trim_end())]
108    ExecutableConflict { path: RepoPathBuf, summary: String },
109    #[error(
110        "The output file is either unchanged or empty after the editor quit (run with --debug to \
111         see the exact invocation)."
112    )]
113    EmptyOrUnchanged,
114    #[error(transparent)]
115    Backend(#[from] jj_lib::backend::BackendError),
116    #[error(transparent)]
117    Io(#[from] std::io::Error),
118}
119
120#[derive(Debug, Error)]
121#[error("Stopped due to error after resolving {resolved_count} conflicts")]
122pub struct MergeToolPartialResolutionError {
123    pub source: ConflictResolveError,
124    pub resolved_count: usize,
125}
126
127#[derive(Debug, Error)]
128pub enum MergeToolConfigError {
129    #[error(transparent)]
130    Config(#[from] ConfigGetError),
131    #[error("The tool `{tool_name}` cannot be used as a merge tool with `jj resolve`")]
132    MergeArgsNotConfigured { tool_name: String },
133    #[error("The tool `{tool_name}` cannot be used as a diff editor")]
134    EditArgsNotConfigured { tool_name: String },
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub enum MergeTool {
139    Builtin,
140    Ours,
141    Theirs,
142    // Boxed because ExternalMergeTool is big compared to the Builtin variant.
143    External(Box<ExternalMergeTool>),
144}
145
146impl MergeTool {
147    fn external(tool: ExternalMergeTool) -> Self {
148        Self::External(Box::new(tool))
149    }
150
151    /// Resolves builtin merge tool names or loads external tool options from
152    /// `[merge-tools.<name>]`.
153    fn get_tool_config(
154        settings: &UserSettings,
155        name: &str,
156    ) -> Result<Option<Self>, MergeToolConfigError> {
157        match name {
158            BUILTIN_EDITOR_NAME => Ok(Some(Self::Builtin)),
159            OURS_TOOL_NAME => Ok(Some(Self::Ours)),
160            THEIRS_TOOL_NAME => Ok(Some(Self::Theirs)),
161            _ => Ok(get_external_tool_config(settings, name)?.map(Self::external)),
162        }
163    }
164}
165
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub enum DiffEditTool {
168    Builtin,
169    // Boxed because ExternalMergeTool is big compared to the Builtin variant.
170    External(Box<ExternalMergeTool>),
171}
172
173impl DiffEditTool {
174    fn external(tool: ExternalMergeTool) -> Self {
175        Self::External(Box::new(tool))
176    }
177
178    /// Resolves builtin merge tool name or loads external tool options from
179    /// `[merge-tools.<name>]`.
180    fn get_tool_config(
181        settings: &UserSettings,
182        name: &str,
183    ) -> Result<Option<Self>, MergeToolConfigError> {
184        match name {
185            BUILTIN_EDITOR_NAME => Ok(Some(Self::Builtin)),
186            _ => Ok(get_external_tool_config(settings, name)?.map(Self::external)),
187        }
188    }
189}
190
191/// Finds the appropriate tool for diff editing or merges
192fn editor_args_from_settings(
193    ui: &Ui,
194    settings: &UserSettings,
195    key: &'static str,
196) -> Result<CommandNameAndArgs, ConfigGetError> {
197    // TODO: Make this configuration have a table of possible editors and detect the
198    // best one here.
199    if let Some(args) = settings.get(key).optional()? {
200        Ok(args)
201    } else {
202        let default_editor = BUILTIN_EDITOR_NAME;
203        writeln!(
204            ui.hint_default(),
205            "Using default editor '{default_editor}'; run `jj config set --user {key} :builtin` \
206             to disable this message."
207        )
208        .ok();
209        Ok(default_editor.into())
210    }
211}
212
213/// List configured merge tools (diff editors, diff tools, merge editors)
214pub fn configured_merge_tools(settings: &UserSettings) -> impl Iterator<Item = &str> {
215    settings.table_keys("merge-tools")
216}
217
218/// Loads external diff/merge tool options from `[merge-tools.<name>]`.
219pub fn get_external_tool_config(
220    settings: &UserSettings,
221    name: &str,
222) -> Result<Option<ExternalMergeTool>, ConfigGetError> {
223    let full_name = ConfigNamePathBuf::from_iter(["merge-tools", name]);
224    let Some(mut tool) = settings.get::<ExternalMergeTool>(&full_name).optional()? else {
225        return Ok(None);
226    };
227    if tool.program.is_empty() {
228        tool.program = name.to_owned();
229    }
230    Ok(Some(tool))
231}
232
233/// Configured diff editor.
234#[derive(Clone, Debug)]
235pub struct DiffEditor {
236    tool: DiffEditTool,
237    base_ignores: Arc<GitIgnoreFile>,
238    use_instructions: bool,
239    conflict_marker_style: ConflictMarkerStyle,
240}
241
242impl DiffEditor {
243    /// Creates diff editor of the given name, and loads parameters from the
244    /// settings.
245    pub fn with_name(
246        name: &str,
247        settings: &UserSettings,
248        base_ignores: Arc<GitIgnoreFile>,
249        conflict_marker_style: ConflictMarkerStyle,
250    ) -> Result<Self, MergeToolConfigError> {
251        let tool = DiffEditTool::get_tool_config(settings, name)?
252            .unwrap_or_else(|| DiffEditTool::external(ExternalMergeTool::with_program(name)));
253        Self::new_inner(name, tool, settings, base_ignores, conflict_marker_style)
254    }
255
256    /// Loads the default diff editor from the settings.
257    pub fn from_settings(
258        ui: &Ui,
259        settings: &UserSettings,
260        base_ignores: Arc<GitIgnoreFile>,
261        conflict_marker_style: ConflictMarkerStyle,
262    ) -> Result<Self, MergeToolConfigError> {
263        let args = editor_args_from_settings(ui, settings, "ui.diff-editor")?;
264        let tool = if let Some(name) = args.as_str() {
265            DiffEditTool::get_tool_config(settings, name)?
266        } else {
267            None
268        }
269        .unwrap_or_else(|| DiffEditTool::external(ExternalMergeTool::with_edit_args(&args)));
270        Self::new_inner(&args, tool, settings, base_ignores, conflict_marker_style)
271    }
272
273    fn new_inner(
274        name: impl ToString,
275        tool: DiffEditTool,
276        settings: &UserSettings,
277        base_ignores: Arc<GitIgnoreFile>,
278        conflict_marker_style: ConflictMarkerStyle,
279    ) -> Result<Self, MergeToolConfigError> {
280        if let DiffEditTool::External(mergetool) = &tool
281            && mergetool.edit_args.is_empty()
282        {
283            return Err(MergeToolConfigError::EditArgsNotConfigured {
284                tool_name: name.to_string(),
285            });
286        }
287        Ok(Self {
288            tool,
289            base_ignores,
290            use_instructions: settings.get_bool("ui.diff-instructions")?,
291            conflict_marker_style,
292        })
293    }
294
295    /// Starts a diff editor on the two directories.
296    pub async fn edit(
297        &self,
298        trees: Diff<&MergedTree>,
299        matcher: &dyn Matcher,
300        format_instructions: impl FnOnce() -> String,
301    ) -> Result<MergedTree, DiffEditError> {
302        match &self.tool {
303            DiffEditTool::Builtin => {
304                Ok(
305                    edit_diff_builtin(trees, matcher, self.conflict_marker_style)
306                        .await
307                        .map_err(Box::new)?,
308                )
309            }
310            DiffEditTool::External(editor) => {
311                let instructions = self.use_instructions.then(format_instructions);
312                edit_diff_external(
313                    editor,
314                    trees,
315                    matcher,
316                    instructions.as_deref(),
317                    self.base_ignores.clone(),
318                    self.conflict_marker_style,
319                )
320                .await
321            }
322        }
323    }
324}
325
326/// A file to be merged by a merge tool.
327struct MergeToolFile {
328    repo_path: RepoPathBuf,
329    conflict: MergedTreeValue,
330    file: MaterializedFileConflictValue,
331}
332
333impl MergeToolFile {
334    async fn from_tree_and_path(
335        tree: &MergedTree,
336        repo_path: &RepoPath,
337    ) -> Result<Self, ConflictResolveError> {
338        let conflict = match tree.path_value(repo_path).await?.into_resolved() {
339            Err(conflict) => conflict,
340            Ok(Some(_)) => return Err(ConflictResolveError::NotAConflict(repo_path.to_owned())),
341            Ok(None) => return Err(ConflictResolveError::PathNotFound(repo_path.to_owned())),
342        };
343        let file =
344            try_materialize_file_conflict_value(tree.store(), repo_path, &conflict, tree.labels())
345                .await?
346                .ok_or_else(|| ConflictResolveError::NotNormalFiles {
347                    path: repo_path.to_owned(),
348                    summary: conflict.describe(tree.labels()),
349                })?;
350        // We only support conflicts with 2 sides (3-way conflicts)
351        if file.ids.num_sides() > 2 {
352            return Err(ConflictResolveError::ConflictTooComplicated {
353                path: repo_path.to_owned(),
354                sides: file.ids.num_sides(),
355            });
356        }
357        if file.executable.is_none() {
358            return Err(ConflictResolveError::ExecutableConflict {
359                path: repo_path.to_owned(),
360                summary: conflict.describe(tree.labels()),
361            });
362        }
363        Ok(Self {
364            repo_path: repo_path.to_owned(),
365            conflict,
366            file,
367        })
368    }
369}
370
371/// Configured 3-way merge editor.
372#[derive(Clone, Debug)]
373pub struct MergeEditor {
374    tool: MergeTool,
375    path_converter: RepoPathUiConverter,
376    conflict_marker_style: ConflictMarkerStyle,
377}
378
379impl MergeEditor {
380    /// Creates 3-way merge editor of the given name, and loads parameters from
381    /// the settings.
382    pub fn with_name(
383        name: &str,
384        settings: &UserSettings,
385        path_converter: RepoPathUiConverter,
386        conflict_marker_style: ConflictMarkerStyle,
387    ) -> Result<Self, MergeToolConfigError> {
388        let tool = MergeTool::get_tool_config(settings, name)?
389            .unwrap_or_else(|| MergeTool::external(ExternalMergeTool::with_program(name)));
390        Self::new_inner(name, tool, path_converter, conflict_marker_style)
391    }
392
393    /// Loads the default 3-way merge editor from the settings.
394    pub fn from_settings(
395        ui: &Ui,
396        settings: &UserSettings,
397        path_converter: RepoPathUiConverter,
398        conflict_marker_style: ConflictMarkerStyle,
399    ) -> Result<Self, MergeToolConfigError> {
400        let args = editor_args_from_settings(ui, settings, "ui.merge-editor")?;
401        let tool = if let Some(name) = args.as_str() {
402            MergeTool::get_tool_config(settings, name)?
403        } else {
404            None
405        }
406        .unwrap_or_else(|| MergeTool::external(ExternalMergeTool::with_merge_args(&args)));
407        Self::new_inner(&args, tool, path_converter, conflict_marker_style)
408    }
409
410    fn new_inner(
411        name: impl ToString,
412        tool: MergeTool,
413        path_converter: RepoPathUiConverter,
414        conflict_marker_style: ConflictMarkerStyle,
415    ) -> Result<Self, MergeToolConfigError> {
416        if let MergeTool::External(mergetool) = &tool
417            && mergetool.merge_args.is_empty()
418        {
419            return Err(MergeToolConfigError::MergeArgsNotConfigured {
420                tool_name: name.to_string(),
421            });
422        }
423        Ok(Self {
424            tool,
425            path_converter,
426            conflict_marker_style,
427        })
428    }
429
430    /// Starts a merge editor for the specified files.
431    pub async fn edit_files(
432        &self,
433        ui: &Ui,
434        tree: &MergedTree,
435        repo_paths: &[&RepoPath],
436    ) -> Result<(MergedTree, Option<MergeToolPartialResolutionError>), ConflictResolveError> {
437        let merge_tool_files: Vec<MergeToolFile> = try_join_all(
438            repo_paths
439                .iter()
440                .map(|&repo_path| MergeToolFile::from_tree_and_path(tree, repo_path)),
441        )
442        .await?;
443
444        match &self.tool {
445            MergeTool::Builtin => {
446                let tree = edit_merge_builtin(tree, &merge_tool_files)
447                    .await
448                    .map_err(Box::new)?;
449                Ok((tree, None))
450            }
451            MergeTool::Ours => {
452                let tree = pick_conflict_side(tree, &merge_tool_files, 0).await?;
453                Ok((tree, None))
454            }
455            MergeTool::Theirs => {
456                let tree = pick_conflict_side(tree, &merge_tool_files, 1).await?;
457                Ok((tree, None))
458            }
459            MergeTool::External(editor) => {
460                external::run_mergetool_external(
461                    ui,
462                    &self.path_converter,
463                    editor,
464                    tree,
465                    &merge_tool_files,
466                    self.conflict_marker_style,
467                )
468                .await
469            }
470        }
471    }
472}
473
474async fn pick_conflict_side(
475    tree: &MergedTree,
476    merge_tool_files: &[MergeToolFile],
477    add_index: usize,
478) -> Result<MergedTree, BackendError> {
479    let mut tree_builder = MergedTreeBuilder::new(tree.clone());
480    for merge_tool_file in merge_tool_files {
481        // We use file IDs here to match the logic for the other external merge tools.
482        // This ensures that the behavior is consistent.
483        let file = &merge_tool_file.file;
484        let file_id = file.ids.get_add(add_index).unwrap();
485        let executable = file.executable.expect("should have been resolved");
486        let new_tree_value = Merge::resolved(file_id.clone().map(|id| TreeValue::File {
487            id,
488            executable,
489            copy_id: CopyId::placeholder(),
490        }));
491        tree_builder.set_or_remove(merge_tool_file.repo_path.clone(), new_tree_value);
492    }
493    tree_builder.write_tree().await
494}
495
496#[cfg(test)]
497mod tests {
498    use jj_lib::config::ConfigLayer;
499    use jj_lib::config::ConfigSource;
500    use jj_lib::config::StackedConfig;
501
502    use super::*;
503
504    fn config_from_string(text: &str) -> StackedConfig {
505        let mut config = StackedConfig::with_defaults();
506        // Load defaults to test the default args lookup
507        config.extend_layers(crate::config::default_config_layers());
508        config.add_layer(ConfigLayer::parse(ConfigSource::User, text).unwrap());
509        config
510    }
511
512    #[test]
513    fn test_get_diff_editor_with_name() {
514        let get = |name, config_text| {
515            let config = config_from_string(config_text);
516            let settings = UserSettings::from_config(config).unwrap();
517            DiffEditor::with_name(
518                name,
519                &settings,
520                GitIgnoreFile::empty(),
521                ConflictMarkerStyle::Diff,
522            )
523            .map(|editor| editor.tool)
524        };
525
526        insta::assert_debug_snapshot!(get(":builtin", "").unwrap(), @"Builtin");
527
528        // Just program name, edit_args are filled by default
529        insta::assert_debug_snapshot!(get("my diff", "").unwrap(), @r#"
530        External(
531            ExternalMergeTool {
532                program: "my diff",
533                diff_args: [
534                    "$left",
535                    "$right",
536                ],
537                diff_expected_exit_codes: [
538                    0,
539                ],
540                diff_invocation_mode: Dir,
541                diff_do_chdir: true,
542                edit_args: [
543                    "$left",
544                    "$right",
545                ],
546                edit_invocation_mode: Dir,
547                merge_args: [],
548                merge_conflict_exit_codes: [],
549                merge_tool_edits_conflict_markers: false,
550                conflict_marker_style: None,
551            },
552        )
553        "#);
554
555        // Pick from merge-tools
556        insta::assert_debug_snapshot!(get(
557            "foo bar", r#"
558        [merge-tools."foo bar"]
559        edit-args = ["--edit", "args", "$left", "$right"]
560        "#,
561        ).unwrap(), @r#"
562        External(
563            ExternalMergeTool {
564                program: "foo bar",
565                diff_args: [
566                    "$left",
567                    "$right",
568                ],
569                diff_expected_exit_codes: [
570                    0,
571                ],
572                diff_invocation_mode: Dir,
573                diff_do_chdir: true,
574                edit_args: [
575                    "--edit",
576                    "args",
577                    "$left",
578                    "$right",
579                ],
580                edit_invocation_mode: Dir,
581                merge_args: [],
582                merge_conflict_exit_codes: [],
583                merge_tool_edits_conflict_markers: false,
584                conflict_marker_style: None,
585            },
586        )
587        "#);
588    }
589
590    #[test]
591    fn test_get_diff_editor_from_settings() {
592        let get = |text| {
593            let config = config_from_string(text);
594            let ui = Ui::with_config(&config).unwrap();
595            let settings = UserSettings::from_config(config).unwrap();
596            DiffEditor::from_settings(
597                &ui,
598                &settings,
599                GitIgnoreFile::empty(),
600                ConflictMarkerStyle::Diff,
601            )
602            .map(|editor| editor.tool)
603        };
604
605        // Default
606        insta::assert_debug_snapshot!(get("").unwrap(), @"Builtin");
607
608        // Just program name, edit_args are filled by default
609        insta::assert_debug_snapshot!(get(r#"ui.diff-editor = "my-diff""#).unwrap(), @r#"
610        External(
611            ExternalMergeTool {
612                program: "my-diff",
613                diff_args: [
614                    "$left",
615                    "$right",
616                ],
617                diff_expected_exit_codes: [
618                    0,
619                ],
620                diff_invocation_mode: Dir,
621                diff_do_chdir: true,
622                edit_args: [
623                    "$left",
624                    "$right",
625                ],
626                edit_invocation_mode: Dir,
627                merge_args: [],
628                merge_conflict_exit_codes: [],
629                merge_tool_edits_conflict_markers: false,
630                conflict_marker_style: None,
631            },
632        )
633        "#);
634
635        // String args (with interpolation variables)
636        insta::assert_debug_snapshot!(
637            get(r#"ui.diff-editor = "my-diff -l $left -r $right""#).unwrap(), @r#"
638        External(
639            ExternalMergeTool {
640                program: "my-diff",
641                diff_args: [
642                    "$left",
643                    "$right",
644                ],
645                diff_expected_exit_codes: [
646                    0,
647                ],
648                diff_invocation_mode: Dir,
649                diff_do_chdir: true,
650                edit_args: [
651                    "-l",
652                    "$left",
653                    "-r",
654                    "$right",
655                ],
656                edit_invocation_mode: Dir,
657                merge_args: [],
658                merge_conflict_exit_codes: [],
659                merge_tool_edits_conflict_markers: false,
660                conflict_marker_style: None,
661            },
662        )
663        "#);
664
665        // List args (with interpolation variables)
666        insta::assert_debug_snapshot!(
667            get(r#"ui.diff-editor = ["my-diff", "--diff", "$left", "$right"]"#).unwrap(), @r#"
668        External(
669            ExternalMergeTool {
670                program: "my-diff",
671                diff_args: [
672                    "$left",
673                    "$right",
674                ],
675                diff_expected_exit_codes: [
676                    0,
677                ],
678                diff_invocation_mode: Dir,
679                diff_do_chdir: true,
680                edit_args: [
681                    "--diff",
682                    "$left",
683                    "$right",
684                ],
685                edit_invocation_mode: Dir,
686                merge_args: [],
687                merge_conflict_exit_codes: [],
688                merge_tool_edits_conflict_markers: false,
689                conflict_marker_style: None,
690            },
691        )
692        "#);
693
694        // Pick from merge-tools
695        insta::assert_debug_snapshot!(get(
696        r#"
697        ui.diff-editor = "foo bar"
698        [merge-tools."foo bar"]
699        edit-args = ["--edit", "args", "$left", "$right"]
700        diff-args = []  # Should not cause an error, since we're getting the diff *editor*
701        "#,
702        ).unwrap(), @r#"
703        External(
704            ExternalMergeTool {
705                program: "foo bar",
706                diff_args: [],
707                diff_expected_exit_codes: [
708                    0,
709                ],
710                diff_invocation_mode: Dir,
711                diff_do_chdir: true,
712                edit_args: [
713                    "--edit",
714                    "args",
715                    "$left",
716                    "$right",
717                ],
718                edit_invocation_mode: Dir,
719                merge_args: [],
720                merge_conflict_exit_codes: [],
721                merge_tool_edits_conflict_markers: false,
722                conflict_marker_style: None,
723            },
724        )
725        "#);
726
727        // Pick from merge-tools, but no edit-args specified
728        insta::assert_debug_snapshot!(get(
729        r#"
730        ui.diff-editor = "my-diff"
731        [merge-tools.my-diff]
732        program = "MyDiff"
733        "#,
734        ).unwrap(), @r#"
735        External(
736            ExternalMergeTool {
737                program: "MyDiff",
738                diff_args: [
739                    "$left",
740                    "$right",
741                ],
742                diff_expected_exit_codes: [
743                    0,
744                ],
745                diff_invocation_mode: Dir,
746                diff_do_chdir: true,
747                edit_args: [
748                    "$left",
749                    "$right",
750                ],
751                edit_invocation_mode: Dir,
752                merge_args: [],
753                merge_conflict_exit_codes: [],
754                merge_tool_edits_conflict_markers: false,
755                conflict_marker_style: None,
756            },
757        )
758        "#);
759
760        // List args should never be a merge-tools key, edit_args are filled by default
761        insta::assert_debug_snapshot!(get(r#"ui.diff-editor = ["meld"]"#).unwrap(), @r#"
762        External(
763            ExternalMergeTool {
764                program: "meld",
765                diff_args: [
766                    "$left",
767                    "$right",
768                ],
769                diff_expected_exit_codes: [
770                    0,
771                ],
772                diff_invocation_mode: Dir,
773                diff_do_chdir: true,
774                edit_args: [
775                    "$left",
776                    "$right",
777                ],
778                edit_invocation_mode: Dir,
779                merge_args: [],
780                merge_conflict_exit_codes: [],
781                merge_tool_edits_conflict_markers: false,
782                conflict_marker_style: None,
783            },
784        )
785        "#);
786
787        // Invalid type
788        assert!(get(r#"ui.diff-editor.k = 0"#).is_err());
789
790        // Explicitly empty edit-args cause an error
791        insta::assert_debug_snapshot!(get(
792        r#"
793        ui.diff-editor = "my-diff"
794        [merge-tools.my-diff]
795        program = "MyDiff"
796        edit-args = []
797        "#,
798        ), @r#"
799        Err(
800            EditArgsNotConfigured {
801                tool_name: "my-diff",
802            },
803        )
804        "#);
805    }
806
807    #[test]
808    fn test_get_merge_editor_with_name() {
809        let get = |name, config_text| {
810            let config = config_from_string(config_text);
811            let settings = UserSettings::from_config(config).unwrap();
812            let path_converter = RepoPathUiConverter::Fs {
813                cwd: "".into(),
814                base: "".into(),
815            };
816            MergeEditor::with_name(name, &settings, path_converter, ConflictMarkerStyle::Diff)
817                .map(|editor| editor.tool)
818        };
819
820        insta::assert_debug_snapshot!(get(":builtin", "").unwrap(), @"Builtin");
821
822        // Just program name
823        insta::assert_debug_snapshot!(get("my diff", "").unwrap_err(), @r#"
824        MergeArgsNotConfigured {
825            tool_name: "my diff",
826        }
827        "#);
828
829        // Pick from merge-tools
830        insta::assert_debug_snapshot!(get(
831            "foo bar", r#"
832        [merge-tools."foo bar"]
833        merge-args = ["$base", "$left", "$right", "$output"]
834        "#,
835        ).unwrap(), @r#"
836        External(
837            ExternalMergeTool {
838                program: "foo bar",
839                diff_args: [
840                    "$left",
841                    "$right",
842                ],
843                diff_expected_exit_codes: [
844                    0,
845                ],
846                diff_invocation_mode: Dir,
847                diff_do_chdir: true,
848                edit_args: [
849                    "$left",
850                    "$right",
851                ],
852                edit_invocation_mode: Dir,
853                merge_args: [
854                    "$base",
855                    "$left",
856                    "$right",
857                    "$output",
858                ],
859                merge_conflict_exit_codes: [],
860                merge_tool_edits_conflict_markers: false,
861                conflict_marker_style: None,
862            },
863        )
864        "#);
865    }
866
867    #[test]
868    fn test_get_merge_editor_from_settings() {
869        let get = |text| {
870            let config = config_from_string(text);
871            let ui = Ui::with_config(&config).unwrap();
872            let settings = UserSettings::from_config(config).unwrap();
873            let path_converter = RepoPathUiConverter::Fs {
874                cwd: "".into(),
875                base: "".into(),
876            };
877            MergeEditor::from_settings(&ui, &settings, path_converter, ConflictMarkerStyle::Diff)
878                .map(|editor| editor.tool)
879        };
880
881        // Default
882        insta::assert_debug_snapshot!(get("").unwrap(), @"Builtin");
883
884        // Just program name
885        insta::assert_debug_snapshot!(get(r#"ui.merge-editor = "my-merge""#).unwrap_err(), @r#"
886        MergeArgsNotConfigured {
887            tool_name: "my-merge",
888        }
889        "#);
890
891        // String args
892        insta::assert_debug_snapshot!(
893            get(r#"ui.merge-editor = "my-merge $left $base $right $output""#).unwrap(), @r#"
894        External(
895            ExternalMergeTool {
896                program: "my-merge",
897                diff_args: [
898                    "$left",
899                    "$right",
900                ],
901                diff_expected_exit_codes: [
902                    0,
903                ],
904                diff_invocation_mode: Dir,
905                diff_do_chdir: true,
906                edit_args: [
907                    "$left",
908                    "$right",
909                ],
910                edit_invocation_mode: Dir,
911                merge_args: [
912                    "$left",
913                    "$base",
914                    "$right",
915                    "$output",
916                ],
917                merge_conflict_exit_codes: [],
918                merge_tool_edits_conflict_markers: false,
919                conflict_marker_style: None,
920            },
921        )
922        "#);
923
924        // List args
925        insta::assert_debug_snapshot!(
926            get(
927                r#"ui.merge-editor = ["my-merge", "$left", "$base", "$right", "$output"]"#,
928            ).unwrap(), @r#"
929        External(
930            ExternalMergeTool {
931                program: "my-merge",
932                diff_args: [
933                    "$left",
934                    "$right",
935                ],
936                diff_expected_exit_codes: [
937                    0,
938                ],
939                diff_invocation_mode: Dir,
940                diff_do_chdir: true,
941                edit_args: [
942                    "$left",
943                    "$right",
944                ],
945                edit_invocation_mode: Dir,
946                merge_args: [
947                    "$left",
948                    "$base",
949                    "$right",
950                    "$output",
951                ],
952                merge_conflict_exit_codes: [],
953                merge_tool_edits_conflict_markers: false,
954                conflict_marker_style: None,
955            },
956        )
957        "#);
958
959        // Pick from merge-tools
960        insta::assert_debug_snapshot!(get(
961        r#"
962        ui.merge-editor = "foo bar"
963        [merge-tools."foo bar"]
964        merge-args = ["$base", "$left", "$right", "$output"]
965        "#,
966        ).unwrap(), @r#"
967        External(
968            ExternalMergeTool {
969                program: "foo bar",
970                diff_args: [
971                    "$left",
972                    "$right",
973                ],
974                diff_expected_exit_codes: [
975                    0,
976                ],
977                diff_invocation_mode: Dir,
978                diff_do_chdir: true,
979                edit_args: [
980                    "$left",
981                    "$right",
982                ],
983                edit_invocation_mode: Dir,
984                merge_args: [
985                    "$base",
986                    "$left",
987                    "$right",
988                    "$output",
989                ],
990                merge_conflict_exit_codes: [],
991                merge_tool_edits_conflict_markers: false,
992                conflict_marker_style: None,
993            },
994        )
995        "#);
996
997        // List args should never be a merge-tools key
998        insta::assert_debug_snapshot!(
999            get(r#"ui.merge-editor = ["meld"]"#).unwrap_err(), @r#"
1000        MergeArgsNotConfigured {
1001            tool_name: "meld",
1002        }
1003        "#);
1004
1005        // Invalid type
1006        assert!(get(r#"ui.merge-editor.k = 0"#).is_err());
1007    }
1008}