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