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