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