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                edit_invocation_mode: Dir,
546                merge_args: [],
547                merge_conflict_exit_codes: [],
548                merge_tool_edits_conflict_markers: false,
549                conflict_marker_style: None,
550            },
551        )
552        "#);
553
554        // Pick from merge-tools
555        insta::assert_debug_snapshot!(get(
556            "foo bar", r#"
557        [merge-tools."foo bar"]
558        edit-args = ["--edit", "args", "$left", "$right"]
559        "#,
560        ).unwrap(), @r#"
561        External(
562            ExternalMergeTool {
563                program: "foo bar",
564                diff_args: [
565                    "$left",
566                    "$right",
567                ],
568                diff_expected_exit_codes: [
569                    0,
570                ],
571                diff_invocation_mode: Dir,
572                diff_do_chdir: true,
573                edit_args: [
574                    "--edit",
575                    "args",
576                    "$left",
577                    "$right",
578                ],
579                edit_invocation_mode: Dir,
580                merge_args: [],
581                merge_conflict_exit_codes: [],
582                merge_tool_edits_conflict_markers: false,
583                conflict_marker_style: None,
584            },
585        )
586        "#);
587    }
588
589    #[test]
590    fn test_get_diff_editor_from_settings() {
591        let get = |text| {
592            let config = config_from_string(text);
593            let ui = Ui::with_config(&config).unwrap();
594            let settings = UserSettings::from_config(config).unwrap();
595            DiffEditor::from_settings(
596                &ui,
597                &settings,
598                GitIgnoreFile::empty(),
599                ConflictMarkerStyle::Diff,
600            )
601            .map(|editor| editor.tool)
602        };
603
604        // Default
605        insta::assert_debug_snapshot!(get("").unwrap(), @"Builtin");
606
607        // Just program name, edit_args are filled by default
608        insta::assert_debug_snapshot!(get(r#"ui.diff-editor = "my-diff""#).unwrap(), @r#"
609        External(
610            ExternalMergeTool {
611                program: "my-diff",
612                diff_args: [
613                    "$left",
614                    "$right",
615                ],
616                diff_expected_exit_codes: [
617                    0,
618                ],
619                diff_invocation_mode: Dir,
620                diff_do_chdir: true,
621                edit_args: [
622                    "$left",
623                    "$right",
624                ],
625                edit_invocation_mode: Dir,
626                merge_args: [],
627                merge_conflict_exit_codes: [],
628                merge_tool_edits_conflict_markers: false,
629                conflict_marker_style: None,
630            },
631        )
632        "#);
633
634        // String args (with interpolation variables)
635        insta::assert_debug_snapshot!(
636            get(r#"ui.diff-editor = "my-diff -l $left -r $right""#).unwrap(), @r#"
637        External(
638            ExternalMergeTool {
639                program: "my-diff",
640                diff_args: [
641                    "$left",
642                    "$right",
643                ],
644                diff_expected_exit_codes: [
645                    0,
646                ],
647                diff_invocation_mode: Dir,
648                diff_do_chdir: true,
649                edit_args: [
650                    "-l",
651                    "$left",
652                    "-r",
653                    "$right",
654                ],
655                edit_invocation_mode: Dir,
656                merge_args: [],
657                merge_conflict_exit_codes: [],
658                merge_tool_edits_conflict_markers: false,
659                conflict_marker_style: None,
660            },
661        )
662        "#);
663
664        // List args (with interpolation variables)
665        insta::assert_debug_snapshot!(
666            get(r#"ui.diff-editor = ["my-diff", "--diff", "$left", "$right"]"#).unwrap(), @r#"
667        External(
668            ExternalMergeTool {
669                program: "my-diff",
670                diff_args: [
671                    "$left",
672                    "$right",
673                ],
674                diff_expected_exit_codes: [
675                    0,
676                ],
677                diff_invocation_mode: Dir,
678                diff_do_chdir: true,
679                edit_args: [
680                    "--diff",
681                    "$left",
682                    "$right",
683                ],
684                edit_invocation_mode: Dir,
685                merge_args: [],
686                merge_conflict_exit_codes: [],
687                merge_tool_edits_conflict_markers: false,
688                conflict_marker_style: None,
689            },
690        )
691        "#);
692
693        // Pick from merge-tools
694        insta::assert_debug_snapshot!(get(
695        r#"
696        ui.diff-editor = "foo bar"
697        [merge-tools."foo bar"]
698        edit-args = ["--edit", "args", "$left", "$right"]
699        diff-args = []  # Should not cause an error, since we're getting the diff *editor*
700        "#,
701        ).unwrap(), @r#"
702        External(
703            ExternalMergeTool {
704                program: "foo bar",
705                diff_args: [],
706                diff_expected_exit_codes: [
707                    0,
708                ],
709                diff_invocation_mode: Dir,
710                diff_do_chdir: true,
711                edit_args: [
712                    "--edit",
713                    "args",
714                    "$left",
715                    "$right",
716                ],
717                edit_invocation_mode: Dir,
718                merge_args: [],
719                merge_conflict_exit_codes: [],
720                merge_tool_edits_conflict_markers: false,
721                conflict_marker_style: None,
722            },
723        )
724        "#);
725
726        // Pick from merge-tools, but no edit-args specified
727        insta::assert_debug_snapshot!(get(
728        r#"
729        ui.diff-editor = "my-diff"
730        [merge-tools.my-diff]
731        program = "MyDiff"
732        "#,
733        ).unwrap(), @r#"
734        External(
735            ExternalMergeTool {
736                program: "MyDiff",
737                diff_args: [
738                    "$left",
739                    "$right",
740                ],
741                diff_expected_exit_codes: [
742                    0,
743                ],
744                diff_invocation_mode: Dir,
745                diff_do_chdir: true,
746                edit_args: [
747                    "$left",
748                    "$right",
749                ],
750                edit_invocation_mode: Dir,
751                merge_args: [],
752                merge_conflict_exit_codes: [],
753                merge_tool_edits_conflict_markers: false,
754                conflict_marker_style: None,
755            },
756        )
757        "#);
758
759        // List args should never be a merge-tools key, edit_args are filled by default
760        insta::assert_debug_snapshot!(get(r#"ui.diff-editor = ["meld"]"#).unwrap(), @r#"
761        External(
762            ExternalMergeTool {
763                program: "meld",
764                diff_args: [
765                    "$left",
766                    "$right",
767                ],
768                diff_expected_exit_codes: [
769                    0,
770                ],
771                diff_invocation_mode: Dir,
772                diff_do_chdir: true,
773                edit_args: [
774                    "$left",
775                    "$right",
776                ],
777                edit_invocation_mode: Dir,
778                merge_args: [],
779                merge_conflict_exit_codes: [],
780                merge_tool_edits_conflict_markers: false,
781                conflict_marker_style: None,
782            },
783        )
784        "#);
785
786        // Invalid type
787        assert!(get(r#"ui.diff-editor.k = 0"#).is_err());
788
789        // Explicitly empty edit-args cause an error
790        insta::assert_debug_snapshot!(get(
791        r#"
792        ui.diff-editor = "my-diff"
793        [merge-tools.my-diff]
794        program = "MyDiff"
795        edit-args = []
796        "#,
797        ), @r#"
798        Err(
799            EditArgsNotConfigured {
800                tool_name: "my-diff",
801            },
802        )
803        "#);
804    }
805
806    #[test]
807    fn test_get_merge_editor_with_name() {
808        let get = |name, config_text| {
809            let config = config_from_string(config_text);
810            let settings = UserSettings::from_config(config).unwrap();
811            let path_converter = RepoPathUiConverter::Fs {
812                cwd: "".into(),
813                base: "".into(),
814            };
815            MergeEditor::with_name(name, &settings, path_converter, ConflictMarkerStyle::Diff)
816                .map(|editor| editor.tool)
817        };
818
819        insta::assert_debug_snapshot!(get(":builtin", "").unwrap(), @"Builtin");
820
821        // Just program name
822        insta::assert_debug_snapshot!(get("my diff", "").unwrap_err(), @r#"
823        MergeArgsNotConfigured {
824            tool_name: "my diff",
825        }
826        "#);
827
828        // Pick from merge-tools
829        insta::assert_debug_snapshot!(get(
830            "foo bar", r#"
831        [merge-tools."foo bar"]
832        merge-args = ["$base", "$left", "$right", "$output"]
833        "#,
834        ).unwrap(), @r#"
835        External(
836            ExternalMergeTool {
837                program: "foo bar",
838                diff_args: [
839                    "$left",
840                    "$right",
841                ],
842                diff_expected_exit_codes: [
843                    0,
844                ],
845                diff_invocation_mode: Dir,
846                diff_do_chdir: true,
847                edit_args: [
848                    "$left",
849                    "$right",
850                ],
851                edit_invocation_mode: Dir,
852                merge_args: [
853                    "$base",
854                    "$left",
855                    "$right",
856                    "$output",
857                ],
858                merge_conflict_exit_codes: [],
859                merge_tool_edits_conflict_markers: false,
860                conflict_marker_style: None,
861            },
862        )
863        "#);
864    }
865
866    #[test]
867    fn test_get_merge_editor_from_settings() {
868        let get = |text| {
869            let config = config_from_string(text);
870            let ui = Ui::with_config(&config).unwrap();
871            let settings = UserSettings::from_config(config).unwrap();
872            let path_converter = RepoPathUiConverter::Fs {
873                cwd: "".into(),
874                base: "".into(),
875            };
876            MergeEditor::from_settings(&ui, &settings, path_converter, ConflictMarkerStyle::Diff)
877                .map(|editor| editor.tool)
878        };
879
880        // Default
881        insta::assert_debug_snapshot!(get("").unwrap(), @"Builtin");
882
883        // Just program name
884        insta::assert_debug_snapshot!(get(r#"ui.merge-editor = "my-merge""#).unwrap_err(), @r#"
885        MergeArgsNotConfigured {
886            tool_name: "my-merge",
887        }
888        "#);
889
890        // String args
891        insta::assert_debug_snapshot!(
892            get(r#"ui.merge-editor = "my-merge $left $base $right $output""#).unwrap(), @r#"
893        External(
894            ExternalMergeTool {
895                program: "my-merge",
896                diff_args: [
897                    "$left",
898                    "$right",
899                ],
900                diff_expected_exit_codes: [
901                    0,
902                ],
903                diff_invocation_mode: Dir,
904                diff_do_chdir: true,
905                edit_args: [
906                    "$left",
907                    "$right",
908                ],
909                edit_invocation_mode: Dir,
910                merge_args: [
911                    "$left",
912                    "$base",
913                    "$right",
914                    "$output",
915                ],
916                merge_conflict_exit_codes: [],
917                merge_tool_edits_conflict_markers: false,
918                conflict_marker_style: None,
919            },
920        )
921        "#);
922
923        // List args
924        insta::assert_debug_snapshot!(
925            get(
926                r#"ui.merge-editor = ["my-merge", "$left", "$base", "$right", "$output"]"#,
927            ).unwrap(), @r#"
928        External(
929            ExternalMergeTool {
930                program: "my-merge",
931                diff_args: [
932                    "$left",
933                    "$right",
934                ],
935                diff_expected_exit_codes: [
936                    0,
937                ],
938                diff_invocation_mode: Dir,
939                diff_do_chdir: true,
940                edit_args: [
941                    "$left",
942                    "$right",
943                ],
944                edit_invocation_mode: Dir,
945                merge_args: [
946                    "$left",
947                    "$base",
948                    "$right",
949                    "$output",
950                ],
951                merge_conflict_exit_codes: [],
952                merge_tool_edits_conflict_markers: false,
953                conflict_marker_style: None,
954            },
955        )
956        "#);
957
958        // Pick from merge-tools
959        insta::assert_debug_snapshot!(get(
960        r#"
961        ui.merge-editor = "foo bar"
962        [merge-tools."foo bar"]
963        merge-args = ["$base", "$left", "$right", "$output"]
964        "#,
965        ).unwrap(), @r#"
966        External(
967            ExternalMergeTool {
968                program: "foo bar",
969                diff_args: [
970                    "$left",
971                    "$right",
972                ],
973                diff_expected_exit_codes: [
974                    0,
975                ],
976                diff_invocation_mode: Dir,
977                diff_do_chdir: true,
978                edit_args: [
979                    "$left",
980                    "$right",
981                ],
982                edit_invocation_mode: Dir,
983                merge_args: [
984                    "$base",
985                    "$left",
986                    "$right",
987                    "$output",
988                ],
989                merge_conflict_exit_codes: [],
990                merge_tool_edits_conflict_markers: false,
991                conflict_marker_style: None,
992            },
993        )
994        "#);
995
996        // List args should never be a merge-tools key
997        insta::assert_debug_snapshot!(
998            get(r#"ui.merge-editor = ["meld"]"#).unwrap_err(), @r#"
999        MergeArgsNotConfigured {
1000            tool_name: "meld",
1001        }
1002        "#);
1003
1004        // Invalid type
1005        assert!(get(r#"ui.merge-editor.k = 0"#).is_err());
1006    }
1007}