1use std::io::Write;
7use std::path::Path;
8use std::process::{Command, Stdio};
9
10use similar::{ChangeTag, TextDiff};
11use tempfile::NamedTempFile;
12
13use crate::combined_diff_patch::{format_combined_diff_body, CombinedDiffWsOptions};
14use crate::combined_tree_diff::CombinedParentSide;
15use crate::config::{parse_bool, ConfigSet};
16use crate::crlf::{get_file_attrs, load_gitattributes, DiffAttr, FileAttrs};
17use crate::diff::{detect_renames, diff_trees, DiffStatus};
18use crate::objects::{parse_commit, parse_tree, ObjectId, ObjectKind};
19use crate::odb::Odb;
20use crate::quote_path::format_diff_path_with_prefix;
21use crate::textconv_cache::{read_textconv_cache, write_textconv_cache};
22
23#[must_use]
25pub fn combined_diff_paths(odb: &Odb, commit_tree: &ObjectId, parents: &[ObjectId]) -> Vec<String> {
26 if parents.len() < 2 {
27 return Vec::new();
28 }
29 let mut per_parent: Vec<std::collections::HashSet<String>> = Vec::new();
30 for p in parents {
31 let Ok(po) = odb.read(p) else {
32 continue;
33 };
34 let Ok(pc) = parse_commit(&po.data) else {
35 continue;
36 };
37 let Ok(entries) = diff_trees(odb, Some(&pc.tree), Some(commit_tree), "") else {
38 continue;
39 };
40 let paths: std::collections::HashSet<String> =
41 entries.iter().map(|e| e.path().to_string()).collect();
42 per_parent.push(paths);
43 }
44 if per_parent.is_empty() {
45 return Vec::new();
46 }
47 let mut common = per_parent[0].clone();
48 for s in &per_parent[1..] {
49 common = common.intersection(s).cloned().collect();
50 }
51 if common.is_empty() {
52 return Vec::new();
53 }
54 let mut ordered = paths_in_tree_order(odb, commit_tree, "", &common);
55 if ordered.len() < common.len() {
59 let seen: std::collections::HashSet<String> = ordered.iter().cloned().collect();
60 let mut rest: Vec<String> = common.difference(&seen).cloned().collect();
61 rest.sort();
62 ordered.extend(rest);
63 }
64 ordered
65}
66
67#[must_use]
71pub fn combined_merge_parent_blob_paths(
72 odb: &Odb,
73 merge_path: &str,
74 parent_trees: &[ObjectId],
75 result_tree: &ObjectId,
76 rename_threshold: u32,
77) -> Option<Vec<String>> {
78 if parent_trees.len() < 2 {
79 return None;
80 }
81 let mut per_parent: Vec<String> = Vec::with_capacity(parent_trees.len());
82 for t in parent_trees {
83 if blob_oid_at_path(odb, t, merge_path).is_some() {
84 per_parent.push(merge_path.to_string());
85 } else {
86 per_parent.push(String::new());
87 }
88 }
89 if per_parent.iter().all(|p| !p.is_empty()) {
90 return None;
91 }
92 let mut any_rename = false;
93 for (i, t) in parent_trees.iter().enumerate() {
94 if !per_parent[i].is_empty() {
95 continue;
96 }
97 let entries = diff_trees(odb, Some(t), Some(result_tree), "").ok()?;
102 let with_rn = detect_renames(odb, None, entries, rename_threshold);
103 let mut found: Option<String> = None;
104 for e in with_rn {
105 if e.status != DiffStatus::Renamed {
106 continue;
107 }
108 let new_p = e.new_path.as_deref().unwrap_or("");
109 if new_p != merge_path {
110 continue;
111 }
112 let old_p = e.old_path.clone()?;
113 if blob_oid_at_path(odb, t, &old_p).is_some() {
114 if found.is_some() {
115 return None;
116 }
117 found = Some(old_p);
118 }
119 }
120 let p = found?;
121 per_parent[i] = p;
122 any_rename = true;
123 }
124 any_rename.then_some(per_parent)
125}
126
127fn blob_mode_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<u32> {
129 let mut current = *tree;
130 let parts: Vec<&str> = path.split('/').collect();
131 for (pi, part) in parts.iter().enumerate() {
132 let obj = odb.read(¤t).ok()?;
133 let entries = crate::objects::parse_tree(&obj.data).ok()?;
134 let found = entries
135 .iter()
136 .find(|e| std::str::from_utf8(&e.name).ok() == Some(*part))?;
137 if pi + 1 == parts.len() {
138 return Some(found.mode);
139 }
140 if found.mode != 0o040000 {
141 return None;
142 }
143 current = found.oid;
144 }
145 None
146}
147
148pub fn enrich_combined_path_renames(
156 odb: &Odb,
157 path: &mut crate::combined_tree_diff::CombinedDiffPath,
158 parent_trees: &[ObjectId],
159 result_tree: &ObjectId,
160 rename_threshold: u32,
161) {
162 use crate::combined_tree_diff::CombinedParentStatus;
163 if parent_trees.len() != path.parents.len() {
164 return;
165 }
166 let Some(parent_paths) = combined_merge_parent_blob_paths(
167 odb,
168 &path.path,
169 parent_trees,
170 result_tree,
171 rename_threshold,
172 ) else {
173 return;
174 };
175 for (i, side) in path.parents.iter_mut().enumerate() {
176 if side.status != CombinedParentStatus::Added {
177 continue;
178 }
179 let src = &parent_paths[i];
180 if src.is_empty() || src == &path.path {
181 continue;
182 }
183 let (Some(oid), Some(mode)) = (
184 blob_oid_at_path(odb, &parent_trees[i], src),
185 blob_mode_at_path(odb, &parent_trees[i], src),
186 ) else {
187 continue;
188 };
189 side.status = CombinedParentStatus::Renamed;
190 side.oid = oid;
191 side.mode = mode;
192 side.rename_from = Some(src.clone());
193 }
194}
195
196#[must_use]
199pub fn all_blob_paths_in_tree_order(odb: &Odb, tree_oid: &ObjectId) -> Vec<String> {
200 all_blob_paths_dfs(odb, tree_oid, "")
201}
202
203fn all_blob_paths_dfs(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Vec<String> {
204 let Ok(obj) = odb.read(tree_oid) else {
205 return Vec::new();
206 };
207 if obj.kind != ObjectKind::Tree {
208 return Vec::new();
209 }
210 let Ok(entries) = parse_tree(&obj.data) else {
211 return Vec::new();
212 };
213 let mut out = Vec::new();
214 for e in entries {
215 let name = String::from_utf8_lossy(&e.name);
216 let path = if prefix.is_empty() {
217 name.into_owned()
218 } else {
219 format!("{prefix}/{name}")
220 };
221 if e.mode == 0o040000 {
222 out.extend(all_blob_paths_dfs(odb, &e.oid, &path));
223 } else {
224 out.push(path);
225 }
226 }
227 out
228}
229
230fn paths_in_tree_order(
233 odb: &Odb,
234 tree_oid: &ObjectId,
235 prefix: &str,
236 want: &std::collections::HashSet<String>,
237) -> Vec<String> {
238 let Ok(obj) = odb.read(tree_oid) else {
239 return Vec::new();
240 };
241 if obj.kind != ObjectKind::Tree {
242 return Vec::new();
243 }
244 let Ok(entries) = parse_tree(&obj.data) else {
245 return Vec::new();
246 };
247 let mut out = Vec::new();
248 for e in entries {
249 let name = String::from_utf8_lossy(&e.name);
250 let path = if prefix.is_empty() {
251 name.into_owned()
252 } else {
253 format!("{prefix}/{name}")
254 };
255 if e.mode == 0o040000 {
256 out.extend(paths_in_tree_order(odb, &e.oid, &path, want));
257 } else if want.contains(&path) {
258 out.push(path);
259 }
260 }
261 out
262}
263
264fn attrs_for_repo_path(git_dir: &Path, path: &str) -> FileAttrs {
266 let work_tree = git_dir.parent().unwrap_or(git_dir);
267 let rules = load_gitattributes(work_tree);
268 let config = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
269 get_file_attrs(&rules, path, false, &config)
270}
271
272#[must_use]
274pub fn is_binary_for_diff(git_dir: &Path, path: &str, blob: &[u8]) -> bool {
275 let fa = attrs_for_repo_path(git_dir, path);
276 if matches!(fa.diff_attr, DiffAttr::Unset) {
277 return true;
278 }
279 crate::crlf::is_binary(blob)
280}
281
282fn diff_driver_binary_config(config: &ConfigSet, driver: &str) -> bool {
284 let key = format!("diff.{driver}.binary");
285 config
286 .get(&key)
287 .is_some_and(|v| parse_bool(v.as_str()).unwrap_or(false))
288}
289
290#[must_use]
296pub fn diff_forced_binary_by_driver(
297 git_dir: &Path,
298 config: &ConfigSet,
299 path: &str,
300 old_mode: &str,
301 new_mode: &str,
302) -> bool {
303 let fa = attrs_for_repo_path(git_dir, path);
304 let DiffAttr::Driver(driver) = fa.diff_attr else {
305 return false;
306 };
307 if !diff_driver_binary_config(config, &driver) {
308 return false;
309 }
310 if old_mode == "120000" || new_mode == "120000" {
311 return false;
312 }
313 true
314}
315
316fn textconv_cmd_needs_shell_wrapper(cmd_line: &str) -> bool {
319 cmd_line.chars().any(|c| {
320 matches!(
321 c,
322 '|' | '&'
323 | ';'
324 | '<'
325 | '>'
326 | '('
327 | ')'
328 | '$'
329 | '`'
330 | '\\'
331 | '"'
332 | '\''
333 | ' '
334 | '\t'
335 | '\n'
336 | '*'
337 | '?'
338 | '['
339 | '#'
340 | '~'
341 | '='
342 | '%'
343 )
344 })
345}
346
347pub fn run_textconv_raw(
354 command_cwd: &Path,
355 config: &ConfigSet,
356 driver: &str,
357 input: &[u8],
358) -> Option<Vec<u8>> {
359 let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
360 cmd_line = cmd_line.trim_end().to_string();
361 let stdin_mode = if cmd_line.ends_with('<') {
362 let t = cmd_line.trim_end_matches('<').trim_end();
363 cmd_line = t.to_string();
364 true
365 } else {
366 false
367 };
368 if stdin_mode {
369 let mut child = Command::new("sh")
370 .arg("-c")
371 .arg(&cmd_line)
372 .current_dir(command_cwd)
373 .stdin(Stdio::piped())
374 .stdout(Stdio::piped())
375 .stderr(Stdio::null())
376 .spawn()
377 .ok()?;
378 let mut stdin = child.stdin.take()?;
379 stdin.write_all(input).ok()?;
380 drop(stdin);
381 let out = child.wait_with_output().ok()?;
382 return if out.status.success() {
383 Some(out.stdout)
384 } else {
385 None
386 };
387 }
388
389 let mut tmp = NamedTempFile::new().ok()?;
390 tmp.write_all(input).ok()?;
391 tmp.flush().ok()?;
392 let path = tmp.path().to_owned();
393
394 let out = if textconv_cmd_needs_shell_wrapper(&cmd_line) {
395 Command::new("sh")
396 .current_dir(command_cwd)
397 .arg("-c")
398 .arg(format!("{} \"$@\"", cmd_line))
399 .arg(&cmd_line)
400 .arg(&path)
401 .stdout(Stdio::piped())
402 .stderr(Stdio::null())
403 .output()
404 .ok()?
405 } else {
406 Command::new("sh")
407 .current_dir(command_cwd)
408 .arg(&cmd_line)
409 .arg(&path)
410 .stdout(Stdio::piped())
411 .stderr(Stdio::null())
412 .output()
413 .ok()?
414 };
415
416 if !out.status.success() {
417 return None;
418 }
419 Some(out.stdout)
420}
421
422pub fn run_textconv(
424 command_cwd: &Path,
425 config: &ConfigSet,
426 driver: &str,
427 input: &[u8],
428) -> Option<String> {
429 run_textconv_raw(command_cwd, config, driver, input)
430 .map(|b| String::from_utf8_lossy(&b).into_owned())
431}
432
433pub fn diff_textconv_cmd_line(config: &ConfigSet, driver: &str) -> Option<String> {
434 let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
435 cmd_line = cmd_line.trim_end().to_string();
436 if cmd_line.ends_with('<') {
437 let t = cmd_line.trim_end_matches('<').trim_end();
438 cmd_line = t.to_string();
439 }
440 Some(cmd_line)
441}
442
443pub fn diff_cachetextconv_enabled(config: &ConfigSet, driver: &str) -> bool {
444 config
445 .get(&format!("diff.{driver}.cachetextconv"))
446 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "yes" | "1" | "on"))
447 .unwrap_or(false)
448}
449
450#[must_use]
455pub fn diff_textconv_active(git_dir: &Path, config: &ConfigSet, path: &str) -> bool {
456 let fa = attrs_for_repo_path(git_dir, path);
457 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
458 return false;
459 };
460 diff_textconv_cmd_line(config, driver).is_some()
461}
462
463#[must_use]
466pub fn diff_attr_forces_binary(git_dir: &Path, path: &str) -> bool {
467 matches!(
468 attrs_for_repo_path(git_dir, path).diff_attr,
469 DiffAttr::Unset
470 )
471}
472
473#[must_use]
476pub fn diff_attr_forces_text(git_dir: &Path, path: &str) -> bool {
477 matches!(attrs_for_repo_path(git_dir, path).diff_attr, DiffAttr::Set)
478}
479
480#[must_use]
487pub fn diff_attr_external_driver(
488 git_dir: &Path,
489 config: &ConfigSet,
490 path: &str,
491) -> Option<(String, bool)> {
492 let fa = attrs_for_repo_path(git_dir, path);
493 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
494 return None;
495 };
496 let cmd = config.get(&format!("diff.{driver}.command"))?;
497 if cmd.trim().is_empty() {
498 return None;
499 }
500 let trust = config
501 .get(&format!("diff.{driver}.trustExitCode"))
502 .and_then(|v| parse_bool(v.as_str()).ok())
503 .unwrap_or(false);
504 Some((cmd, trust))
505}
506
507fn textconv_command_cwd(git_dir: &Path) -> std::path::PathBuf {
508 git_dir.parent().unwrap_or(git_dir).to_path_buf()
509}
510
511fn blob_text_for_diff_inner(
512 odb: Option<&Odb>,
513 git_dir: &Path,
514 config: &ConfigSet,
515 path: &str,
516 blob: &[u8],
517 blob_oid: Option<&ObjectId>,
518 use_textconv: bool,
519) -> String {
520 if !use_textconv {
521 return String::from_utf8_lossy(blob).into_owned();
522 }
523 let fa = attrs_for_repo_path(git_dir, path);
524 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
525 return String::from_utf8_lossy(blob).into_owned();
526 };
527 let Some(cmd_line) = diff_textconv_cmd_line(config, driver) else {
528 return String::from_utf8_lossy(blob).into_owned();
529 };
530 let want_cache = diff_cachetextconv_enabled(config, driver);
531 if want_cache {
532 if let (Some(odb), Some(oid)) = (odb, blob_oid) {
533 if let Some(bytes) = read_textconv_cache(odb, git_dir, driver, &cmd_line, oid) {
534 return String::from_utf8_lossy(&bytes).into_owned();
535 }
536 }
537 }
538 let cwd = textconv_command_cwd(git_dir);
539 let Some(t) = run_textconv(&cwd, config, driver, blob) else {
540 return String::from_utf8_lossy(blob).into_owned();
541 };
542 if want_cache {
543 if let (Some(odb), Some(oid)) = (odb, blob_oid) {
544 write_textconv_cache(odb, git_dir, driver, &cmd_line, oid, t.as_bytes());
545 }
546 }
547 t
548}
549
550#[must_use]
553pub fn blob_text_for_diff_with_oid(
554 odb: &Odb,
555 git_dir: &Path,
556 config: &ConfigSet,
557 path: &str,
558 blob: &[u8],
559 blob_oid: &ObjectId,
560 use_textconv: bool,
561) -> String {
562 blob_text_for_diff_inner(
563 Some(odb),
564 git_dir,
565 config,
566 path,
567 blob,
568 Some(blob_oid),
569 use_textconv,
570 )
571}
572
573pub fn convert_blob_to_worktree_for_path(
578 git_dir: &Path,
579 work_tree: &Path,
580 index: Option<&crate::index::Index>,
581 odb: &Odb,
582 path: &str,
583 blob: &[u8],
584 oid_hex: Option<&str>,
585) -> std::io::Result<Vec<u8>> {
586 let config = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
587 let conv = crate::crlf::ConversionConfig::from_config(&config);
588 let rules = match index {
589 Some(idx) => crate::crlf::load_gitattributes_for_checkout(work_tree, path, idx, odb),
590 None => crate::crlf::load_gitattributes(work_tree),
591 };
592 let file_attrs = crate::crlf::get_file_attrs(&rules, path, false, &config);
593 crate::crlf::convert_to_worktree_eager(blob, path, &conv, &file_attrs, oid_hex, None)
594 .map_err(std::io::Error::other)
595}
596
597pub fn blob_text_for_diff(
602 git_dir: &Path,
603 config: &ConfigSet,
604 path: &str,
605 blob: &[u8],
606 use_textconv: bool,
607) -> String {
608 blob_text_for_diff_inner(None, git_dir, config, path, blob, None, use_textconv)
609}
610
611#[allow(clippy::too_many_arguments)]
613pub fn format_parent_patch(
614 git_dir: &Path,
615 config: &ConfigSet,
616 odb: &Odb,
617 path: &str,
618 parent_tree: &ObjectId,
619 result_tree: &ObjectId,
620 abbrev: usize,
621 context: usize,
622 use_textconv: bool,
623) -> Option<String> {
624 let entries = diff_trees(odb, Some(parent_tree), Some(result_tree), "").ok()?;
625 let entry = entries.iter().find(|e| e.path() == path)?;
626 if entry.status == DiffStatus::Unmerged {
627 return None;
628 }
629
630 let old_blob = read_blob(odb, &entry.old_oid);
631 let new_blob = read_blob(odb, &entry.new_oid);
632 let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
633 let binary = !textconv_for_patch
634 && (is_binary_for_diff(git_dir, path, &old_blob)
635 || is_binary_for_diff(git_dir, path, &new_blob));
636
637 let old_abbrev = abbrev_hex(&entry.old_oid, abbrev);
638 let new_abbrev = abbrev_hex(&entry.new_oid, abbrev);
639
640 let mut out = String::new();
641 out.push_str(&format!("diff --git a/{path} b/{path}\n"));
642 let (old_disp, new_disp) = match entry.status {
645 DiffStatus::Added => {
646 out.push_str(&format!("new file mode {}\n", entry.new_mode));
647 out.push_str(&format!("index {old_abbrev}..{new_abbrev}\n"));
648 ("/dev/null".to_string(), format!("b/{path}"))
649 }
650 DiffStatus::Deleted => {
651 out.push_str(&format!("deleted file mode {}\n", entry.old_mode));
652 out.push_str(&format!("index {old_abbrev}..{new_abbrev}\n"));
653 (format!("a/{path}"), "/dev/null".to_string())
654 }
655 _ => {
656 if entry.old_mode != entry.new_mode {
657 out.push_str(&format!("old mode {}\n", entry.old_mode));
658 out.push_str(&format!("new mode {}\n", entry.new_mode));
659 out.push_str(&format!("index {old_abbrev}..{new_abbrev}\n"));
660 } else {
661 out.push_str(&format!(
662 "index {old_abbrev}..{new_abbrev} {}\n",
663 entry.new_mode
664 ));
665 }
666 (format!("a/{path}"), format!("b/{path}"))
667 }
668 };
669
670 if binary {
671 out.push_str(&format!("Binary files {old_disp} and {new_disp} differ\n"));
672 return Some(out);
673 }
674
675 let old_t = if textconv_for_patch {
676 blob_text_for_diff_with_oid(odb, git_dir, config, path, &old_blob, &entry.old_oid, true)
677 } else {
678 blob_text_for_diff(git_dir, config, path, &old_blob, use_textconv)
679 };
680 let new_t = if textconv_for_patch {
681 blob_text_for_diff_with_oid(odb, git_dir, config, path, &new_blob, &entry.new_oid, true)
682 } else {
683 blob_text_for_diff(git_dir, config, path, &new_blob, use_textconv)
684 };
685 let patch = crate::diff::unified_diff_with_prefix(
688 &old_t,
689 &new_t,
690 &old_disp,
691 &new_disp,
692 context,
693 0,
694 "",
695 "",
696 true,
697 config.quote_path_fully(),
698 );
699 out.push_str(&patch);
700 Some(out)
701}
702
703pub fn format_combined_binary_header(
705 path: &str,
706 parent_oids: &[ObjectId],
707 result_oid: &ObjectId,
708 abbrev: usize,
709 use_cc_word: bool,
710) -> String {
711 format_combined_binary_header_n(path, parent_oids, result_oid, abbrev, use_cc_word)
712}
713
714#[must_use]
716pub fn format_combined_binary_header_n(
717 path: &str,
718 parent_oids: &[ObjectId],
719 result_oid: &ObjectId,
720 abbrev: usize,
721 use_cc_word: bool,
722) -> String {
723 let idx: Vec<String> = parent_oids.iter().map(|o| abbrev_hex(o, abbrev)).collect();
724 let res = abbrev_hex(result_oid, abbrev);
725 let kind = if use_cc_word { "cc" } else { "combined" };
726 format!(
727 "diff --{kind} {path}\nindex {}..{res}\nBinary files differ\n",
728 idx.join(",")
729 )
730}
731
732pub fn format_combined_binary(
734 path: &str,
735 parent_oids: &[ObjectId],
736 result_oid: &ObjectId,
737 abbrev: usize,
738 use_cc_word: bool,
739) -> String {
740 format_combined_binary_header_n(path, parent_oids, result_oid, abbrev, use_cc_word)
741}
742
743fn push_combined_file_headers(
744 out: &mut String,
745 merge_path: &str,
746 parent_paths: &[String],
747 parent_sides: &[CombinedParentSide],
748 combined_all_paths: bool,
749 quote_path_fully: bool,
750) {
751 let a_prefix = "a/";
752 let b_prefix = "b/";
753 if combined_all_paths {
754 for (i, p) in parent_paths.iter().enumerate() {
755 let added_no_rename = parent_sides.get(i).is_some_and(|s| {
760 s.status == crate::combined_tree_diff::CombinedParentStatus::Added
761 }) && (p.is_empty() || p == merge_path);
762 if added_no_rename {
763 out.push_str("--- /dev/null\n");
764 } else {
765 let line = format_diff_path_with_prefix(a_prefix, p, quote_path_fully);
766 out.push_str("--- ");
767 out.push_str(&line);
768 out.push('\n');
769 }
770 }
771 let line = format_diff_path_with_prefix(b_prefix, merge_path, quote_path_fully);
772 out.push_str("+++ ");
773 out.push_str(&line);
774 out.push('\n');
775 } else {
776 let la = format_diff_path_with_prefix(a_prefix, merge_path, quote_path_fully);
777 let lb = format_diff_path_with_prefix(b_prefix, merge_path, quote_path_fully);
778 out.push_str("--- ");
779 out.push_str(&la);
780 out.push('\n');
781 out.push_str("+++ ");
782 out.push_str(&lb);
783 out.push('\n');
784 }
785}
786
787#[allow(clippy::too_many_arguments)]
793pub fn format_combined_textconv_patch(
794 git_dir: &Path,
795 config: &ConfigSet,
796 odb: &Odb,
797 path: &str,
798 parent_trees: &[ObjectId],
799 result_tree: &ObjectId,
800 abbrev: usize,
801 context: usize,
802 use_cc_word: bool,
803 use_textconv: bool,
804 ws: CombinedDiffWsOptions,
805 combined_all_paths: bool,
806 parent_blob_paths: Option<&[String]>,
807 parent_sides: &[CombinedParentSide],
808 quote_path_fully: bool,
809) -> Option<String> {
810 if parent_trees.len() < 2 {
811 return None;
812 }
813 let parent_paths: Vec<&str> = if let Some(ps) = parent_blob_paths {
814 if ps.len() != parent_trees.len() {
815 return None;
816 }
817 ps.iter().map(|s| s.as_str()).collect()
818 } else {
819 vec![path; parent_trees.len()]
820 };
821
822 let mut parent_blobs = Vec::with_capacity(parent_trees.len());
823 let mut parent_oids = Vec::with_capacity(parent_trees.len());
824 for (i, t) in parent_trees.iter().enumerate() {
825 let p = parent_paths[i];
826 match blob_oid_at_path(odb, t, p) {
830 Some(oid) => {
831 parent_blobs.push(read_blob(odb, &oid));
832 parent_oids.push(oid);
833 }
834 None => {
835 parent_blobs.push(Vec::new());
836 parent_oids.push(ObjectId::zero());
837 }
838 }
839 }
840 let result_blob = read_blob_at_path(odb, result_tree, path)?;
841 let roid = blob_oid_at_path(odb, result_tree, path)?;
842
843 let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
844 if !textconv_for_patch
845 && (parent_blobs
846 .iter()
847 .any(|b| is_binary_for_diff(git_dir, path, b))
848 || is_binary_for_diff(git_dir, path, &result_blob))
849 {
850 return Some(format_combined_binary(
851 path,
852 &parent_oids,
853 &roid,
854 abbrev,
855 use_cc_word,
856 ));
857 }
858
859 let mut parent_texts = Vec::with_capacity(parent_trees.len());
860 for (i, blob) in parent_blobs.iter().enumerate() {
861 let p = parent_paths[i];
862 let oid = &parent_oids[i];
863 let t = if textconv_for_patch {
864 blob_text_for_diff_with_oid(odb, git_dir, config, p, blob, oid, true)
865 } else {
866 blob_text_for_diff(git_dir, config, p, blob, use_textconv)
867 };
868 parent_texts.push(t);
869 }
870 let tr = if textconv_for_patch {
871 blob_text_for_diff_with_oid(odb, git_dir, config, path, &result_blob, &roid, true)
872 } else {
873 blob_text_for_diff(git_dir, config, path, &result_blob, use_textconv)
874 };
875
876 let idx: Vec<String> = parent_oids.iter().map(|o| abbrev_hex(o, abbrev)).collect();
877 let ra = abbrev_hex(&roid, abbrev);
878 let kind = if use_cc_word { "cc" } else { "combined" };
879
880 let header_paths: Vec<String> = if combined_all_paths {
881 parent_paths.iter().map(|s| (*s).to_string()).collect()
882 } else {
883 Vec::new()
884 };
885
886 let mut out = String::new();
887 out.push_str(&format!("diff --{kind} {path}\n"));
888 out.push_str(&format!("index {}..{ra}\n", idx.join(",")));
889 if combined_all_paths {
890 push_combined_file_headers(
891 &mut out,
892 path,
893 &header_paths,
894 parent_sides,
895 true,
896 quote_path_fully,
897 );
898 } else {
899 push_combined_file_headers(&mut out, path, &[], parent_sides, false, quote_path_fully);
900 }
901 out.push_str(&format_combined_diff_body(
902 &parent_texts,
903 &tr,
904 context,
905 use_cc_word,
906 ws,
907 ));
908 Some(out)
909}
910
911#[must_use]
914pub fn format_gitlink_unmerged_conflict_combined(
915 path: &str,
916 stage2_oid: &ObjectId,
917 stage3_oid: &ObjectId,
918 result_subproject_line: &str,
919 abbrev: usize,
920) -> String {
921 let p1a = abbrev_hex(stage2_oid, abbrev);
922 let p2a = abbrev_hex(stage3_oid, abbrev);
923 let z = crate::diff::zero_oid();
924 let za = abbrev_hex(&z, abbrev);
925
926 let t_ours = format!("Subproject commit {}", stage2_oid.to_hex());
927 let t_theirs = format!("Subproject commit {}", stage3_oid.to_hex());
928 let tr = result_subproject_line.trim_end_matches('\n').to_owned();
929
930 let mut out = String::new();
931 out.push_str(&format!("diff --cc {path}\n"));
932 out.push_str(&format!("index {p1a},{p2a}..{za}\n"));
933 out.push_str(&format!("--- a/{path}\n"));
934 out.push_str(&format!("+++ b/{path}\n"));
935 out.push_str(&combined_hunk_two_parents(&t_ours, &t_theirs, &tr));
936 out
937}
938
939#[allow(clippy::too_many_arguments)]
941pub fn format_worktree_conflict_combined(
942 git_dir: &Path,
943 config: &ConfigSet,
944 odb: &Odb,
945 path: &str,
946 stage1_oid: &ObjectId,
947 stage2_oid: &ObjectId,
948 stage3_oid: &ObjectId,
949 worktree_bytes: &[u8],
950 abbrev: usize,
951) -> String {
952 let ours_blob = read_blob(odb, stage2_oid);
953 let theirs_blob = read_blob(odb, stage3_oid);
954 let _base_blob = read_blob(odb, stage1_oid);
955
956 let use_conv = !worktree_bytes.contains(&0);
957 let textconv_cache_path = diff_textconv_active(git_dir, config, path);
958 let t_ours = if textconv_cache_path {
959 blob_text_for_diff_with_oid(odb, git_dir, config, path, &ours_blob, stage2_oid, true)
960 } else {
961 blob_text_for_diff(git_dir, config, path, &ours_blob, use_conv)
962 };
963 let t_theirs = if textconv_cache_path {
964 blob_text_for_diff_with_oid(odb, git_dir, config, path, &theirs_blob, stage3_oid, true)
965 } else {
966 blob_text_for_diff(git_dir, config, path, &theirs_blob, use_conv)
967 };
968 let wt_text = if textconv_cache_path || use_conv {
969 blob_text_for_diff(git_dir, config, path, worktree_bytes, true)
970 } else {
971 String::from_utf8_lossy(worktree_bytes).into_owned()
972 };
973 let wt_for_conflict = wt_text.clone();
974
975 let p1a = abbrev_hex(stage2_oid, abbrev);
976 let p2a = abbrev_hex(stage3_oid, abbrev);
977 let z = crate::diff::zero_oid();
978 let za = abbrev_hex(&z, abbrev);
979
980 let mut out = String::new();
981 out.push_str(&format!("diff --cc {path}\n"));
982 out.push_str(&format!("index {p1a},{p2a}..{za}\n"));
983 out.push_str(&format!("--- a/{path}\n"));
984 out.push_str(&format!("+++ b/{path}\n"));
985
986 if wt_text.contains("<<<<<<<") && wt_text.contains(">>>>>>>") {
987 out.push_str(&conflict_combined_body(&wt_for_conflict));
988 } else {
989 out.push_str(&format_combined_diff_body(
990 &[t_ours, t_theirs],
991 &wt_text,
992 3,
993 true,
994 CombinedDiffWsOptions::default(),
995 ));
996 }
997 out
998}
999
1000fn conflict_combined_body(wt: &str) -> String {
1002 let lines: Vec<&str> = wt.lines().collect();
1003 let mut body = String::new();
1004 let mut i = 0usize;
1005 while i < lines.len() {
1006 let line = lines[i];
1007 if line.starts_with("<<<<<<< ") {
1008 let mut hunk_new = 0u32;
1009 let mut ours_count = 0u32;
1010 let mut theirs_count = 0u32;
1011 body.push_str(&format!("++{line}\n"));
1012 hunk_new += 1;
1013 i += 1;
1014 while i < lines.len() && !lines[i].starts_with("=======") {
1015 body.push_str(&format!(" +{}\n", lines[i]));
1016 ours_count += 1;
1017 hunk_new += 1;
1018 i += 1;
1019 }
1020 if i < lines.len() && lines[i].starts_with("=======") {
1021 body.push_str("++=======\n");
1022 hunk_new += 1;
1023 i += 1;
1024 }
1025 while i < lines.len() && !lines[i].starts_with(">>>>>>>") {
1026 body.push_str(&format!("+ {}\n", lines[i]));
1027 theirs_count += 1;
1028 hunk_new += 1;
1029 i += 1;
1030 }
1031 if i < lines.len() {
1032 let closing = lines[i];
1033 body.push_str(&format!("++{closing}\n"));
1034 hunk_new += 1;
1035 }
1036 let header = format!(
1037 "@@@ -1,{} -1,{} +1,{} @@@\n",
1038 ours_count.max(1),
1039 theirs_count.max(1),
1040 hunk_new
1041 );
1042 return header + &body;
1043 }
1044 i += 1;
1045 }
1046 body
1047}
1048
1049#[allow(dead_code)] fn result_line_differs_from_parent(parent: &str, result: &str) -> Vec<bool> {
1052 let lr: Vec<&str> = result.lines().collect();
1053 let mut out = vec![false; lr.len()];
1054 let diff = TextDiff::configure().diff_lines(parent, result);
1055 for change in diff.iter_all_changes() {
1056 match change.tag() {
1057 ChangeTag::Equal => {}
1058 ChangeTag::Delete => {}
1059 ChangeTag::Insert => {
1060 let range = change.value().lines().count();
1061 let Some(start) = change.new_index() else {
1062 continue;
1063 };
1064 for i in 0..range {
1065 if let Some(slot) = out.get_mut(start + i) {
1066 *slot = true;
1067 }
1068 }
1069 }
1070 }
1071 }
1072 out
1073}
1074
1075fn combined_hunk_two_parents(a: &str, b: &str, result: &str) -> String {
1081 let la: Vec<&str> = a.lines().collect();
1082 let lb: Vec<&str> = b.lines().collect();
1083 let lr: Vec<&str> = result.lines().collect();
1084
1085 let old_a = la.len().max(1) as u32;
1086 let old_b = lb.len().max(1) as u32;
1087 let new_c = lr.len().max(1) as u32;
1088
1089 let result_set: std::collections::HashSet<&&str> = lr.iter().collect();
1091
1092 let mut body = String::new();
1093 for line in &la {
1095 if !result_set.contains(line) {
1096 body.push_str(&format!("- {line}\n"));
1097 }
1098 }
1099 for line in &lb {
1100 if !result_set.contains(line) {
1101 body.push_str(&format!(" -{line}\n"));
1102 }
1103 }
1104
1105 let d0 = result_line_differs_from_parent(a, result);
1106 let d1 = result_line_differs_from_parent(b, result);
1107 for (i, line) in lr.iter().enumerate() {
1108 let c0 = if d0.get(i).copied().unwrap_or(true) {
1109 '+'
1110 } else {
1111 ' '
1112 };
1113 let c1 = if d1.get(i).copied().unwrap_or(true) {
1114 '+'
1115 } else {
1116 ' '
1117 };
1118 body.push_str(&format!("{c0}{c1}{line}\n"));
1119 }
1120
1121 format!("@@@ -1,{old_a} -1,{old_b} +1,{new_c} @@@\n{body}")
1122}
1123
1124fn read_blob(odb: &Odb, oid: &ObjectId) -> Vec<u8> {
1125 if *oid == crate::diff::zero_oid() {
1126 return Vec::new();
1127 }
1128 odb.read(oid).map(|o| o.data).unwrap_or_default()
1129}
1130
1131#[must_use]
1133pub fn read_blob_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<Vec<u8>> {
1134 let oid = blob_oid_at_path(odb, tree, path)?;
1135 Some(read_blob(odb, &oid))
1136}
1137
1138#[must_use]
1140pub fn blob_oid_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<ObjectId> {
1141 let mut current = *tree;
1142 let parts: Vec<&str> = path.split('/').collect();
1143 for (pi, part) in parts.iter().enumerate() {
1144 let obj = odb.read(¤t).ok()?;
1145 let entries = crate::objects::parse_tree(&obj.data).ok()?;
1146 let found = entries
1147 .iter()
1148 .find(|e| std::str::from_utf8(&e.name).ok() == Some(*part))?;
1149 if pi + 1 == parts.len() {
1150 return Some(found.oid);
1151 }
1152 if found.mode != 0o040000 {
1153 return None;
1154 }
1155 current = found.oid;
1156 }
1157 None
1158}
1159
1160fn abbrev_hex(oid: &ObjectId, abbrev: usize) -> String {
1161 let hex = oid.to_hex();
1162 let len = abbrev.min(hex.len());
1163 hex[..len].to_owned()
1164}