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::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::Merge;
24use jj_lib::merged_tree::MergedTree;
25use jj_lib::merged_tree::MergedTreeBuilder;
26use jj_lib::repo_path::RepoPathUiConverter;
27use jj_lib::store::Store;
28use pollster::FutureExt as _;
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 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 uses_marker_length = find_all_variables(&editor.merge_args).contains(&"marker_length");
196
197 let conflict_marker_len = if editor.merge_tool_edits_conflict_markers || uses_marker_length {
202 choose_materialized_conflict_marker_len(&file.contents)
203 } else {
204 MIN_CONFLICT_MARKER_LEN
205 };
206 let initial_output_content = if editor.merge_tool_edits_conflict_markers {
207 let options = ConflictMaterializeOptions {
208 marker_style: editor
209 .conflict_marker_style
210 .unwrap_or(default_conflict_marker_style),
211 marker_len: Some(conflict_marker_len),
212 merge: store.merge_options().clone(),
213 };
214 materialize_merge_result_to_bytes(&file.contents, &options)
215 } else {
216 BString::default()
217 };
218 assert_eq!(file.contents.num_sides(), 2);
219 let files: HashMap<&str, &[u8]> = maplit::hashmap! {
220 "base" => file.contents.get_remove(0).unwrap().as_slice(),
221 "left" => file.contents.get_add(0).unwrap().as_slice(),
222 "right" => file.contents.get_add(1).unwrap().as_slice(),
223 "output" => initial_output_content.as_slice(),
224 };
225
226 let temp_dir = new_utf8_temp_dir("jj-resolve-").map_err(ExternalToolError::SetUpDir)?;
227 let suffix = if let Some(filename) = repo_path.components().next_back() {
228 let name = filename
229 .to_fs_name()
230 .map_err(|err| err.with_path(repo_path))?;
231 format!("_{name}")
232 } else {
233 "".to_owned()
236 };
237 let mut variables: HashMap<&str, _> = files
238 .iter()
239 .map(|(role, contents)| -> Result<_, ConflictResolveError> {
240 let path = temp_dir.path().join(format!("{role}{suffix}"));
241 std::fs::write(&path, contents).map_err(ExternalToolError::SetUpDir)?;
242 if *role != "output" {
243 set_readonly_recursively(&path).map_err(ExternalToolError::SetUpDir)?;
245 }
246 Ok((
247 *role,
248 path.into_os_string()
249 .into_string()
250 .expect("temp_dir should be valid utf-8"),
251 ))
252 })
253 .try_collect()?;
254 variables.insert("marker_length", conflict_marker_len.to_string());
255 variables.insert("path", repo_path.as_internal_file_string().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_len,
298 )
299 .block_on()?
300 } else {
301 let new_file_id = store
302 .write_file(repo_path, &mut output_file_contents.as_slice())
303 .block_on()?;
304 Merge::normal(new_file_id)
305 };
306
307 if exit_status_implies_conflict && new_file_ids.is_resolved() {
312 return Err(ConflictResolveError::ExternalTool(
313 ExternalToolError::InvalidConflictMarkers { exit_status },
314 ));
315 }
316
317 let new_tree_value = match new_file_ids.into_resolved() {
318 Ok(file_id) => {
319 let executable = file.executable.expect("should have been resolved");
320 Merge::resolved(file_id.map(|id| TreeValue::File {
321 id,
322 executable,
323 copy_id: CopyId::placeholder(),
324 }))
325 }
326 Err(file_ids) => conflict.with_new_file_ids(&file_ids),
328 };
329 tree_builder.set_or_remove(repo_path.to_owned(), new_tree_value);
330 Ok(())
331}
332
333pub fn run_mergetool_external(
334 ui: &Ui,
335 path_converter: &RepoPathUiConverter,
336 editor: &ExternalMergeTool,
337 tree: &MergedTree,
338 merge_tool_files: &[MergeToolFile],
339 default_conflict_marker_style: ConflictMarkerStyle,
340) -> Result<(MergedTreeId, Option<MergeToolPartialResolutionError>), ConflictResolveError> {
341 let mut tree_builder = MergedTreeBuilder::new(tree.id());
344 let mut partial_resolution_error = None;
345 for (i, merge_tool_file) in merge_tool_files.iter().enumerate() {
346 writeln!(
347 ui.status(),
348 "Resolving conflicts in: {}",
349 path_converter.format_file_path(&merge_tool_file.repo_path)
350 )?;
351 match run_mergetool_external_single_file(
352 editor,
353 tree.store(),
354 merge_tool_file,
355 default_conflict_marker_style,
356 &mut tree_builder,
357 ) {
358 Ok(()) => {}
359 Err(err) if i == 0 => {
360 return Err(err);
362 }
363 Err(err) => {
364 partial_resolution_error = Some(MergeToolPartialResolutionError {
367 source: err,
368 resolved_count: i,
369 });
370 break;
371 }
372 }
373 }
374 let new_tree = tree_builder.write_tree(tree.store())?;
375 Ok((new_tree, partial_resolution_error))
376}
377
378pub fn edit_diff_external(
379 editor: &ExternalMergeTool,
380 trees: [&MergedTree; 2],
381 matcher: &dyn Matcher,
382 instructions: Option<&str>,
383 base_ignores: Arc<GitIgnoreFile>,
384 default_conflict_marker_style: ConflictMarkerStyle,
385) -> Result<MergedTreeId, DiffEditError> {
386 let conflict_marker_style = editor
387 .conflict_marker_style
388 .unwrap_or(default_conflict_marker_style);
389
390 let got_output_field = find_all_variables(&editor.edit_args).contains(&"output");
391 let diff_type = if got_output_field {
392 DiffType::ThreeWay
393 } else {
394 DiffType::TwoWay
395 };
396 let diffedit_wc = DiffEditWorkingCopies::check_out(
397 trees,
398 matcher,
399 diff_type,
400 instructions,
401 conflict_marker_style,
402 )?;
403
404 let patterns = diffedit_wc.working_copies.to_command_variables(false);
405 let mut cmd = Command::new(&editor.program);
406 cmd.args(interpolate_variables(&editor.edit_args, &patterns));
407 tracing::info!(?cmd, "Invoking the external diff editor:");
408 let exit_status = cmd
409 .status()
410 .map_err(|e| ExternalToolError::FailedToExecute {
411 tool_binary: editor.program.clone(),
412 source: e,
413 })?;
414 if !exit_status.success() {
415 return Err(DiffEditError::from(ExternalToolError::ToolAborted {
416 exit_status,
417 }));
418 }
419
420 diffedit_wc.snapshot_results(base_ignores)
421}
422
423pub fn generate_diff(
425 ui: &Ui,
426 writer: &mut dyn Write,
427 trees: [&MergedTree; 2],
428 matcher: &dyn Matcher,
429 tool: &ExternalMergeTool,
430 default_conflict_marker_style: ConflictMarkerStyle,
431 width: usize,
432) -> Result<(), DiffGenerateError> {
433 let conflict_marker_style = tool
434 .conflict_marker_style
435 .unwrap_or(default_conflict_marker_style);
436 let diff_wc = check_out_trees(trees, matcher, DiffType::TwoWay, conflict_marker_style)?;
437 diff_wc.set_left_readonly()?;
438 diff_wc.set_right_readonly()?;
439 let mut patterns = diff_wc.to_command_variables(true);
440 patterns.insert("width", width.to_string());
441 invoke_external_diff(ui, writer, tool, diff_wc.temp_dir(), &patterns)
442}
443
444pub fn invoke_external_diff(
446 ui: &Ui,
447 writer: &mut dyn Write,
448 tool: &ExternalMergeTool,
449 diff_dir: &Path,
450 patterns: &HashMap<&str, String>,
451) -> Result<(), DiffGenerateError> {
452 let mut cmd = Command::new(&tool.program);
454 let mut patterns = patterns.clone();
455 if !tool.diff_do_chdir {
456 let absolute_left_path = Path::new(diff_dir).join(&patterns["left"]);
457 let absolute_right_path = Path::new(diff_dir).join(&patterns["right"]);
458 patterns.insert(
459 "left",
460 absolute_left_path
461 .into_os_string()
462 .into_string()
463 .expect("temp_dir should be valid utf-8"),
464 );
465 patterns.insert(
466 "right",
467 absolute_right_path
468 .into_os_string()
469 .into_string()
470 .expect("temp_dir should be valid utf-8"),
471 );
472 } else {
473 cmd.current_dir(diff_dir);
474 }
475 cmd.args(interpolate_variables(&tool.diff_args, &patterns));
476
477 tracing::info!(?cmd, "Invoking the external diff generator:");
478 let mut child = cmd
479 .stdin(Stdio::null())
480 .stdout(Stdio::piped())
481 .stderr(ui.stderr_for_child().map_err(ExternalToolError::Io)?)
482 .spawn()
483 .map_err(|source| ExternalToolError::FailedToExecute {
484 tool_binary: tool.program.clone(),
485 source,
486 })?;
487 let copy_result = io::copy(&mut child.stdout.take().unwrap(), writer);
488 let exit_status = child.wait().map_err(ExternalToolError::Io)?;
491 tracing::info!(?cmd, ?exit_status, "The external diff generator exited:");
492 let exit_ok = exit_status
493 .code()
494 .is_some_and(|status| tool.diff_expected_exit_codes.contains(&status));
495 if !exit_ok {
496 writeln!(
497 ui.warning_default(),
498 "Tool exited with {exit_status} (run with --debug to see the exact invocation)",
499 )
500 .ok();
501 }
502 copy_result.map_err(ExternalToolError::Io)?;
503 Ok(())
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_interpolate_variables() {
512 let patterns = maplit::hashmap! {
513 "left" => "LEFT",
514 "right" => "RIGHT",
515 "left_right" => "$left $right",
516 };
517
518 assert_eq!(
519 interpolate_variables(
520 &["$left", "$1", "$right", "$2"].map(ToOwned::to_owned),
521 &patterns
522 ),
523 ["LEFT", "$1", "RIGHT", "$2"],
524 );
525
526 assert_eq!(
528 interpolate_variables(&["-o$left$right".to_owned()], &patterns),
529 ["-oLEFTRIGHT"],
530 );
531
532 assert_eq!(
534 interpolate_variables(&["($unknown $left $right)".to_owned()], &patterns),
535 ["($unknown LEFT RIGHT)"],
536 );
537
538 assert_eq!(
540 interpolate_variables(&["$lefty".to_owned()], &patterns),
541 ["$lefty"],
542 );
543
544 assert_eq!(
546 interpolate_variables(&["$left_right".to_owned()], &patterns),
547 ["$left $right"],
548 );
549 }
550
551 #[test]
552 fn test_find_all_variables() {
553 assert_eq!(
554 find_all_variables(
555 &[
556 "$left",
557 "$right",
558 "--two=$1 and $2",
559 "--can-be-part-of-string=$output",
560 "$NOT_CAPITALS",
561 "--can-repeat=$right"
562 ]
563 .map(ToOwned::to_owned),
564 )
565 .collect_vec(),
566 ["left", "right", "1", "2", "output", "right"],
567 );
568 }
569}