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#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
47#[serde(default, rename_all = "kebab-case")]
48pub struct ExternalMergeTool {
49 pub program: String,
52 pub diff_args: Vec<String>,
55 pub diff_expected_exit_codes: Vec<i32>,
57 pub diff_invocation_mode: DiffToolMode,
60 pub diff_do_chdir: bool,
62 pub edit_args: Vec<String>,
65 pub edit_invocation_mode: DiffToolMode,
68 pub merge_args: Vec<String>,
72 pub merge_conflict_exit_codes: Vec<i32>,
80 pub merge_tool_edits_conflict_markers: bool,
88 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 Dir,
99 FileByFile,
101}
102
103impl Default for ExternalMergeTool {
104 fn default() -> Self {
105 Self {
106 program: String::new(),
107 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 #[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 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 "".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 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 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 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 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 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 return Err(err);
367 }
368 Err(err) => {
369 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
445pub 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
466pub 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 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 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 assert_eq!(
550 interpolate_variables(&["-o$left$right".to_owned()], &patterns),
551 ["-oLEFTRIGHT"],
552 );
553
554 assert_eq!(
556 interpolate_variables(&["($unknown $left $right)".to_owned()], &patterns),
557 ["($unknown LEFT RIGHT)"],
558 );
559
560 assert_eq!(
562 interpolate_variables(&["$lefty".to_owned()], &patterns),
563 ["$lefty"],
564 );
565
566 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}