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 rename_threshold: u32,
76) -> Option<Vec<String>> {
77 if parent_trees.len() < 2 {
78 return None;
79 }
80 let mut per_parent: Vec<String> = Vec::with_capacity(parent_trees.len());
81 for t in parent_trees {
82 if blob_oid_at_path(odb, t, merge_path).is_some() {
83 per_parent.push(merge_path.to_string());
84 } else {
85 per_parent.push(String::new());
86 }
87 }
88 if per_parent.iter().all(|p| !p.is_empty()) {
89 return None;
90 }
91 let mut any_rename = false;
92 for (i, t) in parent_trees.iter().enumerate() {
93 if !per_parent[i].is_empty() {
94 continue;
95 }
96 let entries = diff_trees(odb, Some(t), None, merge_path).ok()?;
97 let with_rn = detect_renames(odb, None, entries, rename_threshold);
98 let mut found: Option<String> = None;
99 for e in with_rn {
100 if e.status != DiffStatus::Renamed {
101 continue;
102 }
103 let new_p = e.new_path.as_deref().unwrap_or("");
104 if new_p != merge_path {
105 continue;
106 }
107 let old_p = e.old_path.clone()?;
108 if blob_oid_at_path(odb, t, &old_p).is_some() {
109 if found.is_some() {
110 return None;
111 }
112 found = Some(old_p);
113 }
114 }
115 let p = found?;
116 per_parent[i] = p;
117 any_rename = true;
118 }
119 any_rename.then_some(per_parent)
120}
121
122#[must_use]
125pub fn all_blob_paths_in_tree_order(odb: &Odb, tree_oid: &ObjectId) -> Vec<String> {
126 all_blob_paths_dfs(odb, tree_oid, "")
127}
128
129fn all_blob_paths_dfs(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Vec<String> {
130 let Ok(obj) = odb.read(tree_oid) else {
131 return Vec::new();
132 };
133 if obj.kind != ObjectKind::Tree {
134 return Vec::new();
135 }
136 let Ok(entries) = parse_tree(&obj.data) else {
137 return Vec::new();
138 };
139 let mut out = Vec::new();
140 for e in entries {
141 let name = String::from_utf8_lossy(&e.name);
142 let path = if prefix.is_empty() {
143 name.into_owned()
144 } else {
145 format!("{prefix}/{name}")
146 };
147 if e.mode == 0o040000 {
148 out.extend(all_blob_paths_dfs(odb, &e.oid, &path));
149 } else {
150 out.push(path);
151 }
152 }
153 out
154}
155
156fn paths_in_tree_order(
159 odb: &Odb,
160 tree_oid: &ObjectId,
161 prefix: &str,
162 want: &std::collections::HashSet<String>,
163) -> Vec<String> {
164 let Ok(obj) = odb.read(tree_oid) else {
165 return Vec::new();
166 };
167 if obj.kind != ObjectKind::Tree {
168 return Vec::new();
169 }
170 let Ok(entries) = parse_tree(&obj.data) else {
171 return Vec::new();
172 };
173 let mut out = Vec::new();
174 for e in entries {
175 let name = String::from_utf8_lossy(&e.name);
176 let path = if prefix.is_empty() {
177 name.into_owned()
178 } else {
179 format!("{prefix}/{name}")
180 };
181 if e.mode == 0o040000 {
182 out.extend(paths_in_tree_order(odb, &e.oid, &path, want));
183 } else if want.contains(&path) {
184 out.push(path);
185 }
186 }
187 out
188}
189
190fn attrs_for_repo_path(git_dir: &Path, path: &str) -> FileAttrs {
192 let work_tree = git_dir.parent().unwrap_or(git_dir);
193 let rules = load_gitattributes(work_tree);
194 let config = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
195 get_file_attrs(&rules, path, false, &config)
196}
197
198#[must_use]
200pub fn is_binary_for_diff(git_dir: &Path, path: &str, blob: &[u8]) -> bool {
201 let fa = attrs_for_repo_path(git_dir, path);
202 if matches!(fa.diff_attr, DiffAttr::Unset) {
203 return true;
204 }
205 crate::crlf::is_binary(blob)
206}
207
208fn diff_driver_binary_config(config: &ConfigSet, driver: &str) -> bool {
210 let key = format!("diff.{driver}.binary");
211 config
212 .get(&key)
213 .is_some_and(|v| parse_bool(v.as_str()).unwrap_or(false))
214}
215
216#[must_use]
222pub fn diff_forced_binary_by_driver(
223 git_dir: &Path,
224 config: &ConfigSet,
225 path: &str,
226 old_mode: &str,
227 new_mode: &str,
228) -> bool {
229 let fa = attrs_for_repo_path(git_dir, path);
230 let DiffAttr::Driver(driver) = fa.diff_attr else {
231 return false;
232 };
233 if !diff_driver_binary_config(config, &driver) {
234 return false;
235 }
236 if old_mode == "120000" || new_mode == "120000" {
237 return false;
238 }
239 true
240}
241
242fn textconv_cmd_needs_shell_wrapper(cmd_line: &str) -> bool {
245 cmd_line.chars().any(|c| {
246 matches!(
247 c,
248 '|' | '&'
249 | ';'
250 | '<'
251 | '>'
252 | '('
253 | ')'
254 | '$'
255 | '`'
256 | '\\'
257 | '"'
258 | '\''
259 | ' '
260 | '\t'
261 | '\n'
262 | '*'
263 | '?'
264 | '['
265 | '#'
266 | '~'
267 | '='
268 | '%'
269 )
270 })
271}
272
273pub fn run_textconv_raw(
280 command_cwd: &Path,
281 config: &ConfigSet,
282 driver: &str,
283 input: &[u8],
284) -> Option<Vec<u8>> {
285 let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
286 cmd_line = cmd_line.trim_end().to_string();
287 let stdin_mode = if cmd_line.ends_with('<') {
288 let t = cmd_line.trim_end_matches('<').trim_end();
289 cmd_line = t.to_string();
290 true
291 } else {
292 false
293 };
294 if stdin_mode {
295 let mut child = Command::new("sh")
296 .arg("-c")
297 .arg(&cmd_line)
298 .current_dir(command_cwd)
299 .stdin(Stdio::piped())
300 .stdout(Stdio::piped())
301 .stderr(Stdio::null())
302 .spawn()
303 .ok()?;
304 let mut stdin = child.stdin.take()?;
305 stdin.write_all(input).ok()?;
306 drop(stdin);
307 let out = child.wait_with_output().ok()?;
308 return if out.status.success() {
309 Some(out.stdout)
310 } else {
311 None
312 };
313 }
314
315 let mut tmp = NamedTempFile::new().ok()?;
316 tmp.write_all(input).ok()?;
317 tmp.flush().ok()?;
318 let path = tmp.path().to_owned();
319
320 let out = if textconv_cmd_needs_shell_wrapper(&cmd_line) {
321 Command::new("sh")
322 .current_dir(command_cwd)
323 .arg("-c")
324 .arg(format!("{} \"$@\"", cmd_line))
325 .arg(&cmd_line)
326 .arg(&path)
327 .stdout(Stdio::piped())
328 .stderr(Stdio::null())
329 .output()
330 .ok()?
331 } else {
332 Command::new("sh")
333 .current_dir(command_cwd)
334 .arg(&cmd_line)
335 .arg(&path)
336 .stdout(Stdio::piped())
337 .stderr(Stdio::null())
338 .output()
339 .ok()?
340 };
341
342 if !out.status.success() {
343 return None;
344 }
345 Some(out.stdout)
346}
347
348pub fn run_textconv(
350 command_cwd: &Path,
351 config: &ConfigSet,
352 driver: &str,
353 input: &[u8],
354) -> Option<String> {
355 run_textconv_raw(command_cwd, config, driver, input)
356 .map(|b| String::from_utf8_lossy(&b).into_owned())
357}
358
359pub fn diff_textconv_cmd_line(config: &ConfigSet, driver: &str) -> Option<String> {
360 let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
361 cmd_line = cmd_line.trim_end().to_string();
362 if cmd_line.ends_with('<') {
363 let t = cmd_line.trim_end_matches('<').trim_end();
364 cmd_line = t.to_string();
365 }
366 Some(cmd_line)
367}
368
369pub fn diff_cachetextconv_enabled(config: &ConfigSet, driver: &str) -> bool {
370 config
371 .get(&format!("diff.{driver}.cachetextconv"))
372 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "yes" | "1" | "on"))
373 .unwrap_or(false)
374}
375
376#[must_use]
381pub fn diff_textconv_active(git_dir: &Path, config: &ConfigSet, path: &str) -> bool {
382 let fa = attrs_for_repo_path(git_dir, path);
383 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
384 return false;
385 };
386 diff_textconv_cmd_line(config, driver).is_some()
387}
388
389fn textconv_command_cwd(git_dir: &Path) -> std::path::PathBuf {
390 git_dir.parent().unwrap_or(git_dir).to_path_buf()
391}
392
393fn blob_text_for_diff_inner(
394 odb: Option<&Odb>,
395 git_dir: &Path,
396 config: &ConfigSet,
397 path: &str,
398 blob: &[u8],
399 blob_oid: Option<&ObjectId>,
400 use_textconv: bool,
401) -> String {
402 if !use_textconv {
403 return String::from_utf8_lossy(blob).into_owned();
404 }
405 let fa = attrs_for_repo_path(git_dir, path);
406 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
407 return String::from_utf8_lossy(blob).into_owned();
408 };
409 let Some(cmd_line) = diff_textconv_cmd_line(config, driver) else {
410 return String::from_utf8_lossy(blob).into_owned();
411 };
412 let want_cache = diff_cachetextconv_enabled(config, driver);
413 if want_cache {
414 if let (Some(odb), Some(oid)) = (odb, blob_oid) {
415 if let Some(bytes) = read_textconv_cache(odb, git_dir, driver, &cmd_line, oid) {
416 return String::from_utf8_lossy(&bytes).into_owned();
417 }
418 }
419 }
420 let cwd = textconv_command_cwd(git_dir);
421 let Some(t) = run_textconv(&cwd, config, driver, blob) else {
422 return String::from_utf8_lossy(blob).into_owned();
423 };
424 if want_cache {
425 if let (Some(odb), Some(oid)) = (odb, blob_oid) {
426 write_textconv_cache(odb, git_dir, driver, &cmd_line, oid, t.as_bytes());
427 }
428 }
429 t
430}
431
432#[must_use]
435pub fn blob_text_for_diff_with_oid(
436 odb: &Odb,
437 git_dir: &Path,
438 config: &ConfigSet,
439 path: &str,
440 blob: &[u8],
441 blob_oid: &ObjectId,
442 use_textconv: bool,
443) -> String {
444 blob_text_for_diff_inner(
445 Some(odb),
446 git_dir,
447 config,
448 path,
449 blob,
450 Some(blob_oid),
451 use_textconv,
452 )
453}
454
455pub fn convert_blob_to_worktree_for_path(
460 git_dir: &Path,
461 work_tree: &Path,
462 index: Option<&crate::index::Index>,
463 odb: &Odb,
464 path: &str,
465 blob: &[u8],
466 oid_hex: Option<&str>,
467) -> std::io::Result<Vec<u8>> {
468 let config = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
469 let conv = crate::crlf::ConversionConfig::from_config(&config);
470 let rules = match index {
471 Some(idx) => crate::crlf::load_gitattributes_for_checkout(work_tree, path, idx, odb),
472 None => crate::crlf::load_gitattributes(work_tree),
473 };
474 let file_attrs = crate::crlf::get_file_attrs(&rules, path, false, &config);
475 crate::crlf::convert_to_worktree_eager(blob, path, &conv, &file_attrs, oid_hex, None)
476 .map_err(std::io::Error::other)
477}
478
479pub fn blob_text_for_diff(
484 git_dir: &Path,
485 config: &ConfigSet,
486 path: &str,
487 blob: &[u8],
488 use_textconv: bool,
489) -> String {
490 blob_text_for_diff_inner(None, git_dir, config, path, blob, None, use_textconv)
491}
492
493#[allow(clippy::too_many_arguments)]
495pub fn format_parent_patch(
496 git_dir: &Path,
497 config: &ConfigSet,
498 odb: &Odb,
499 path: &str,
500 parent_tree: &ObjectId,
501 result_tree: &ObjectId,
502 abbrev: usize,
503 context: usize,
504 use_textconv: bool,
505) -> Option<String> {
506 let entries = diff_trees(odb, Some(parent_tree), Some(result_tree), "").ok()?;
507 let entry = entries.iter().find(|e| e.path() == path)?;
508 if entry.status == DiffStatus::Unmerged {
509 return None;
510 }
511
512 let old_blob = read_blob(odb, &entry.old_oid);
513 let new_blob = read_blob(odb, &entry.new_oid);
514 let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
515 let binary = !textconv_for_patch
516 && (is_binary_for_diff(git_dir, path, &old_blob)
517 || is_binary_for_diff(git_dir, path, &new_blob));
518
519 let old_abbrev = abbrev_hex(&entry.old_oid, abbrev);
520 let new_abbrev = abbrev_hex(&entry.new_oid, abbrev);
521
522 let mut out = String::new();
523 out.push_str(&format!("diff --git a/{path} b/{path}\n"));
524 if entry.old_mode != entry.new_mode {
525 out.push_str(&format!("index {old_abbrev}..{new_abbrev}\n"));
526 out.push_str(&format!("old mode {}\n", entry.old_mode));
527 out.push_str(&format!("new mode {}\n", entry.new_mode));
528 } else {
529 out.push_str(&format!(
530 "index {old_abbrev}..{new_abbrev} {}\n",
531 entry.new_mode
532 ));
533 }
534
535 if binary {
536 out.push_str(&format!("Binary files a/{path} and b/{path} differ\n"));
537 return Some(out);
538 }
539
540 let old_t = if textconv_for_patch {
541 blob_text_for_diff_with_oid(odb, git_dir, config, path, &old_blob, &entry.old_oid, true)
542 } else {
543 blob_text_for_diff(git_dir, config, path, &old_blob, use_textconv)
544 };
545 let new_t = if textconv_for_patch {
546 blob_text_for_diff_with_oid(odb, git_dir, config, path, &new_blob, &entry.new_oid, true)
547 } else {
548 blob_text_for_diff(git_dir, config, path, &new_blob, use_textconv)
549 };
550 let patch = crate::diff::unified_diff(
551 &old_t,
552 &new_t,
553 path,
554 path,
555 context,
556 true,
557 config.quote_path_fully(),
558 );
559 out.push_str(&patch);
560 Some(out)
561}
562
563pub fn format_combined_binary_header(
565 path: &str,
566 parent_oids: &[ObjectId],
567 result_oid: &ObjectId,
568 abbrev: usize,
569 use_cc_word: bool,
570) -> String {
571 format_combined_binary_header_n(path, parent_oids, result_oid, abbrev, use_cc_word)
572}
573
574#[must_use]
576pub fn format_combined_binary_header_n(
577 path: &str,
578 parent_oids: &[ObjectId],
579 result_oid: &ObjectId,
580 abbrev: usize,
581 use_cc_word: bool,
582) -> String {
583 let idx: Vec<String> = parent_oids.iter().map(|o| abbrev_hex(o, abbrev)).collect();
584 let res = abbrev_hex(result_oid, abbrev);
585 let kind = if use_cc_word { "cc" } else { "combined" };
586 format!(
587 "diff --{kind} {path}\nindex {}..{res}\nBinary files differ\n",
588 idx.join(",")
589 )
590}
591
592pub fn format_combined_binary(
594 path: &str,
595 parent_oids: &[ObjectId],
596 result_oid: &ObjectId,
597 abbrev: usize,
598 use_cc_word: bool,
599) -> String {
600 format_combined_binary_header_n(path, parent_oids, result_oid, abbrev, use_cc_word)
601}
602
603fn push_combined_file_headers(
604 out: &mut String,
605 merge_path: &str,
606 parent_paths: &[String],
607 parent_sides: &[CombinedParentSide],
608 combined_all_paths: bool,
609 quote_path_fully: bool,
610) {
611 let a_prefix = "a/";
612 let b_prefix = "b/";
613 if combined_all_paths {
614 for (i, p) in parent_paths.iter().enumerate() {
615 if parent_sides
616 .get(i)
617 .is_some_and(|s| s.status == crate::combined_tree_diff::CombinedParentStatus::Added)
618 {
619 out.push_str("--- /dev/null\n");
620 } else {
621 let line = format_diff_path_with_prefix(a_prefix, p, quote_path_fully);
622 out.push_str("--- ");
623 out.push_str(&line);
624 out.push('\n');
625 }
626 }
627 let line = format_diff_path_with_prefix(b_prefix, merge_path, quote_path_fully);
628 out.push_str("+++ ");
629 out.push_str(&line);
630 out.push('\n');
631 } else {
632 let la = format_diff_path_with_prefix(a_prefix, merge_path, quote_path_fully);
633 let lb = format_diff_path_with_prefix(b_prefix, merge_path, quote_path_fully);
634 out.push_str("--- ");
635 out.push_str(&la);
636 out.push('\n');
637 out.push_str("+++ ");
638 out.push_str(&lb);
639 out.push('\n');
640 }
641}
642
643#[allow(clippy::too_many_arguments)]
649pub fn format_combined_textconv_patch(
650 git_dir: &Path,
651 config: &ConfigSet,
652 odb: &Odb,
653 path: &str,
654 parent_trees: &[ObjectId],
655 result_tree: &ObjectId,
656 abbrev: usize,
657 context: usize,
658 use_cc_word: bool,
659 use_textconv: bool,
660 ws: CombinedDiffWsOptions,
661 combined_all_paths: bool,
662 parent_blob_paths: Option<&[String]>,
663 parent_sides: &[CombinedParentSide],
664 quote_path_fully: bool,
665) -> Option<String> {
666 if parent_trees.len() < 2 {
667 return None;
668 }
669 let parent_paths: Vec<&str> = if let Some(ps) = parent_blob_paths {
670 if ps.len() != parent_trees.len() {
671 return None;
672 }
673 ps.iter().map(|s| s.as_str()).collect()
674 } else {
675 vec![path; parent_trees.len()]
676 };
677
678 let mut parent_blobs = Vec::with_capacity(parent_trees.len());
679 let mut parent_oids = Vec::with_capacity(parent_trees.len());
680 for (i, t) in parent_trees.iter().enumerate() {
681 let p = parent_paths[i];
682 let b = read_blob_at_path(odb, t, p)?;
683 let oid = blob_oid_at_path(odb, t, p)?;
684 parent_blobs.push(b);
685 parent_oids.push(oid);
686 }
687 let result_blob = read_blob_at_path(odb, result_tree, path)?;
688 let roid = blob_oid_at_path(odb, result_tree, path)?;
689
690 let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
691 if !textconv_for_patch
692 && (parent_blobs
693 .iter()
694 .any(|b| is_binary_for_diff(git_dir, path, b))
695 || is_binary_for_diff(git_dir, path, &result_blob))
696 {
697 return Some(format_combined_binary(
698 path,
699 &parent_oids,
700 &roid,
701 abbrev,
702 use_cc_word,
703 ));
704 }
705
706 let mut parent_texts = Vec::with_capacity(parent_trees.len());
707 for (i, blob) in parent_blobs.iter().enumerate() {
708 let p = parent_paths[i];
709 let oid = &parent_oids[i];
710 let t = if textconv_for_patch {
711 blob_text_for_diff_with_oid(odb, git_dir, config, p, blob, oid, true)
712 } else {
713 blob_text_for_diff(git_dir, config, p, blob, use_textconv)
714 };
715 parent_texts.push(t);
716 }
717 let tr = if textconv_for_patch {
718 blob_text_for_diff_with_oid(odb, git_dir, config, path, &result_blob, &roid, true)
719 } else {
720 blob_text_for_diff(git_dir, config, path, &result_blob, use_textconv)
721 };
722
723 let idx: Vec<String> = parent_oids.iter().map(|o| abbrev_hex(o, abbrev)).collect();
724 let ra = abbrev_hex(&roid, abbrev);
725 let kind = if use_cc_word { "cc" } else { "combined" };
726
727 let header_paths: Vec<String> = if combined_all_paths {
728 parent_paths.iter().map(|s| (*s).to_string()).collect()
729 } else {
730 Vec::new()
731 };
732
733 let mut out = String::new();
734 out.push_str(&format!("diff --{kind} {path}\n"));
735 out.push_str(&format!("index {}..{ra}\n", idx.join(",")));
736 if combined_all_paths {
737 push_combined_file_headers(
738 &mut out,
739 path,
740 &header_paths,
741 parent_sides,
742 true,
743 quote_path_fully,
744 );
745 } else {
746 push_combined_file_headers(&mut out, path, &[], parent_sides, false, quote_path_fully);
747 }
748 out.push_str(&format_combined_diff_body(
749 &parent_texts,
750 &tr,
751 context,
752 use_cc_word,
753 ws,
754 ));
755 Some(out)
756}
757
758#[must_use]
761pub fn format_gitlink_unmerged_conflict_combined(
762 path: &str,
763 stage2_oid: &ObjectId,
764 stage3_oid: &ObjectId,
765 result_subproject_line: &str,
766 abbrev: usize,
767) -> String {
768 let p1a = abbrev_hex(stage2_oid, abbrev);
769 let p2a = abbrev_hex(stage3_oid, abbrev);
770 let z = crate::diff::zero_oid();
771 let za = abbrev_hex(&z, abbrev);
772
773 let t_ours = format!("Subproject commit {}", stage2_oid.to_hex());
774 let t_theirs = format!("Subproject commit {}", stage3_oid.to_hex());
775 let tr = result_subproject_line.trim_end_matches('\n').to_owned();
776
777 let mut out = String::new();
778 out.push_str(&format!("diff --cc {path}\n"));
779 out.push_str(&format!("index {p1a},{p2a}..{za}\n"));
780 out.push_str(&format!("--- a/{path}\n"));
781 out.push_str(&format!("+++ b/{path}\n"));
782 out.push_str(&combined_hunk_two_parents(&t_ours, &t_theirs, &tr));
783 out
784}
785
786#[allow(clippy::too_many_arguments)]
788pub fn format_worktree_conflict_combined(
789 git_dir: &Path,
790 config: &ConfigSet,
791 odb: &Odb,
792 path: &str,
793 stage1_oid: &ObjectId,
794 stage2_oid: &ObjectId,
795 stage3_oid: &ObjectId,
796 worktree_bytes: &[u8],
797 abbrev: usize,
798) -> String {
799 let ours_blob = read_blob(odb, stage2_oid);
800 let theirs_blob = read_blob(odb, stage3_oid);
801 let _base_blob = read_blob(odb, stage1_oid);
802
803 let use_conv = !worktree_bytes.contains(&0);
804 let textconv_cache_path = diff_textconv_active(git_dir, config, path);
805 let t_ours = if textconv_cache_path {
806 blob_text_for_diff_with_oid(odb, git_dir, config, path, &ours_blob, stage2_oid, true)
807 } else {
808 blob_text_for_diff(git_dir, config, path, &ours_blob, use_conv)
809 };
810 let t_theirs = if textconv_cache_path {
811 blob_text_for_diff_with_oid(odb, git_dir, config, path, &theirs_blob, stage3_oid, true)
812 } else {
813 blob_text_for_diff(git_dir, config, path, &theirs_blob, use_conv)
814 };
815 let wt_text = if textconv_cache_path || use_conv {
816 blob_text_for_diff(git_dir, config, path, worktree_bytes, true)
817 } else {
818 String::from_utf8_lossy(worktree_bytes).into_owned()
819 };
820 let wt_for_conflict = wt_text.clone();
821
822 let p1a = abbrev_hex(stage2_oid, abbrev);
823 let p2a = abbrev_hex(stage3_oid, abbrev);
824 let z = crate::diff::zero_oid();
825 let za = abbrev_hex(&z, abbrev);
826
827 let mut out = String::new();
828 out.push_str(&format!("diff --cc {path}\n"));
829 out.push_str(&format!("index {p1a},{p2a}..{za}\n"));
830 out.push_str(&format!("--- a/{path}\n"));
831 out.push_str(&format!("+++ b/{path}\n"));
832
833 if wt_text.contains("<<<<<<<") && wt_text.contains(">>>>>>>") {
834 out.push_str(&conflict_combined_body(&wt_for_conflict));
835 } else {
836 out.push_str(&format_combined_diff_body(
837 &[t_ours, t_theirs],
838 &wt_text,
839 3,
840 true,
841 CombinedDiffWsOptions::default(),
842 ));
843 }
844 out
845}
846
847fn conflict_combined_body(wt: &str) -> String {
849 let lines: Vec<&str> = wt.lines().collect();
850 let mut body = String::new();
851 let mut i = 0usize;
852 while i < lines.len() {
853 let line = lines[i];
854 if line.starts_with("<<<<<<< ") {
855 let mut hunk_new = 0u32;
856 let mut ours_count = 0u32;
857 let mut theirs_count = 0u32;
858 body.push_str(&format!("++{line}\n"));
859 hunk_new += 1;
860 i += 1;
861 while i < lines.len() && !lines[i].starts_with("=======") {
862 body.push_str(&format!(" +{}\n", lines[i]));
863 ours_count += 1;
864 hunk_new += 1;
865 i += 1;
866 }
867 if i < lines.len() && lines[i].starts_with("=======") {
868 body.push_str("++=======\n");
869 hunk_new += 1;
870 i += 1;
871 }
872 while i < lines.len() && !lines[i].starts_with(">>>>>>>") {
873 body.push_str(&format!("+ {}\n", lines[i]));
874 theirs_count += 1;
875 hunk_new += 1;
876 i += 1;
877 }
878 if i < lines.len() {
879 let closing = lines[i];
880 body.push_str(&format!("++{closing}\n"));
881 hunk_new += 1;
882 }
883 let header = format!(
884 "@@@ -1,{} -1,{} +1,{} @@@\n",
885 ours_count.max(1),
886 theirs_count.max(1),
887 hunk_new
888 );
889 return header + &body;
890 }
891 i += 1;
892 }
893 body
894}
895
896#[allow(dead_code)] fn result_line_differs_from_parent(parent: &str, result: &str) -> Vec<bool> {
899 let lr: Vec<&str> = result.lines().collect();
900 let mut out = vec![false; lr.len()];
901 let diff = TextDiff::configure().diff_lines(parent, result);
902 for change in diff.iter_all_changes() {
903 match change.tag() {
904 ChangeTag::Equal => {}
905 ChangeTag::Delete => {}
906 ChangeTag::Insert => {
907 let range = change.value().lines().count();
908 let Some(start) = change.new_index() else {
909 continue;
910 };
911 for i in 0..range {
912 if let Some(slot) = out.get_mut(start + i) {
913 *slot = true;
914 }
915 }
916 }
917 }
918 }
919 out
920}
921
922#[allow(dead_code)]
924fn combined_hunk_two_parents(a: &str, b: &str, result: &str) -> String {
925 let la: Vec<&str> = a.lines().collect();
926 let lb: Vec<&str> = b.lines().collect();
927 let lr: Vec<&str> = result.lines().collect();
928
929 let d0 = result_line_differs_from_parent(a, result);
930 let d1 = result_line_differs_from_parent(b, result);
931
932 let old_a = la.len().max(1) as u32;
933 let old_b = lb.len().max(1) as u32;
934 let new_c = lr.len().max(1) as u32;
935
936 let mut body = String::new();
937 for (i, line) in lr.iter().enumerate() {
938 let c0 = if d0.get(i).copied().unwrap_or(true) {
939 '+'
940 } else {
941 ' '
942 };
943 let c1 = if d1.get(i).copied().unwrap_or(true) {
944 '+'
945 } else {
946 ' '
947 };
948 body.push_str(&format!("{c0}{c1}{line}\n"));
949 }
950
951 format!("@@@ -1,{old_a} -1,{old_b} +1,{new_c} @@@\n{body}")
952}
953
954fn read_blob(odb: &Odb, oid: &ObjectId) -> Vec<u8> {
955 if *oid == crate::diff::zero_oid() {
956 return Vec::new();
957 }
958 odb.read(oid).map(|o| o.data).unwrap_or_default()
959}
960
961#[must_use]
963pub fn read_blob_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<Vec<u8>> {
964 let oid = blob_oid_at_path(odb, tree, path)?;
965 Some(read_blob(odb, &oid))
966}
967
968#[must_use]
970pub fn blob_oid_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<ObjectId> {
971 let mut current = *tree;
972 let parts: Vec<&str> = path.split('/').collect();
973 for (pi, part) in parts.iter().enumerate() {
974 let obj = odb.read(¤t).ok()?;
975 let entries = crate::objects::parse_tree(&obj.data).ok()?;
976 let found = entries
977 .iter()
978 .find(|e| std::str::from_utf8(&e.name).ok() == Some(*part))?;
979 if pi + 1 == parts.len() {
980 return Some(found.oid);
981 }
982 if found.mode != 0o040000 {
983 return None;
984 }
985 current = found.oid;
986 }
987 None
988}
989
990fn abbrev_hex(oid: &ObjectId, abbrev: usize) -> String {
991 let hex = oid.to_hex();
992 let len = abbrev.min(hex.len());
993 hex[..len].to_owned()
994}