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