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::CopyId;
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::ConflictMarkerStyle;
29use jj_lib::conflicts::MaterializedFileConflictValue;
30use jj_lib::conflicts::try_materialize_file_conflict_value;
31use jj_lib::gitignore::GitIgnoreFile;
32use jj_lib::matchers::Matcher;
33use jj_lib::merge::Diff;
34use jj_lib::merge::Merge;
35use jj_lib::merge::MergedTreeValue;
36use jj_lib::merged_tree::MergedTree;
37use jj_lib::merged_tree::MergedTreeBuilder;
38use jj_lib::repo_path::InvalidRepoPathError;
39use jj_lib::repo_path::RepoPath;
40use jj_lib::repo_path::RepoPathBuf;
41use jj_lib::repo_path::RepoPathUiConverter;
42use jj_lib::settings::UserSettings;
43use jj_lib::working_copy::SnapshotError;
44use pollster::FutureExt as _;
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 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                        .map_err(Box::new)?,
307                )
308            }
309            DiffEditTool::External(editor) => {
310                let instructions = self.use_instructions.then(format_instructions);
311                edit_diff_external(
312                    editor,
313                    trees,
314                    matcher,
315                    instructions.as_deref(),
316                    self.base_ignores.clone(),
317                    self.conflict_marker_style,
318                )
319            }
320        }
321    }
322}
323
324/// A file to be merged by a merge tool.
325struct MergeToolFile {
326    repo_path: RepoPathBuf,
327    conflict: MergedTreeValue,
328    file: MaterializedFileConflictValue,
329}
330
331impl MergeToolFile {
332    fn from_tree_and_path(
333        tree: &MergedTree,
334        repo_path: &RepoPath,
335    ) -> Result<Self, ConflictResolveError> {
336        let conflict = match tree.path_value(repo_path)?.into_resolved() {
337            Err(conflict) => conflict,
338            Ok(Some(_)) => return Err(ConflictResolveError::NotAConflict(repo_path.to_owned())),
339            Ok(None) => return Err(ConflictResolveError::PathNotFound(repo_path.to_owned())),
340        };
341        let file =
342            try_materialize_file_conflict_value(tree.store(), repo_path, &conflict, tree.labels())
343                .block_on()?
344                .ok_or_else(|| ConflictResolveError::NotNormalFiles {
345                    path: repo_path.to_owned(),
346                    summary: conflict.describe(tree.labels()),
347                })?;
348        // We only support conflicts with 2 sides (3-way conflicts)
349        if file.ids.num_sides() > 2 {
350            return Err(ConflictResolveError::ConflictTooComplicated {
351                path: repo_path.to_owned(),
352                sides: file.ids.num_sides(),
353            });
354        };
355        if file.executable.is_none() {
356            return Err(ConflictResolveError::ExecutableConflict {
357                path: repo_path.to_owned(),
358                summary: conflict.describe(tree.labels()),
359            });
360        }
361        Ok(Self {
362            repo_path: repo_path.to_owned(),
363            conflict,
364            file,
365        })
366    }
367}
368
369/// Configured 3-way merge editor.
370#[derive(Clone, Debug)]
371pub struct MergeEditor {
372    tool: MergeTool,
373    path_converter: RepoPathUiConverter,
374    conflict_marker_style: ConflictMarkerStyle,
375}
376
377impl MergeEditor {
378    /// Creates 3-way merge editor of the given name, and loads parameters from
379    /// the settings.
380    pub fn with_name(
381        name: &str,
382        settings: &UserSettings,
383        path_converter: RepoPathUiConverter,
384        conflict_marker_style: ConflictMarkerStyle,
385    ) -> Result<Self, MergeToolConfigError> {
386        let tool = MergeTool::get_tool_config(settings, name)?
387            .unwrap_or_else(|| MergeTool::external(ExternalMergeTool::with_program(name)));
388        Self::new_inner(name, tool, path_converter, conflict_marker_style)
389    }
390
391    /// Loads the default 3-way merge editor from the settings.
392    pub fn from_settings(
393        ui: &Ui,
394        settings: &UserSettings,
395        path_converter: RepoPathUiConverter,
396        conflict_marker_style: ConflictMarkerStyle,
397    ) -> Result<Self, MergeToolConfigError> {
398        let args = editor_args_from_settings(ui, settings, "ui.merge-editor")?;
399        let tool = if let Some(name) = args.as_str() {
400            MergeTool::get_tool_config(settings, name)?
401        } else {
402            None
403        }
404        .unwrap_or_else(|| MergeTool::external(ExternalMergeTool::with_merge_args(&args)));
405        Self::new_inner(&args, tool, path_converter, conflict_marker_style)
406    }
407
408    fn new_inner(
409        name: impl ToString,
410        tool: MergeTool,
411        path_converter: RepoPathUiConverter,
412        conflict_marker_style: ConflictMarkerStyle,
413    ) -> Result<Self, MergeToolConfigError> {
414        if let MergeTool::External(mergetool) = &tool
415            && mergetool.merge_args.is_empty()
416        {
417            return Err(MergeToolConfigError::MergeArgsNotConfigured {
418                tool_name: name.to_string(),
419            });
420        }
421        Ok(Self {
422            tool,
423            path_converter,
424            conflict_marker_style,
425        })
426    }
427
428    /// Starts a merge editor for the specified files.
429    pub fn edit_files(
430        &self,
431        ui: &Ui,
432        tree: &MergedTree,
433        repo_paths: &[&RepoPath],
434    ) -> Result<(MergedTree, Option<MergeToolPartialResolutionError>), ConflictResolveError> {
435        let merge_tool_files: Vec<MergeToolFile> = repo_paths
436            .iter()
437            .map(|&repo_path| MergeToolFile::from_tree_and_path(tree, repo_path))
438            .try_collect()?;
439
440        match &self.tool {
441            MergeTool::Builtin => {
442                let tree = edit_merge_builtin(tree, &merge_tool_files).map_err(Box::new)?;
443                Ok((tree, None))
444            }
445            MergeTool::Ours => {
446                let tree = pick_conflict_side(tree, &merge_tool_files, 0)?;
447                Ok((tree, None))
448            }
449            MergeTool::Theirs => {
450                let tree = pick_conflict_side(tree, &merge_tool_files, 1)?;
451                Ok((tree, None))
452            }
453            MergeTool::External(editor) => external::run_mergetool_external(
454                ui,
455                &self.path_converter,
456                editor,
457                tree,
458                &merge_tool_files,
459                self.conflict_marker_style,
460            ),
461        }
462    }
463}
464
465fn pick_conflict_side(
466    tree: &MergedTree,
467    merge_tool_files: &[MergeToolFile],
468    add_index: usize,
469) -> Result<MergedTree, BackendError> {
470    let mut tree_builder = MergedTreeBuilder::new(tree.clone());
471    for merge_tool_file in merge_tool_files {
472        // We use file IDs here to match the logic for the other external merge tools.
473        // This ensures that the behavior is consistent.
474        let file = &merge_tool_file.file;
475        let file_id = file.ids.get_add(add_index).unwrap();
476        let executable = file.executable.expect("should have been resolved");
477        let new_tree_value = Merge::resolved(file_id.clone().map(|id| TreeValue::File {
478            id,
479            executable,
480            copy_id: CopyId::placeholder(),
481        }));
482        tree_builder.set_or_remove(merge_tool_file.repo_path.clone(), new_tree_value);
483    }
484    tree_builder.write_tree()
485}
486
487#[cfg(test)]
488mod tests {
489    use jj_lib::config::ConfigLayer;
490    use jj_lib::config::ConfigSource;
491    use jj_lib::config::StackedConfig;
492
493    use super::*;
494
495    fn config_from_string(text: &str) -> StackedConfig {
496        let mut config = StackedConfig::with_defaults();
497        // Load defaults to test the default args lookup
498        config.extend_layers(crate::config::default_config_layers());
499        config.add_layer(ConfigLayer::parse(ConfigSource::User, text).unwrap());
500        config
501    }
502
503    #[test]
504    fn test_get_diff_editor_with_name() {
505        let get = |name, config_text| {
506            let config = config_from_string(config_text);
507            let settings = UserSettings::from_config(config).unwrap();
508            DiffEditor::with_name(
509                name,
510                &settings,
511                GitIgnoreFile::empty(),
512                ConflictMarkerStyle::Diff,
513            )
514            .map(|editor| editor.tool)
515        };
516
517        insta::assert_debug_snapshot!(get(":builtin", "").unwrap(), @"Builtin");
518
519        // Just program name, edit_args are filled by default
520        insta::assert_debug_snapshot!(get("my diff", "").unwrap(), @r#"
521        External(
522            ExternalMergeTool {
523                program: "my diff",
524                diff_args: [
525                    "$left",
526                    "$right",
527                ],
528                diff_expected_exit_codes: [
529                    0,
530                ],
531                diff_invocation_mode: Dir,
532                diff_do_chdir: true,
533                edit_args: [
534                    "$left",
535                    "$right",
536                ],
537                merge_args: [],
538                merge_conflict_exit_codes: [],
539                merge_tool_edits_conflict_markers: false,
540                conflict_marker_style: None,
541            },
542        )
543        "#);
544
545        // Pick from merge-tools
546        insta::assert_debug_snapshot!(get(
547            "foo bar", r#"
548        [merge-tools."foo bar"]
549        edit-args = ["--edit", "args", "$left", "$right"]
550        "#,
551        ).unwrap(), @r#"
552        External(
553            ExternalMergeTool {
554                program: "foo bar",
555                diff_args: [
556                    "$left",
557                    "$right",
558                ],
559                diff_expected_exit_codes: [
560                    0,
561                ],
562                diff_invocation_mode: Dir,
563                diff_do_chdir: true,
564                edit_args: [
565                    "--edit",
566                    "args",
567                    "$left",
568                    "$right",
569                ],
570                merge_args: [],
571                merge_conflict_exit_codes: [],
572                merge_tool_edits_conflict_markers: false,
573                conflict_marker_style: None,
574            },
575        )
576        "#);
577    }
578
579    #[test]
580    fn test_get_diff_editor_from_settings() {
581        let get = |text| {
582            let config = config_from_string(text);
583            let ui = Ui::with_config(&config).unwrap();
584            let settings = UserSettings::from_config(config).unwrap();
585            DiffEditor::from_settings(
586                &ui,
587                &settings,
588                GitIgnoreFile::empty(),
589                ConflictMarkerStyle::Diff,
590            )
591            .map(|editor| editor.tool)
592        };
593
594        // Default
595        insta::assert_debug_snapshot!(get("").unwrap(), @"Builtin");
596
597        // Just program name, edit_args are filled by default
598        insta::assert_debug_snapshot!(get(r#"ui.diff-editor = "my-diff""#).unwrap(), @r#"
599        External(
600            ExternalMergeTool {
601                program: "my-diff",
602                diff_args: [
603                    "$left",
604                    "$right",
605                ],
606                diff_expected_exit_codes: [
607                    0,
608                ],
609                diff_invocation_mode: Dir,
610                diff_do_chdir: true,
611                edit_args: [
612                    "$left",
613                    "$right",
614                ],
615                merge_args: [],
616                merge_conflict_exit_codes: [],
617                merge_tool_edits_conflict_markers: false,
618                conflict_marker_style: None,
619            },
620        )
621        "#);
622
623        // String args (with interpolation variables)
624        insta::assert_debug_snapshot!(
625            get(r#"ui.diff-editor = "my-diff -l $left -r $right""#).unwrap(), @r#"
626        External(
627            ExternalMergeTool {
628                program: "my-diff",
629                diff_args: [
630                    "$left",
631                    "$right",
632                ],
633                diff_expected_exit_codes: [
634                    0,
635                ],
636                diff_invocation_mode: Dir,
637                diff_do_chdir: true,
638                edit_args: [
639                    "-l",
640                    "$left",
641                    "-r",
642                    "$right",
643                ],
644                merge_args: [],
645                merge_conflict_exit_codes: [],
646                merge_tool_edits_conflict_markers: false,
647                conflict_marker_style: None,
648            },
649        )
650        "#);
651
652        // List args (with interpolation variables)
653        insta::assert_debug_snapshot!(
654            get(r#"ui.diff-editor = ["my-diff", "--diff", "$left", "$right"]"#).unwrap(), @r#"
655        External(
656            ExternalMergeTool {
657                program: "my-diff",
658                diff_args: [
659                    "$left",
660                    "$right",
661                ],
662                diff_expected_exit_codes: [
663                    0,
664                ],
665                diff_invocation_mode: Dir,
666                diff_do_chdir: true,
667                edit_args: [
668                    "--diff",
669                    "$left",
670                    "$right",
671                ],
672                merge_args: [],
673                merge_conflict_exit_codes: [],
674                merge_tool_edits_conflict_markers: false,
675                conflict_marker_style: None,
676            },
677        )
678        "#);
679
680        // Pick from merge-tools
681        insta::assert_debug_snapshot!(get(
682        r#"
683        ui.diff-editor = "foo bar"
684        [merge-tools."foo bar"]
685        edit-args = ["--edit", "args", "$left", "$right"]
686        diff-args = []  # Should not cause an error, since we're getting the diff *editor*
687        "#,
688        ).unwrap(), @r#"
689        External(
690            ExternalMergeTool {
691                program: "foo bar",
692                diff_args: [],
693                diff_expected_exit_codes: [
694                    0,
695                ],
696                diff_invocation_mode: Dir,
697                diff_do_chdir: true,
698                edit_args: [
699                    "--edit",
700                    "args",
701                    "$left",
702                    "$right",
703                ],
704                merge_args: [],
705                merge_conflict_exit_codes: [],
706                merge_tool_edits_conflict_markers: false,
707                conflict_marker_style: None,
708            },
709        )
710        "#);
711
712        // Pick from merge-tools, but no edit-args specified
713        insta::assert_debug_snapshot!(get(
714        r#"
715        ui.diff-editor = "my-diff"
716        [merge-tools.my-diff]
717        program = "MyDiff"
718        "#,
719        ).unwrap(), @r#"
720        External(
721            ExternalMergeTool {
722                program: "MyDiff",
723                diff_args: [
724                    "$left",
725                    "$right",
726                ],
727                diff_expected_exit_codes: [
728                    0,
729                ],
730                diff_invocation_mode: Dir,
731                diff_do_chdir: true,
732                edit_args: [
733                    "$left",
734                    "$right",
735                ],
736                merge_args: [],
737                merge_conflict_exit_codes: [],
738                merge_tool_edits_conflict_markers: false,
739                conflict_marker_style: None,
740            },
741        )
742        "#);
743
744        // List args should never be a merge-tools key, edit_args are filled by default
745        insta::assert_debug_snapshot!(get(r#"ui.diff-editor = ["meld"]"#).unwrap(), @r#"
746        External(
747            ExternalMergeTool {
748                program: "meld",
749                diff_args: [
750                    "$left",
751                    "$right",
752                ],
753                diff_expected_exit_codes: [
754                    0,
755                ],
756                diff_invocation_mode: Dir,
757                diff_do_chdir: true,
758                edit_args: [
759                    "$left",
760                    "$right",
761                ],
762                merge_args: [],
763                merge_conflict_exit_codes: [],
764                merge_tool_edits_conflict_markers: false,
765                conflict_marker_style: None,
766            },
767        )
768        "#);
769
770        // Invalid type
771        assert!(get(r#"ui.diff-editor.k = 0"#).is_err());
772
773        // Explicitly empty edit-args cause an error
774        insta::assert_debug_snapshot!(get(
775        r#"
776        ui.diff-editor = "my-diff"
777        [merge-tools.my-diff]
778        program = "MyDiff"
779        edit-args = []
780        "#,
781        ), @r#"
782        Err(
783            EditArgsNotConfigured {
784                tool_name: "my-diff",
785            },
786        )
787        "#);
788    }
789
790    #[test]
791    fn test_get_merge_editor_with_name() {
792        let get = |name, config_text| {
793            let config = config_from_string(config_text);
794            let settings = UserSettings::from_config(config).unwrap();
795            let path_converter = RepoPathUiConverter::Fs {
796                cwd: "".into(),
797                base: "".into(),
798            };
799            MergeEditor::with_name(name, &settings, path_converter, ConflictMarkerStyle::Diff)
800                .map(|editor| editor.tool)
801        };
802
803        insta::assert_debug_snapshot!(get(":builtin", "").unwrap(), @"Builtin");
804
805        // Just program name
806        insta::assert_debug_snapshot!(get("my diff", "").unwrap_err(), @r#"
807        MergeArgsNotConfigured {
808            tool_name: "my diff",
809        }
810        "#);
811
812        // Pick from merge-tools
813        insta::assert_debug_snapshot!(get(
814            "foo bar", r#"
815        [merge-tools."foo bar"]
816        merge-args = ["$base", "$left", "$right", "$output"]
817        "#,
818        ).unwrap(), @r#"
819        External(
820            ExternalMergeTool {
821                program: "foo bar",
822                diff_args: [
823                    "$left",
824                    "$right",
825                ],
826                diff_expected_exit_codes: [
827                    0,
828                ],
829                diff_invocation_mode: Dir,
830                diff_do_chdir: true,
831                edit_args: [
832                    "$left",
833                    "$right",
834                ],
835                merge_args: [
836                    "$base",
837                    "$left",
838                    "$right",
839                    "$output",
840                ],
841                merge_conflict_exit_codes: [],
842                merge_tool_edits_conflict_markers: false,
843                conflict_marker_style: None,
844            },
845        )
846        "#);
847    }
848
849    #[test]
850    fn test_get_merge_editor_from_settings() {
851        let get = |text| {
852            let config = config_from_string(text);
853            let ui = Ui::with_config(&config).unwrap();
854            let settings = UserSettings::from_config(config).unwrap();
855            let path_converter = RepoPathUiConverter::Fs {
856                cwd: "".into(),
857                base: "".into(),
858            };
859            MergeEditor::from_settings(&ui, &settings, path_converter, ConflictMarkerStyle::Diff)
860                .map(|editor| editor.tool)
861        };
862
863        // Default
864        insta::assert_debug_snapshot!(get("").unwrap(), @"Builtin");
865
866        // Just program name
867        insta::assert_debug_snapshot!(get(r#"ui.merge-editor = "my-merge""#).unwrap_err(), @r#"
868        MergeArgsNotConfigured {
869            tool_name: "my-merge",
870        }
871        "#);
872
873        // String args
874        insta::assert_debug_snapshot!(
875            get(r#"ui.merge-editor = "my-merge $left $base $right $output""#).unwrap(), @r#"
876        External(
877            ExternalMergeTool {
878                program: "my-merge",
879                diff_args: [
880                    "$left",
881                    "$right",
882                ],
883                diff_expected_exit_codes: [
884                    0,
885                ],
886                diff_invocation_mode: Dir,
887                diff_do_chdir: true,
888                edit_args: [
889                    "$left",
890                    "$right",
891                ],
892                merge_args: [
893                    "$left",
894                    "$base",
895                    "$right",
896                    "$output",
897                ],
898                merge_conflict_exit_codes: [],
899                merge_tool_edits_conflict_markers: false,
900                conflict_marker_style: None,
901            },
902        )
903        "#);
904
905        // List args
906        insta::assert_debug_snapshot!(
907            get(
908                r#"ui.merge-editor = ["my-merge", "$left", "$base", "$right", "$output"]"#,
909            ).unwrap(), @r#"
910        External(
911            ExternalMergeTool {
912                program: "my-merge",
913                diff_args: [
914                    "$left",
915                    "$right",
916                ],
917                diff_expected_exit_codes: [
918                    0,
919                ],
920                diff_invocation_mode: Dir,
921                diff_do_chdir: true,
922                edit_args: [
923                    "$left",
924                    "$right",
925                ],
926                merge_args: [
927                    "$left",
928                    "$base",
929                    "$right",
930                    "$output",
931                ],
932                merge_conflict_exit_codes: [],
933                merge_tool_edits_conflict_markers: false,
934                conflict_marker_style: None,
935            },
936        )
937        "#);
938
939        // Pick from merge-tools
940        insta::assert_debug_snapshot!(get(
941        r#"
942        ui.merge-editor = "foo bar"
943        [merge-tools."foo bar"]
944        merge-args = ["$base", "$left", "$right", "$output"]
945        "#,
946        ).unwrap(), @r#"
947        External(
948            ExternalMergeTool {
949                program: "foo bar",
950                diff_args: [
951                    "$left",
952                    "$right",
953                ],
954                diff_expected_exit_codes: [
955                    0,
956                ],
957                diff_invocation_mode: Dir,
958                diff_do_chdir: true,
959                edit_args: [
960                    "$left",
961                    "$right",
962                ],
963                merge_args: [
964                    "$base",
965                    "$left",
966                    "$right",
967                    "$output",
968                ],
969                merge_conflict_exit_codes: [],
970                merge_tool_edits_conflict_markers: false,
971                conflict_marker_style: None,
972            },
973        )
974        "#);
975
976        // List args should never be a merge-tools key
977        insta::assert_debug_snapshot!(
978            get(r#"ui.merge-editor = ["meld"]"#).unwrap_err(), @r#"
979        MergeArgsNotConfigured {
980            tool_name: "meld",
981        }
982        "#);
983
984        // Invalid type
985        assert!(get(r#"ui.merge-editor.k = 0"#).is_err());
986    }
987}