jj_cli/merge_tools/
external.rs

1use std::collections::HashMap;
2use std::io;
3use std::io::Write;
4use std::path::Path;
5use std::process::Command;
6use std::process::ExitStatus;
7use std::process::Stdio;
8use std::sync::Arc;
9
10use bstr::BString;
11use itertools::Itertools as _;
12use jj_lib::backend::CopyId;
13use jj_lib::backend::MergedTreeId;
14use jj_lib::backend::TreeValue;
15use jj_lib::conflicts;
16use jj_lib::conflicts::choose_materialized_conflict_marker_len;
17use jj_lib::conflicts::materialize_merge_result_to_bytes_with_marker_len;
18use jj_lib::conflicts::ConflictMarkerStyle;
19use jj_lib::conflicts::MIN_CONFLICT_MARKER_LEN;
20use jj_lib::gitignore::GitIgnoreFile;
21use jj_lib::matchers::Matcher;
22use jj_lib::merge::Merge;
23use jj_lib::merged_tree::MergedTree;
24use jj_lib::merged_tree::MergedTreeBuilder;
25use jj_lib::repo_path::RepoPathUiConverter;
26use jj_lib::store::Store;
27use jj_lib::working_copy::CheckoutOptions;
28use pollster::FutureExt as _;
29use thiserror::Error;
30
31use super::diff_working_copies::check_out_trees;
32use super::diff_working_copies::new_utf8_temp_dir;
33use super::diff_working_copies::set_readonly_recursively;
34use super::diff_working_copies::DiffEditWorkingCopies;
35use super::diff_working_copies::DiffSide;
36use super::ConflictResolveError;
37use super::DiffEditError;
38use super::DiffGenerateError;
39use super::MergeToolFile;
40use super::MergeToolPartialResolutionError;
41use crate::config::find_all_variables;
42use crate::config::interpolate_variables;
43use crate::config::CommandNameAndArgs;
44use crate::ui::Ui;
45
46/// Merge/diff tool loaded from the settings.
47#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
48#[serde(default, rename_all = "kebab-case")]
49pub struct ExternalMergeTool {
50    /// Program to execute. Must be defined; defaults to the tool name
51    /// if not specified in the config.
52    pub program: String,
53    /// Arguments to pass to the program when generating diffs.
54    /// `$left` and `$right` are replaced with the corresponding directories.
55    pub diff_args: Vec<String>,
56    /// Exit codes to be treated as success when generating diffs.
57    pub diff_expected_exit_codes: Vec<i32>,
58    /// Whether to execute the tool with a pair of directories or individual
59    /// files.
60    pub diff_invocation_mode: DiffToolMode,
61    /// Whether to execute the tool in the temporary diff directory
62    pub diff_do_chdir: bool,
63    /// Arguments to pass to the program when editing diffs.
64    /// `$left` and `$right` are replaced with the corresponding directories.
65    pub edit_args: Vec<String>,
66    /// Arguments to pass to the program when resolving 3-way conflicts.
67    /// `$left`, `$right`, `$base`, and `$output` are replaced with
68    /// paths to the corresponding files.
69    pub merge_args: Vec<String>,
70    /// By default, if a merge tool exits with a non-zero exit code, then the
71    /// merge will be cancelled. Some merge tools allow leaving some conflicts
72    /// unresolved, in which case they will be left as conflict markers in the
73    /// output file. In that case, the merge tool may exit with a non-zero exit
74    /// code to indicate that not all conflicts were resolved. Adding an exit
75    /// code to this array will tell `jj` to interpret that exit code as
76    /// indicating that the `$output` file should contain conflict markers.
77    pub merge_conflict_exit_codes: Vec<i32>,
78    /// If false (default), the `$output` file starts out empty and is accepted
79    /// as a full conflict resolution as-is by `jj` after the merge tool is
80    /// done with it. If true, the `$output` file starts out with the
81    /// contents of the conflict, with the configured conflict markers. After
82    /// the merge tool is done, any remaining conflict markers in the
83    /// file are parsed and taken to mean that the conflict was only partially
84    /// resolved.
85    pub merge_tool_edits_conflict_markers: bool,
86    /// If provided, overrides the normal conflict marker style setting. This is
87    /// useful if a tool parses conflict markers, and so it requires a specific
88    /// format, or if a certain format is more readable than another.
89    pub conflict_marker_style: Option<ConflictMarkerStyle>,
90}
91
92#[derive(serde::Deserialize, Copy, Clone, Debug, Eq, PartialEq)]
93#[serde(rename_all = "kebab-case")]
94pub enum DiffToolMode {
95    /// Invoke the diff tool on a temp directory of the modified files.
96    Dir,
97    /// Invoke the diff tool on each of the modified files individually.
98    FileByFile,
99}
100
101impl Default for ExternalMergeTool {
102    fn default() -> Self {
103        Self {
104            program: String::new(),
105            // TODO(ilyagr): There should be a way to explicitly specify that a
106            // certain tool (e.g. vscode as of this writing) cannot be used as a
107            // diff editor (or a diff tool). A possible TOML syntax would be
108            // `edit-args = false`, or `edit-args = []`, or `edit = { disabled =
109            // true }` to go with `edit = { args = [...] }`.
110            diff_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),
111            diff_expected_exit_codes: vec![0],
112            edit_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),
113            merge_args: vec![],
114            merge_conflict_exit_codes: vec![],
115            merge_tool_edits_conflict_markers: false,
116            conflict_marker_style: None,
117            diff_do_chdir: true,
118            diff_invocation_mode: DiffToolMode::Dir,
119        }
120    }
121}
122
123impl ExternalMergeTool {
124    pub fn with_program(program: impl Into<String>) -> Self {
125        Self {
126            program: program.into(),
127            ..Default::default()
128        }
129    }
130
131    pub fn with_diff_args(command_args: &CommandNameAndArgs) -> Self {
132        Self::with_args_inner(command_args, |tool| &mut tool.diff_args)
133    }
134
135    pub fn with_edit_args(command_args: &CommandNameAndArgs) -> Self {
136        Self::with_args_inner(command_args, |tool| &mut tool.edit_args)
137    }
138
139    pub fn with_merge_args(command_args: &CommandNameAndArgs) -> Self {
140        Self::with_args_inner(command_args, |tool| &mut tool.merge_args)
141    }
142
143    fn with_args_inner(
144        command_args: &CommandNameAndArgs,
145        get_mut_args: impl FnOnce(&mut Self) -> &mut Vec<String>,
146    ) -> Self {
147        let (name, args) = command_args.split_name_and_args();
148        let mut tool = Self {
149            program: name.into_owned(),
150            ..Default::default()
151        };
152        if !args.is_empty() {
153            *get_mut_args(&mut tool) = args.to_vec();
154        }
155        tool
156    }
157}
158
159#[derive(Debug, Error)]
160pub enum ExternalToolError {
161    #[error("Error setting up temporary directory")]
162    SetUpDir(#[source] std::io::Error),
163    // TODO: Remove the "(run with --debug to see the exact invocation)"
164    // from this and other errors. Print it as a hint but only if --debug is *not* set.
165    #[error("Error executing '{tool_binary}' (run with --debug to see the exact invocation)")]
166    FailedToExecute {
167        tool_binary: String,
168        #[source]
169        source: std::io::Error,
170    },
171    #[error("Tool exited with {exit_status} (run with --debug to see the exact invocation)")]
172    ToolAborted { exit_status: ExitStatus },
173    #[error(
174        "Tool exited with {exit_status}, but did not produce valid conflict markers (run with \
175         --debug to see the exact invocation)"
176    )]
177    InvalidConflictMarkers { exit_status: ExitStatus },
178    #[error("I/O error")]
179    Io(#[source] std::io::Error),
180}
181
182fn run_mergetool_external_single_file(
183    editor: &ExternalMergeTool,
184    store: &Store,
185    merge_tool_file: &MergeToolFile,
186    default_conflict_marker_style: ConflictMarkerStyle,
187    tree_builder: &mut MergedTreeBuilder,
188) -> Result<(), ConflictResolveError> {
189    let MergeToolFile {
190        repo_path,
191        conflict,
192        file,
193    } = merge_tool_file;
194
195    let conflict_marker_style = editor
196        .conflict_marker_style
197        .unwrap_or(default_conflict_marker_style);
198
199    let uses_marker_length = find_all_variables(&editor.merge_args).contains(&"marker_length");
200
201    // If the merge tool doesn't get conflict markers pre-populated in the output
202    // file and doesn't accept "$marker_length", then we should default to accepting
203    // MIN_CONFLICT_MARKER_LEN since the merge tool can't know about our rules for
204    // conflict marker length.
205    let conflict_marker_len = if editor.merge_tool_edits_conflict_markers || uses_marker_length {
206        choose_materialized_conflict_marker_len(&file.contents)
207    } else {
208        MIN_CONFLICT_MARKER_LEN
209    };
210    let initial_output_content = if editor.merge_tool_edits_conflict_markers {
211        materialize_merge_result_to_bytes_with_marker_len(
212            &file.contents,
213            conflict_marker_style,
214            conflict_marker_len,
215        )
216    } else {
217        BString::default()
218    };
219    assert_eq!(file.contents.num_sides(), 2);
220    let files: HashMap<&str, &[u8]> = maplit::hashmap! {
221        "base" => file.contents.get_remove(0).unwrap().as_slice(),
222        "left" => file.contents.get_add(0).unwrap().as_slice(),
223        "right" => file.contents.get_add(1).unwrap().as_slice(),
224        "output" => initial_output_content.as_slice(),
225    };
226
227    let temp_dir = new_utf8_temp_dir("jj-resolve-").map_err(ExternalToolError::SetUpDir)?;
228    let suffix = if let Some(filename) = repo_path.components().next_back() {
229        let name = filename
230            .to_fs_name()
231            .map_err(|err| err.with_path(repo_path))?;
232        format!("_{name}")
233    } else {
234        // This should never actually trigger, but we support it just in case
235        // resolving the root path ever makes sense.
236        "".to_owned()
237    };
238    let mut variables: HashMap<&str, _> = files
239        .iter()
240        .map(|(role, contents)| -> Result<_, ConflictResolveError> {
241            let path = temp_dir.path().join(format!("{role}{suffix}"));
242            std::fs::write(&path, contents).map_err(ExternalToolError::SetUpDir)?;
243            if *role != "output" {
244                // TODO: Should actually ignore the error here, or have a warning.
245                set_readonly_recursively(&path).map_err(ExternalToolError::SetUpDir)?;
246            }
247            Ok((
248                *role,
249                path.into_os_string()
250                    .into_string()
251                    .expect("temp_dir should be valid utf-8"),
252            ))
253        })
254        .try_collect()?;
255    variables.insert("marker_length", conflict_marker_len.to_string());
256
257    let mut cmd = Command::new(&editor.program);
258    cmd.args(interpolate_variables(&editor.merge_args, &variables));
259    tracing::info!(?cmd, "Invoking the external merge tool:");
260    let exit_status = cmd
261        .status()
262        .map_err(|e| ExternalToolError::FailedToExecute {
263            tool_binary: editor.program.clone(),
264            source: e,
265        })?;
266    tracing::info!(%exit_status);
267
268    // Check whether the exit status implies that there should be conflict markers
269    let exit_status_implies_conflict = exit_status
270        .code()
271        .is_some_and(|code| editor.merge_conflict_exit_codes.contains(&code));
272
273    if !exit_status.success() && !exit_status_implies_conflict {
274        return Err(ConflictResolveError::from(ExternalToolError::ToolAborted {
275            exit_status,
276        }));
277    }
278
279    let output_file_contents: Vec<u8> =
280        std::fs::read(variables.get("output").unwrap()).map_err(ExternalToolError::Io)?;
281    if output_file_contents.is_empty() || output_file_contents == initial_output_content {
282        return Err(ConflictResolveError::EmptyOrUnchanged);
283    }
284
285    let new_file_ids = if editor.merge_tool_edits_conflict_markers || exit_status_implies_conflict {
286        tracing::info!(
287            ?exit_status_implies_conflict,
288            "jj is reparsing output for conflicts, `merge-tool-edits-conflict-markers = {}` in \
289             TOML config;",
290            editor.merge_tool_edits_conflict_markers
291        );
292        conflicts::update_from_content(
293            &file.unsimplified_ids,
294            store,
295            repo_path,
296            output_file_contents.as_slice(),
297            conflict_marker_style,
298            conflict_marker_len,
299        )
300        .block_on()?
301    } else {
302        let new_file_id = store
303            .write_file(repo_path, &mut output_file_contents.as_slice())
304            .block_on()?;
305        Merge::normal(new_file_id)
306    };
307
308    // If the exit status indicated there should be conflict markers but there
309    // weren't any, it's likely that the tool generated invalid conflict markers, so
310    // we need to inform the user. If we didn't treat this as an error, the user
311    // might think the conflict was resolved successfully.
312    if exit_status_implies_conflict && new_file_ids.is_resolved() {
313        return Err(ConflictResolveError::ExternalTool(
314            ExternalToolError::InvalidConflictMarkers { exit_status },
315        ));
316    }
317
318    let new_tree_value = match new_file_ids.into_resolved() {
319        Ok(file_id) => {
320            let executable = file.executable.expect("should have been resolved");
321            Merge::resolved(file_id.map(|id| TreeValue::File {
322                id,
323                executable,
324                copy_id: CopyId::placeholder(),
325            }))
326        }
327        // Update the file ids only, leaving the executable flags unchanged
328        Err(file_ids) => conflict.with_new_file_ids(&file_ids),
329    };
330    tree_builder.set_or_remove(repo_path.to_owned(), new_tree_value);
331    Ok(())
332}
333
334pub fn run_mergetool_external(
335    ui: &Ui,
336    path_converter: &RepoPathUiConverter,
337    editor: &ExternalMergeTool,
338    tree: &MergedTree,
339    merge_tool_files: &[MergeToolFile],
340    default_conflict_marker_style: ConflictMarkerStyle,
341) -> Result<(MergedTreeId, Option<MergeToolPartialResolutionError>), ConflictResolveError> {
342    // TODO: add support for "dir" invocation mode, similar to the
343    // "diff-invocation-mode" config option for diffs
344    let mut tree_builder = MergedTreeBuilder::new(tree.id());
345    let mut partial_resolution_error = None;
346    for (i, merge_tool_file) in merge_tool_files.iter().enumerate() {
347        writeln!(
348            ui.status(),
349            "Resolving conflicts in: {}",
350            path_converter.format_file_path(&merge_tool_file.repo_path)
351        )?;
352        match run_mergetool_external_single_file(
353            editor,
354            tree.store(),
355            merge_tool_file,
356            default_conflict_marker_style,
357            &mut tree_builder,
358        ) {
359            Ok(()) => {}
360            Err(err) if i == 0 => {
361                // If the first resolution fails, just return the error normally
362                return Err(err);
363            }
364            Err(err) => {
365                // Some conflicts were already resolved, so we should return an error with the
366                // partially-resolved tree so that the caller can save the resolved files.
367                partial_resolution_error = Some(MergeToolPartialResolutionError {
368                    source: err,
369                    resolved_count: i,
370                });
371                break;
372            }
373        }
374    }
375    let new_tree = tree_builder.write_tree(tree.store())?;
376    Ok((new_tree, partial_resolution_error))
377}
378
379pub fn edit_diff_external(
380    editor: &ExternalMergeTool,
381    left_tree: &MergedTree,
382    right_tree: &MergedTree,
383    matcher: &dyn Matcher,
384    instructions: Option<&str>,
385    base_ignores: Arc<GitIgnoreFile>,
386    default_conflict_marker_style: ConflictMarkerStyle,
387) -> Result<MergedTreeId, DiffEditError> {
388    let conflict_marker_style = editor
389        .conflict_marker_style
390        .unwrap_or(default_conflict_marker_style);
391    let options = CheckoutOptions {
392        conflict_marker_style,
393    };
394
395    let got_output_field = find_all_variables(&editor.edit_args).contains(&"output");
396    let store = left_tree.store();
397    let diffedit_wc = DiffEditWorkingCopies::check_out(
398        store,
399        left_tree,
400        right_tree,
401        matcher,
402        got_output_field.then_some(DiffSide::Right),
403        instructions,
404        &options,
405    )?;
406
407    let patterns = diffedit_wc.working_copies.to_command_variables(false);
408    let mut cmd = Command::new(&editor.program);
409    cmd.args(interpolate_variables(&editor.edit_args, &patterns));
410    tracing::info!(?cmd, "Invoking the external diff editor:");
411    let exit_status = cmd
412        .status()
413        .map_err(|e| ExternalToolError::FailedToExecute {
414            tool_binary: editor.program.clone(),
415            source: e,
416        })?;
417    if !exit_status.success() {
418        return Err(DiffEditError::from(ExternalToolError::ToolAborted {
419            exit_status,
420        }));
421    }
422
423    diffedit_wc.snapshot_results(base_ignores, options.conflict_marker_style)
424}
425
426/// Generates textual diff by the specified `tool` and writes into `writer`.
427pub fn generate_diff(
428    ui: &Ui,
429    writer: &mut dyn Write,
430    left_tree: &MergedTree,
431    right_tree: &MergedTree,
432    matcher: &dyn Matcher,
433    tool: &ExternalMergeTool,
434    default_conflict_marker_style: ConflictMarkerStyle,
435) -> Result<(), DiffGenerateError> {
436    let conflict_marker_style = tool
437        .conflict_marker_style
438        .unwrap_or(default_conflict_marker_style);
439    let options = CheckoutOptions {
440        conflict_marker_style,
441    };
442    let store = left_tree.store();
443    let diff_wc = check_out_trees(store, left_tree, right_tree, matcher, None, &options)?;
444    set_readonly_recursively(diff_wc.left_working_copy_path())
445        .map_err(ExternalToolError::SetUpDir)?;
446    set_readonly_recursively(diff_wc.right_working_copy_path())
447        .map_err(ExternalToolError::SetUpDir)?;
448    invoke_external_diff(
449        ui,
450        writer,
451        tool,
452        diff_wc.temp_dir(),
453        &diff_wc.to_command_variables(true),
454    )
455}
456
457/// Invokes the specified `tool` directing its output into `writer`.
458pub fn invoke_external_diff(
459    ui: &Ui,
460    writer: &mut dyn Write,
461    tool: &ExternalMergeTool,
462    diff_dir: &Path,
463    patterns: &HashMap<&str, &str>,
464) -> Result<(), DiffGenerateError> {
465    // TODO: Somehow propagate --color to the external command?
466    let mut cmd = Command::new(&tool.program);
467    let mut patterns = patterns.clone();
468    let absolute_left_path = Path::new(diff_dir).join(patterns["left"]);
469    let absolute_right_path = Path::new(diff_dir).join(patterns["right"]);
470    if !tool.diff_do_chdir {
471        patterns.insert(
472            "left",
473            absolute_left_path
474                .to_str()
475                .expect("temp_dir should be valid utf-8"),
476        );
477        patterns.insert(
478            "right",
479            absolute_right_path
480                .to_str()
481                .expect("temp_dir should be valid utf-8"),
482        );
483    } else {
484        cmd.current_dir(diff_dir);
485    }
486    cmd.args(interpolate_variables(&tool.diff_args, &patterns));
487
488    tracing::info!(?cmd, "Invoking the external diff generator:");
489    let mut child = cmd
490        .stdin(Stdio::null())
491        .stdout(Stdio::piped())
492        .stderr(ui.stderr_for_child().map_err(ExternalToolError::Io)?)
493        .spawn()
494        .map_err(|source| ExternalToolError::FailedToExecute {
495            tool_binary: tool.program.clone(),
496            source,
497        })?;
498    let copy_result = io::copy(&mut child.stdout.take().unwrap(), writer);
499    // Non-zero exit code isn't an error. For example, the traditional diff command
500    // will exit with 1 if inputs are different.
501    let exit_status = child.wait().map_err(ExternalToolError::Io)?;
502    tracing::info!(?cmd, ?exit_status, "The external diff generator exited:");
503    let exit_ok = exit_status
504        .code()
505        .is_some_and(|status| tool.diff_expected_exit_codes.contains(&status));
506    if !exit_ok {
507        writeln!(
508            ui.warning_default(),
509            "Tool exited with {exit_status} (run with --debug to see the exact invocation)",
510        )
511        .ok();
512    }
513    copy_result.map_err(ExternalToolError::Io)?;
514    Ok(())
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn test_interpolate_variables() {
523        let patterns = maplit::hashmap! {
524            "left" => "LEFT",
525            "right" => "RIGHT",
526            "left_right" => "$left $right",
527        };
528
529        assert_eq!(
530            interpolate_variables(
531                &["$left", "$1", "$right", "$2"].map(ToOwned::to_owned),
532                &patterns
533            ),
534            ["LEFT", "$1", "RIGHT", "$2"],
535        );
536
537        // Option-like
538        assert_eq!(
539            interpolate_variables(&["-o$left$right".to_owned()], &patterns),
540            ["-oLEFTRIGHT"],
541        );
542
543        // Sexp-like
544        assert_eq!(
545            interpolate_variables(&["($unknown $left $right)".to_owned()], &patterns),
546            ["($unknown LEFT RIGHT)"],
547        );
548
549        // Not a word "$left"
550        assert_eq!(
551            interpolate_variables(&["$lefty".to_owned()], &patterns),
552            ["$lefty"],
553        );
554
555        // Patterns in pattern: not expanded recursively
556        assert_eq!(
557            interpolate_variables(&["$left_right".to_owned()], &patterns),
558            ["$left $right"],
559        );
560    }
561
562    #[test]
563    fn test_find_all_variables() {
564        assert_eq!(
565            find_all_variables(
566                &[
567                    "$left",
568                    "$right",
569                    "--two=$1 and $2",
570                    "--can-be-part-of-string=$output",
571                    "$NOT_CAPITALS",
572                    "--can-repeat=$right"
573                ]
574                .map(ToOwned::to_owned),
575            )
576            .collect_vec(),
577            ["left", "right", "1", "2", "output", "right"],
578        );
579    }
580}