Skip to main content

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