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