Skip to main content

jj_cli/merge_tools/
mod.rs

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