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#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
48#[serde(default, rename_all = "kebab-case")]
49pub struct ExternalMergeTool {
50 pub program: String,
53 pub diff_args: Vec<String>,
56 pub diff_expected_exit_codes: Vec<i32>,
58 pub diff_invocation_mode: DiffToolMode,
61 pub diff_do_chdir: bool,
63 pub edit_args: Vec<String>,
66 pub edit_invocation_mode: DiffToolMode,
69 pub merge_args: Vec<String>,
73 pub merge_conflict_exit_codes: Vec<i32>,
81 pub merge_tool_edits_conflict_markers: bool,
89 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 Dir,
100 FileByFile,
102}
103
104impl Default for ExternalMergeTool {
105 fn default() -> Self {
106 Self {
107 program: String::new(),
108 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 #[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 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 "".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 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 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 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 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 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 return Err(err);
368 }
369 Err(err) => {
370 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
446pub 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
467pub 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 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 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 assert_eq!(
551 interpolate_variables(&["-o$left$right".to_owned()], &patterns),
552 ["-oLEFTRIGHT"],
553 );
554
555 assert_eq!(
557 interpolate_variables(&["($unknown $left $right)".to_owned()], &patterns),
558 ["($unknown LEFT RIGHT)"],
559 );
560
561 assert_eq!(
563 interpolate_variables(&["$lefty".to_owned()], &patterns),
564 ["$lefty"],
565 );
566
567 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}