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::MergedTreeId;
14use jj_lib::backend::TreeValue;
15use jj_lib::conflicts;
16use jj_lib::conflicts::choose_materialized_conflict_marker_len;
17use jj_lib::conflicts::materialize_merge_result_to_bytes_with_marker_len;
18use jj_lib::conflicts::ConflictMarkerStyle;
19use jj_lib::conflicts::MIN_CONFLICT_MARKER_LEN;
20use jj_lib::gitignore::GitIgnoreFile;
21use jj_lib::matchers::Matcher;
22use jj_lib::merge::Merge;
23use jj_lib::merged_tree::MergedTree;
24use jj_lib::merged_tree::MergedTreeBuilder;
25use jj_lib::repo_path::RepoPathUiConverter;
26use jj_lib::store::Store;
27use jj_lib::working_copy::CheckoutOptions;
28use pollster::FutureExt as _;
29use thiserror::Error;
30
31use super::diff_working_copies::check_out_trees;
32use super::diff_working_copies::new_utf8_temp_dir;
33use super::diff_working_copies::set_readonly_recursively;
34use super::diff_working_copies::DiffEditWorkingCopies;
35use super::diff_working_copies::DiffSide;
36use super::ConflictResolveError;
37use super::DiffEditError;
38use super::DiffGenerateError;
39use super::MergeToolFile;
40use super::MergeToolPartialResolutionError;
41use crate::config::find_all_variables;
42use crate::config::interpolate_variables;
43use crate::config::CommandNameAndArgs;
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 merge_args: Vec<String>,
70 pub merge_conflict_exit_codes: Vec<i32>,
78 pub merge_tool_edits_conflict_markers: bool,
86 pub conflict_marker_style: Option<ConflictMarkerStyle>,
90}
91
92#[derive(serde::Deserialize, Copy, Clone, Debug, Eq, PartialEq)]
93#[serde(rename_all = "kebab-case")]
94pub enum DiffToolMode {
95 Dir,
97 FileByFile,
99}
100
101impl Default for ExternalMergeTool {
102 fn default() -> Self {
103 Self {
104 program: String::new(),
105 diff_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),
111 diff_expected_exit_codes: vec![0],
112 edit_args: ["$left", "$right"].map(ToOwned::to_owned).to_vec(),
113 merge_args: vec![],
114 merge_conflict_exit_codes: vec![],
115 merge_tool_edits_conflict_markers: false,
116 conflict_marker_style: None,
117 diff_do_chdir: true,
118 diff_invocation_mode: DiffToolMode::Dir,
119 }
120 }
121}
122
123impl ExternalMergeTool {
124 pub fn with_program(program: impl Into<String>) -> Self {
125 Self {
126 program: program.into(),
127 ..Default::default()
128 }
129 }
130
131 pub fn with_diff_args(command_args: &CommandNameAndArgs) -> Self {
132 Self::with_args_inner(command_args, |tool| &mut tool.diff_args)
133 }
134
135 pub fn with_edit_args(command_args: &CommandNameAndArgs) -> Self {
136 Self::with_args_inner(command_args, |tool| &mut tool.edit_args)
137 }
138
139 pub fn with_merge_args(command_args: &CommandNameAndArgs) -> Self {
140 Self::with_args_inner(command_args, |tool| &mut tool.merge_args)
141 }
142
143 fn with_args_inner(
144 command_args: &CommandNameAndArgs,
145 get_mut_args: impl FnOnce(&mut Self) -> &mut Vec<String>,
146 ) -> Self {
147 let (name, args) = command_args.split_name_and_args();
148 let mut tool = Self {
149 program: name.into_owned(),
150 ..Default::default()
151 };
152 if !args.is_empty() {
153 *get_mut_args(&mut tool) = args.to_vec();
154 }
155 tool
156 }
157}
158
159#[derive(Debug, Error)]
160pub enum ExternalToolError {
161 #[error("Error setting up temporary directory")]
162 SetUpDir(#[source] std::io::Error),
163 #[error("Error executing '{tool_binary}' (run with --debug to see the exact invocation)")]
166 FailedToExecute {
167 tool_binary: String,
168 #[source]
169 source: std::io::Error,
170 },
171 #[error("Tool exited with {exit_status} (run with --debug to see the exact invocation)")]
172 ToolAborted { exit_status: ExitStatus },
173 #[error(
174 "Tool exited with {exit_status}, but did not produce valid conflict markers (run with \
175 --debug to see the exact invocation)"
176 )]
177 InvalidConflictMarkers { exit_status: ExitStatus },
178 #[error("I/O error")]
179 Io(#[source] std::io::Error),
180}
181
182fn run_mergetool_external_single_file(
183 editor: &ExternalMergeTool,
184 store: &Store,
185 merge_tool_file: &MergeToolFile,
186 default_conflict_marker_style: ConflictMarkerStyle,
187 tree_builder: &mut MergedTreeBuilder,
188) -> Result<(), ConflictResolveError> {
189 let MergeToolFile {
190 repo_path,
191 conflict,
192 file,
193 } = merge_tool_file;
194
195 let conflict_marker_style = editor
196 .conflict_marker_style
197 .unwrap_or(default_conflict_marker_style);
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 materialize_merge_result_to_bytes_with_marker_len(
212 &file.contents,
213 conflict_marker_style,
214 conflict_marker_len,
215 )
216 } else {
217 BString::default()
218 };
219 assert_eq!(file.contents.num_sides(), 2);
220 let files: HashMap<&str, &[u8]> = maplit::hashmap! {
221 "base" => file.contents.get_remove(0).unwrap().as_slice(),
222 "left" => file.contents.get_add(0).unwrap().as_slice(),
223 "right" => file.contents.get_add(1).unwrap().as_slice(),
224 "output" => initial_output_content.as_slice(),
225 };
226
227 let temp_dir = new_utf8_temp_dir("jj-resolve-").map_err(ExternalToolError::SetUpDir)?;
228 let suffix = if let Some(filename) = repo_path.components().next_back() {
229 let name = filename
230 .to_fs_name()
231 .map_err(|err| err.with_path(repo_path))?;
232 format!("_{name}")
233 } else {
234 "".to_owned()
237 };
238 let mut variables: HashMap<&str, _> = files
239 .iter()
240 .map(|(role, contents)| -> Result<_, ConflictResolveError> {
241 let path = temp_dir.path().join(format!("{role}{suffix}"));
242 std::fs::write(&path, contents).map_err(ExternalToolError::SetUpDir)?;
243 if *role != "output" {
244 set_readonly_recursively(&path).map_err(ExternalToolError::SetUpDir)?;
246 }
247 Ok((
248 *role,
249 path.into_os_string()
250 .into_string()
251 .expect("temp_dir should be valid utf-8"),
252 ))
253 })
254 .try_collect()?;
255 variables.insert("marker_length", conflict_marker_len.to_string());
256
257 let mut cmd = Command::new(&editor.program);
258 cmd.args(interpolate_variables(&editor.merge_args, &variables));
259 tracing::info!(?cmd, "Invoking the external merge tool:");
260 let exit_status = cmd
261 .status()
262 .map_err(|e| ExternalToolError::FailedToExecute {
263 tool_binary: editor.program.clone(),
264 source: e,
265 })?;
266 tracing::info!(%exit_status);
267
268 let exit_status_implies_conflict = exit_status
270 .code()
271 .is_some_and(|code| editor.merge_conflict_exit_codes.contains(&code));
272
273 if !exit_status.success() && !exit_status_implies_conflict {
274 return Err(ConflictResolveError::from(ExternalToolError::ToolAborted {
275 exit_status,
276 }));
277 }
278
279 let output_file_contents: Vec<u8> =
280 std::fs::read(variables.get("output").unwrap()).map_err(ExternalToolError::Io)?;
281 if output_file_contents.is_empty() || output_file_contents == initial_output_content {
282 return Err(ConflictResolveError::EmptyOrUnchanged);
283 }
284
285 let new_file_ids = if editor.merge_tool_edits_conflict_markers || exit_status_implies_conflict {
286 tracing::info!(
287 ?exit_status_implies_conflict,
288 "jj is reparsing output for conflicts, `merge-tool-edits-conflict-markers = {}` in \
289 TOML config;",
290 editor.merge_tool_edits_conflict_markers
291 );
292 conflicts::update_from_content(
293 &file.unsimplified_ids,
294 store,
295 repo_path,
296 output_file_contents.as_slice(),
297 conflict_marker_style,
298 conflict_marker_len,
299 )
300 .block_on()?
301 } else {
302 let new_file_id = store
303 .write_file(repo_path, &mut output_file_contents.as_slice())
304 .block_on()?;
305 Merge::normal(new_file_id)
306 };
307
308 if exit_status_implies_conflict && new_file_ids.is_resolved() {
313 return Err(ConflictResolveError::ExternalTool(
314 ExternalToolError::InvalidConflictMarkers { exit_status },
315 ));
316 }
317
318 let new_tree_value = match new_file_ids.into_resolved() {
319 Ok(file_id) => {
320 let executable = file.executable.expect("should have been resolved");
321 Merge::resolved(file_id.map(|id| TreeValue::File {
322 id,
323 executable,
324 copy_id: CopyId::placeholder(),
325 }))
326 }
327 Err(file_ids) => conflict.with_new_file_ids(&file_ids),
329 };
330 tree_builder.set_or_remove(repo_path.to_owned(), new_tree_value);
331 Ok(())
332}
333
334pub fn run_mergetool_external(
335 ui: &Ui,
336 path_converter: &RepoPathUiConverter,
337 editor: &ExternalMergeTool,
338 tree: &MergedTree,
339 merge_tool_files: &[MergeToolFile],
340 default_conflict_marker_style: ConflictMarkerStyle,
341) -> Result<(MergedTreeId, Option<MergeToolPartialResolutionError>), ConflictResolveError> {
342 let mut tree_builder = MergedTreeBuilder::new(tree.id());
345 let mut partial_resolution_error = None;
346 for (i, merge_tool_file) in merge_tool_files.iter().enumerate() {
347 writeln!(
348 ui.status(),
349 "Resolving conflicts in: {}",
350 path_converter.format_file_path(&merge_tool_file.repo_path)
351 )?;
352 match run_mergetool_external_single_file(
353 editor,
354 tree.store(),
355 merge_tool_file,
356 default_conflict_marker_style,
357 &mut tree_builder,
358 ) {
359 Ok(()) => {}
360 Err(err) if i == 0 => {
361 return Err(err);
363 }
364 Err(err) => {
365 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(tree.store())?;
376 Ok((new_tree, partial_resolution_error))
377}
378
379pub fn edit_diff_external(
380 editor: &ExternalMergeTool,
381 left_tree: &MergedTree,
382 right_tree: &MergedTree,
383 matcher: &dyn Matcher,
384 instructions: Option<&str>,
385 base_ignores: Arc<GitIgnoreFile>,
386 default_conflict_marker_style: ConflictMarkerStyle,
387) -> Result<MergedTreeId, DiffEditError> {
388 let conflict_marker_style = editor
389 .conflict_marker_style
390 .unwrap_or(default_conflict_marker_style);
391 let options = CheckoutOptions {
392 conflict_marker_style,
393 };
394
395 let got_output_field = find_all_variables(&editor.edit_args).contains(&"output");
396 let store = left_tree.store();
397 let diffedit_wc = DiffEditWorkingCopies::check_out(
398 store,
399 left_tree,
400 right_tree,
401 matcher,
402 got_output_field.then_some(DiffSide::Right),
403 instructions,
404 &options,
405 )?;
406
407 let patterns = diffedit_wc.working_copies.to_command_variables(false);
408 let mut cmd = Command::new(&editor.program);
409 cmd.args(interpolate_variables(&editor.edit_args, &patterns));
410 tracing::info!(?cmd, "Invoking the external diff editor:");
411 let exit_status = cmd
412 .status()
413 .map_err(|e| ExternalToolError::FailedToExecute {
414 tool_binary: editor.program.clone(),
415 source: e,
416 })?;
417 if !exit_status.success() {
418 return Err(DiffEditError::from(ExternalToolError::ToolAborted {
419 exit_status,
420 }));
421 }
422
423 diffedit_wc.snapshot_results(base_ignores, options.conflict_marker_style)
424}
425
426pub fn generate_diff(
428 ui: &Ui,
429 writer: &mut dyn Write,
430 left_tree: &MergedTree,
431 right_tree: &MergedTree,
432 matcher: &dyn Matcher,
433 tool: &ExternalMergeTool,
434 default_conflict_marker_style: ConflictMarkerStyle,
435) -> Result<(), DiffGenerateError> {
436 let conflict_marker_style = tool
437 .conflict_marker_style
438 .unwrap_or(default_conflict_marker_style);
439 let options = CheckoutOptions {
440 conflict_marker_style,
441 };
442 let store = left_tree.store();
443 let diff_wc = check_out_trees(store, left_tree, right_tree, matcher, None, &options)?;
444 set_readonly_recursively(diff_wc.left_working_copy_path())
445 .map_err(ExternalToolError::SetUpDir)?;
446 set_readonly_recursively(diff_wc.right_working_copy_path())
447 .map_err(ExternalToolError::SetUpDir)?;
448 invoke_external_diff(
449 ui,
450 writer,
451 tool,
452 diff_wc.temp_dir(),
453 &diff_wc.to_command_variables(true),
454 )
455}
456
457pub fn invoke_external_diff(
459 ui: &Ui,
460 writer: &mut dyn Write,
461 tool: &ExternalMergeTool,
462 diff_dir: &Path,
463 patterns: &HashMap<&str, &str>,
464) -> Result<(), DiffGenerateError> {
465 let mut cmd = Command::new(&tool.program);
467 let mut patterns = patterns.clone();
468 let absolute_left_path = Path::new(diff_dir).join(patterns["left"]);
469 let absolute_right_path = Path::new(diff_dir).join(patterns["right"]);
470 if !tool.diff_do_chdir {
471 patterns.insert(
472 "left",
473 absolute_left_path
474 .to_str()
475 .expect("temp_dir should be valid utf-8"),
476 );
477 patterns.insert(
478 "right",
479 absolute_right_path
480 .to_str()
481 .expect("temp_dir should be valid utf-8"),
482 );
483 } else {
484 cmd.current_dir(diff_dir);
485 }
486 cmd.args(interpolate_variables(&tool.diff_args, &patterns));
487
488 tracing::info!(?cmd, "Invoking the external diff generator:");
489 let mut child = cmd
490 .stdin(Stdio::null())
491 .stdout(Stdio::piped())
492 .stderr(ui.stderr_for_child().map_err(ExternalToolError::Io)?)
493 .spawn()
494 .map_err(|source| ExternalToolError::FailedToExecute {
495 tool_binary: tool.program.clone(),
496 source,
497 })?;
498 let copy_result = io::copy(&mut child.stdout.take().unwrap(), writer);
499 let exit_status = child.wait().map_err(ExternalToolError::Io)?;
502 tracing::info!(?cmd, ?exit_status, "The external diff generator exited:");
503 let exit_ok = exit_status
504 .code()
505 .is_some_and(|status| tool.diff_expected_exit_codes.contains(&status));
506 if !exit_ok {
507 writeln!(
508 ui.warning_default(),
509 "Tool exited with {exit_status} (run with --debug to see the exact invocation)",
510 )
511 .ok();
512 }
513 copy_result.map_err(ExternalToolError::Io)?;
514 Ok(())
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn test_interpolate_variables() {
523 let patterns = maplit::hashmap! {
524 "left" => "LEFT",
525 "right" => "RIGHT",
526 "left_right" => "$left $right",
527 };
528
529 assert_eq!(
530 interpolate_variables(
531 &["$left", "$1", "$right", "$2"].map(ToOwned::to_owned),
532 &patterns
533 ),
534 ["LEFT", "$1", "RIGHT", "$2"],
535 );
536
537 assert_eq!(
539 interpolate_variables(&["-o$left$right".to_owned()], &patterns),
540 ["-oLEFTRIGHT"],
541 );
542
543 assert_eq!(
545 interpolate_variables(&["($unknown $left $right)".to_owned()], &patterns),
546 ["($unknown LEFT RIGHT)"],
547 );
548
549 assert_eq!(
551 interpolate_variables(&["$lefty".to_owned()], &patterns),
552 ["$lefty"],
553 );
554
555 assert_eq!(
557 interpolate_variables(&["$left_right".to_owned()], &patterns),
558 ["$left $right"],
559 );
560 }
561
562 #[test]
563 fn test_find_all_variables() {
564 assert_eq!(
565 find_all_variables(
566 &[
567 "$left",
568 "$right",
569 "--two=$1 and $2",
570 "--can-be-part-of-string=$output",
571 "$NOT_CAPITALS",
572 "--can-repeat=$right"
573 ]
574 .map(ToOwned::to_owned),
575 )
576 .collect_vec(),
577 ["left", "right", "1", "2", "output", "right"],
578 );
579 }
580}