1use std::ffi::OsString;
7use std::io::Write;
8use std::path::Path;
9
10use tempfile::NamedTempFile;
11
12use crate::combined_diff_patch::{format_combined_diff_body, CombinedDiffWsOptions};
13use crate::combined_tree_diff::CombinedParentSide;
14use crate::command_runner::{
15 run_program, run_sh_dash_c, system_command_runner, CommandEnvironment, CommandSpec,
16 CommandStdin, CommandStdio, ShellInvocation,
17};
18use crate::config::{parse_bool, ConfigSet};
19use crate::crlf::{DiffAttr, FileAttrs};
20use crate::diff::{detect_renames, diff_trees, DiffStatus};
21use crate::objects::{parse_commit, parse_tree, ObjectId, ObjectKind};
22use crate::odb::Odb;
23use crate::quote_path::format_diff_path_with_prefix;
24use crate::textconv_cache::{read_textconv_cache, write_textconv_cache};
25
26#[must_use]
28pub fn combined_diff_paths(odb: &Odb, commit_tree: &ObjectId, parents: &[ObjectId]) -> Vec<String> {
29 if parents.len() < 2 {
30 return Vec::new();
31 }
32 let mut per_parent: Vec<std::collections::HashSet<String>> = Vec::new();
33 for p in parents {
34 let Ok(po) = odb.read(p) else {
35 continue;
36 };
37 let Ok(pc) = parse_commit(&po.data) else {
38 continue;
39 };
40 let Ok(entries) = diff_trees(odb, Some(&pc.tree), Some(commit_tree), "") else {
41 continue;
42 };
43 let paths: std::collections::HashSet<String> =
44 entries.iter().map(|e| e.path().to_string()).collect();
45 per_parent.push(paths);
46 }
47 if per_parent.is_empty() {
48 return Vec::new();
49 }
50 let mut common = per_parent[0].clone();
51 for s in &per_parent[1..] {
52 common = common.intersection(s).cloned().collect();
53 }
54 if common.is_empty() {
55 return Vec::new();
56 }
57 let mut ordered = paths_in_tree_order(odb, commit_tree, "", &common);
58 if ordered.len() < common.len() {
62 let seen: std::collections::HashSet<String> = ordered.iter().cloned().collect();
63 let mut rest: Vec<String> = common.difference(&seen).cloned().collect();
64 rest.sort();
65 ordered.extend(rest);
66 }
67 ordered
68}
69
70#[must_use]
74pub fn combined_merge_parent_blob_paths(
75 odb: &Odb,
76 merge_path: &str,
77 parent_trees: &[ObjectId],
78 result_tree: &ObjectId,
79 rename_threshold: u32,
80) -> Option<Vec<String>> {
81 if parent_trees.len() < 2 {
82 return None;
83 }
84 let mut per_parent: Vec<String> = Vec::with_capacity(parent_trees.len());
85 for t in parent_trees {
86 if blob_oid_at_path(odb, t, merge_path).is_some() {
87 per_parent.push(merge_path.to_string());
88 } else {
89 per_parent.push(String::new());
90 }
91 }
92 if per_parent.iter().all(|p| !p.is_empty()) {
93 return None;
94 }
95 let mut any_rename = false;
96 for (i, t) in parent_trees.iter().enumerate() {
97 if !per_parent[i].is_empty() {
98 continue;
99 }
100 let entries = diff_trees(odb, Some(t), Some(result_tree), "").ok()?;
105 let with_rn = detect_renames(odb, None, entries, rename_threshold);
106 let mut found: Option<String> = None;
107 for e in with_rn {
108 if e.status != DiffStatus::Renamed {
109 continue;
110 }
111 let new_p = e.new_path.as_deref().unwrap_or("");
112 if new_p != merge_path {
113 continue;
114 }
115 let old_p = e.old_path.clone()?;
116 if blob_oid_at_path(odb, t, &old_p).is_some() {
117 if found.is_some() {
118 return None;
119 }
120 found = Some(old_p);
121 }
122 }
123 let p = found?;
124 per_parent[i] = p;
125 any_rename = true;
126 }
127 any_rename.then_some(per_parent)
128}
129
130fn blob_mode_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<u32> {
132 let mut current = *tree;
133 let parts: Vec<&str> = path.split('/').collect();
134 for (pi, part) in parts.iter().enumerate() {
135 let obj = odb.read(¤t).ok()?;
136 let entries = crate::objects::parse_tree(&obj.data).ok()?;
137 let found = entries
138 .iter()
139 .find(|e| std::str::from_utf8(&e.name).ok() == Some(*part))?;
140 if pi + 1 == parts.len() {
141 return Some(found.mode);
142 }
143 if found.mode != 0o040000 {
144 return None;
145 }
146 current = found.oid;
147 }
148 None
149}
150
151pub fn enrich_combined_path_renames(
159 odb: &Odb,
160 path: &mut crate::combined_tree_diff::CombinedDiffPath,
161 parent_trees: &[ObjectId],
162 result_tree: &ObjectId,
163 rename_threshold: u32,
164) {
165 use crate::combined_tree_diff::CombinedParentStatus;
166 if parent_trees.len() != path.parents.len() {
167 return;
168 }
169 let Some(parent_paths) = combined_merge_parent_blob_paths(
170 odb,
171 &path.path,
172 parent_trees,
173 result_tree,
174 rename_threshold,
175 ) else {
176 return;
177 };
178 for (i, side) in path.parents.iter_mut().enumerate() {
179 if side.status != CombinedParentStatus::Added {
180 continue;
181 }
182 let src = &parent_paths[i];
183 if src.is_empty() || src == &path.path {
184 continue;
185 }
186 let (Some(oid), Some(mode)) = (
187 blob_oid_at_path(odb, &parent_trees[i], src),
188 blob_mode_at_path(odb, &parent_trees[i], src),
189 ) else {
190 continue;
191 };
192 side.status = CombinedParentStatus::Renamed;
193 side.oid = oid;
194 side.mode = mode;
195 side.rename_from = Some(src.clone());
196 }
197}
198
199#[must_use]
202pub fn all_blob_paths_in_tree_order(odb: &Odb, tree_oid: &ObjectId) -> Vec<String> {
203 all_blob_paths_dfs(odb, tree_oid, "")
204}
205
206fn all_blob_paths_dfs(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Vec<String> {
207 let Ok(obj) = odb.read(tree_oid) else {
208 return Vec::new();
209 };
210 if obj.kind != ObjectKind::Tree {
211 return Vec::new();
212 }
213 let Ok(entries) = parse_tree(&obj.data) else {
214 return Vec::new();
215 };
216 let mut out = Vec::new();
217 for e in entries {
218 let name = String::from_utf8_lossy(&e.name);
219 let path = if prefix.is_empty() {
220 name.into_owned()
221 } else {
222 format!("{prefix}/{name}")
223 };
224 if e.mode == 0o040000 {
225 out.extend(all_blob_paths_dfs(odb, &e.oid, &path));
226 } else {
227 out.push(path);
228 }
229 }
230 out
231}
232
233fn paths_in_tree_order(
236 odb: &Odb,
237 tree_oid: &ObjectId,
238 prefix: &str,
239 want: &std::collections::HashSet<String>,
240) -> Vec<String> {
241 let Ok(obj) = odb.read(tree_oid) else {
242 return Vec::new();
243 };
244 if obj.kind != ObjectKind::Tree {
245 return Vec::new();
246 }
247 let Ok(entries) = parse_tree(&obj.data) else {
248 return Vec::new();
249 };
250 let mut out = Vec::new();
251 for e in entries {
252 let name = String::from_utf8_lossy(&e.name);
253 let path = if prefix.is_empty() {
254 name.into_owned()
255 } else {
256 format!("{prefix}/{name}")
257 };
258 if e.mode == 0o040000 {
259 out.extend(paths_in_tree_order(odb, &e.oid, &path, want));
260 } else if want.contains(&path) {
261 out.push(path);
262 }
263 }
264 out
265}
266
267fn attrs_for_repo_path(git_dir: &Path, path: &str) -> FileAttrs {
269 let work_tree = git_dir.parent().unwrap_or(git_dir);
270 let parsed = crate::crlf::load_gitattributes_parsed(work_tree);
271 let config = ConfigSet::load(
272 &crate::environment::Environment::empty(),
273 Some(git_dir),
274 true,
275 )
276 .unwrap_or_default();
277 crate::crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
278}
279
280#[must_use]
282pub fn is_binary_for_diff(git_dir: &Path, path: &str, blob: &[u8]) -> bool {
283 let fa = attrs_for_repo_path(git_dir, path);
284 if matches!(fa.diff_attr, DiffAttr::Unset) {
285 return true;
286 }
287 crate::crlf::is_binary(blob)
288}
289
290fn diff_driver_binary_config(config: &ConfigSet, driver: &str) -> bool {
292 let key = format!("diff.{driver}.binary");
293 config
294 .get(&key)
295 .is_some_and(|v| parse_bool(v.as_str()).unwrap_or(false))
296}
297
298#[must_use]
304pub fn diff_forced_binary_by_driver(
305 git_dir: &Path,
306 config: &ConfigSet,
307 path: &str,
308 old_mode: &str,
309 new_mode: &str,
310) -> bool {
311 let fa = attrs_for_repo_path(git_dir, path);
312 let DiffAttr::Driver(driver) = fa.diff_attr else {
313 return false;
314 };
315 if !diff_driver_binary_config(config, &driver) {
316 return false;
317 }
318 if old_mode == "120000" || new_mode == "120000" {
319 return false;
320 }
321 true
322}
323
324fn textconv_cmd_needs_shell_wrapper(cmd_line: &str) -> bool {
327 cmd_line.chars().any(|c| {
328 matches!(
329 c,
330 '|' | '&'
331 | ';'
332 | '<'
333 | '>'
334 | '('
335 | ')'
336 | '$'
337 | '`'
338 | '\\'
339 | '"'
340 | '\''
341 | ' '
342 | '\t'
343 | '\n'
344 | '*'
345 | '?'
346 | '['
347 | '#'
348 | '~'
349 | '='
350 | '%'
351 )
352 })
353}
354
355pub fn run_textconv_raw(
362 command_cwd: &Path,
363 config: &ConfigSet,
364 driver: &str,
365 input: &[u8],
366) -> Option<Vec<u8>> {
367 let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
368 cmd_line = cmd_line.trim_end().to_string();
369 let stdin_mode = if cmd_line.ends_with('<') {
370 let t = cmd_line.trim_end_matches('<').trim_end();
371 cmd_line = t.to_string();
372 true
373 } else {
374 false
375 };
376 let runner = system_command_runner();
377 if stdin_mode {
378 let out = run_sh_dash_c(
379 runner.as_ref(),
380 &cmd_line,
381 Some(command_cwd),
382 CommandStdin::Pipe(input.to_vec()),
383 CommandStdio::Null,
384 )
385 .ok()?;
386 return if out.status.success {
387 Some(out.stdout)
388 } else {
389 None
390 };
391 }
392
393 let mut tmp = NamedTempFile::new().ok()?;
394 tmp.write_all(input).ok()?;
395 tmp.flush().ok()?;
396 let path = tmp.path().to_owned();
397
398 let out = if textconv_cmd_needs_shell_wrapper(&cmd_line) {
399 let spec = CommandSpec {
400 program: "sh".into(),
401 args: Vec::new(),
402 shell: Some(ShellInvocation::DashC {
403 script: format!("{} \"$@\"", cmd_line),
404 argv0: Some(cmd_line.clone()),
405 args: vec![OsString::from(path.to_string_lossy().as_ref())],
406 }),
407 cwd: Some(command_cwd.to_path_buf()),
408 env: CommandEnvironment::inherit_process_only(),
409 stdin: CommandStdin::Null,
410 stdout: CommandStdio::Pipe,
411 stderr: CommandStdio::Null,
412 };
413 runner.spawn(&spec).ok()?.wait_with_output().ok()?
414 } else {
415 run_program(
416 runner.as_ref(),
417 Path::new("sh"),
418 &[
419 OsString::from(&cmd_line),
420 OsString::from(path.to_string_lossy().as_ref()),
421 ],
422 Some(command_cwd),
423 CommandStdin::Null,
424 CommandStdio::Null,
425 )
426 .ok()?
427 };
428
429 if !out.status.success {
430 return None;
431 }
432 Some(out.stdout)
433}
434
435pub fn run_textconv(
437 command_cwd: &Path,
438 config: &ConfigSet,
439 driver: &str,
440 input: &[u8],
441) -> Option<String> {
442 run_textconv_raw(command_cwd, config, driver, input)
443 .map(|b| String::from_utf8_lossy(&b).into_owned())
444}
445
446pub fn diff_textconv_cmd_line(config: &ConfigSet, driver: &str) -> Option<String> {
448 let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
449 cmd_line = cmd_line.trim_end().to_string();
450 if cmd_line.ends_with('<') {
451 let t = cmd_line.trim_end_matches('<').trim_end();
452 cmd_line = t.to_string();
453 }
454 Some(cmd_line)
455}
456
457pub fn diff_cachetextconv_enabled(config: &ConfigSet, driver: &str) -> bool {
459 config
460 .get(&format!("diff.{driver}.cachetextconv"))
461 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "yes" | "1" | "on"))
462 .unwrap_or(false)
463}
464
465#[must_use]
470pub fn diff_textconv_active(git_dir: &Path, config: &ConfigSet, path: &str) -> bool {
471 let fa = attrs_for_repo_path(git_dir, path);
472 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
473 return false;
474 };
475 diff_textconv_cmd_line(config, driver).is_some()
476}
477
478#[must_use]
481pub fn diff_attr_forces_binary(git_dir: &Path, path: &str) -> bool {
482 matches!(
483 attrs_for_repo_path(git_dir, path).diff_attr,
484 DiffAttr::Unset
485 )
486}
487
488#[must_use]
491pub fn diff_attr_forces_text(git_dir: &Path, path: &str) -> bool {
492 matches!(attrs_for_repo_path(git_dir, path).diff_attr, DiffAttr::Set)
493}
494
495#[must_use]
502pub fn diff_attr_external_driver(
503 git_dir: &Path,
504 config: &ConfigSet,
505 path: &str,
506) -> Option<(String, bool)> {
507 let fa = attrs_for_repo_path(git_dir, path);
508 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
509 return None;
510 };
511 let cmd = config.get(&format!("diff.{driver}.command"))?;
512 if cmd.trim().is_empty() {
513 return None;
514 }
515 let trust = config
516 .get(&format!("diff.{driver}.trustExitCode"))
517 .and_then(|v| parse_bool(v.as_str()).ok())
518 .unwrap_or(false);
519 Some((cmd, trust))
520}
521
522fn textconv_command_cwd(git_dir: &Path) -> std::path::PathBuf {
523 git_dir.parent().unwrap_or(git_dir).to_path_buf()
524}
525
526fn blob_text_for_diff_inner(
527 odb: Option<&Odb>,
528 git_dir: &Path,
529 config: &ConfigSet,
530 path: &str,
531 blob: &[u8],
532 blob_oid: Option<&ObjectId>,
533 use_textconv: bool,
534) -> String {
535 if !use_textconv {
536 return String::from_utf8_lossy(blob).into_owned();
537 }
538 let fa = attrs_for_repo_path(git_dir, path);
539 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
540 return String::from_utf8_lossy(blob).into_owned();
541 };
542 let Some(cmd_line) = diff_textconv_cmd_line(config, driver) else {
543 return String::from_utf8_lossy(blob).into_owned();
544 };
545 let want_cache = diff_cachetextconv_enabled(config, driver);
546 if want_cache {
547 if let (Some(odb), Some(oid)) = (odb, blob_oid) {
548 if let Some(bytes) = read_textconv_cache(odb, git_dir, driver, &cmd_line, oid) {
549 return String::from_utf8_lossy(&bytes).into_owned();
550 }
551 }
552 }
553 let cwd = textconv_command_cwd(git_dir);
554 let Some(t) = run_textconv(&cwd, config, driver, blob) else {
555 return String::from_utf8_lossy(blob).into_owned();
556 };
557 if want_cache {
558 if let (Some(odb), Some(oid)) = (odb, blob_oid) {
559 write_textconv_cache(odb, git_dir, driver, &cmd_line, oid, t.as_bytes());
560 }
561 }
562 t
563}
564
565#[must_use]
568pub fn blob_text_for_diff_with_oid(
569 odb: &Odb,
570 git_dir: &Path,
571 config: &ConfigSet,
572 path: &str,
573 blob: &[u8],
574 blob_oid: &ObjectId,
575 use_textconv: bool,
576) -> String {
577 blob_text_for_diff_inner(
578 Some(odb),
579 git_dir,
580 config,
581 path,
582 blob,
583 Some(blob_oid),
584 use_textconv,
585 )
586}
587
588#[allow(clippy::too_many_arguments)]
596pub fn convert_blob_to_worktree_for_path(
597 git_dir: &Path,
598 work_tree: &Path,
599 index: Option<&crate::index::Index>,
600 odb: &Odb,
601 path: &str,
602 blob: &[u8],
603 oid_hex: Option<&str>,
604 filter_process: Option<&crate::filter_process::FilterProcessState>,
605) -> std::io::Result<Vec<u8>> {
606 let config = ConfigSet::load(
607 &crate::environment::Environment::empty(),
608 Some(git_dir),
609 true,
610 )
611 .unwrap_or_default();
612 let conv = crate::crlf::ConversionConfig::from_config(&config);
613 let parsed = match index {
614 Some(idx) => crate::crlf::load_gitattributes_for_checkout_parsed(work_tree, path, idx, odb),
615 None => crate::crlf::load_gitattributes_parsed(work_tree),
616 };
617 let file_attrs = crate::crlf::file_attrs_from_gitattributes(
618 &parsed.rules,
619 &parsed.macros,
620 path,
621 false,
622 &config,
623 );
624 crate::crlf::convert_to_worktree_eager(
625 blob,
626 path,
627 &conv,
628 &file_attrs,
629 oid_hex,
630 None,
631 filter_process,
632 )
633 .map_err(std::io::Error::other)
634}
635
636pub fn blob_text_for_diff(
641 git_dir: &Path,
642 config: &ConfigSet,
643 path: &str,
644 blob: &[u8],
645 use_textconv: bool,
646) -> String {
647 blob_text_for_diff_inner(None, git_dir, config, path, blob, None, use_textconv)
648}
649
650pub fn format_combined_binary_header(
652 path: &str,
653 parent_oids: &[ObjectId],
654 result_oid: &ObjectId,
655 abbrev: usize,
656 use_cc_word: bool,
657) -> String {
658 format_combined_binary_header_n(path, parent_oids, result_oid, abbrev, use_cc_word)
659}
660
661#[must_use]
663pub fn format_combined_binary_header_n(
664 path: &str,
665 parent_oids: &[ObjectId],
666 result_oid: &ObjectId,
667 abbrev: usize,
668 use_cc_word: bool,
669) -> String {
670 let idx: Vec<String> = parent_oids.iter().map(|o| abbrev_hex(o, abbrev)).collect();
671 let res = abbrev_hex(result_oid, abbrev);
672 let kind = if use_cc_word { "cc" } else { "combined" };
673 format!(
674 "diff --{kind} {path}\nindex {}..{res}\nBinary files differ\n",
675 idx.join(",")
676 )
677}
678
679pub fn format_combined_binary(
681 path: &str,
682 parent_oids: &[ObjectId],
683 result_oid: &ObjectId,
684 abbrev: usize,
685 use_cc_word: bool,
686) -> String {
687 format_combined_binary_header_n(path, parent_oids, result_oid, abbrev, use_cc_word)
688}
689
690fn push_combined_file_headers(
691 out: &mut String,
692 merge_path: &str,
693 parent_paths: &[String],
694 parent_sides: &[CombinedParentSide],
695 combined_all_paths: bool,
696 quote_path_fully: bool,
697) {
698 let a_prefix = "a/";
699 let b_prefix = "b/";
700 if combined_all_paths {
701 for (i, p) in parent_paths.iter().enumerate() {
702 let added_no_rename = parent_sides.get(i).is_some_and(|s| {
707 s.status == crate::combined_tree_diff::CombinedParentStatus::Added
708 }) && (p.is_empty() || p == merge_path);
709 if added_no_rename {
710 out.push_str("--- /dev/null\n");
711 } else {
712 let line = format_diff_path_with_prefix(a_prefix, p, quote_path_fully);
713 out.push_str("--- ");
714 out.push_str(&line);
715 out.push('\n');
716 }
717 }
718 let line = format_diff_path_with_prefix(b_prefix, merge_path, quote_path_fully);
719 out.push_str("+++ ");
720 out.push_str(&line);
721 out.push('\n');
722 } else {
723 let la = format_diff_path_with_prefix(a_prefix, merge_path, quote_path_fully);
724 let lb = format_diff_path_with_prefix(b_prefix, merge_path, quote_path_fully);
725 out.push_str("--- ");
726 out.push_str(&la);
727 out.push('\n');
728 out.push_str("+++ ");
729 out.push_str(&lb);
730 out.push('\n');
731 }
732}
733
734#[allow(clippy::too_many_arguments)]
740pub fn format_combined_textconv_patch(
741 git_dir: &Path,
742 config: &ConfigSet,
743 odb: &Odb,
744 path: &str,
745 parent_trees: &[ObjectId],
746 result_tree: &ObjectId,
747 abbrev: usize,
748 context: usize,
749 use_cc_word: bool,
750 use_textconv: bool,
751 ws: CombinedDiffWsOptions,
752 combined_all_paths: bool,
753 parent_blob_paths: Option<&[String]>,
754 parent_sides: &[CombinedParentSide],
755 quote_path_fully: bool,
756) -> Option<String> {
757 if parent_trees.len() < 2 {
758 return None;
759 }
760 let parent_paths: Vec<&str> = if let Some(ps) = parent_blob_paths {
761 if ps.len() != parent_trees.len() {
762 return None;
763 }
764 ps.iter().map(|s| s.as_str()).collect()
765 } else {
766 vec![path; parent_trees.len()]
767 };
768
769 let mut parent_blobs = Vec::with_capacity(parent_trees.len());
770 let mut parent_oids = Vec::with_capacity(parent_trees.len());
771 for (i, t) in parent_trees.iter().enumerate() {
772 let p = parent_paths[i];
773 match blob_oid_at_path(odb, t, p) {
777 Some(oid) => {
778 parent_blobs.push(read_blob(odb, &oid));
779 parent_oids.push(oid);
780 }
781 None => {
782 parent_blobs.push(Vec::new());
783 parent_oids.push(ObjectId::zero());
784 }
785 }
786 }
787 let result_blob = read_blob_at_path(odb, result_tree, path)?;
788 let roid = blob_oid_at_path(odb, result_tree, path)?;
789
790 let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
791 if !textconv_for_patch
792 && (parent_blobs
793 .iter()
794 .any(|b| is_binary_for_diff(git_dir, path, b))
795 || is_binary_for_diff(git_dir, path, &result_blob))
796 {
797 return Some(format_combined_binary(
798 path,
799 &parent_oids,
800 &roid,
801 abbrev,
802 use_cc_word,
803 ));
804 }
805
806 let mut parent_texts = Vec::with_capacity(parent_trees.len());
807 for (i, blob) in parent_blobs.iter().enumerate() {
808 let p = parent_paths[i];
809 let oid = &parent_oids[i];
810 let t = if textconv_for_patch {
811 blob_text_for_diff_with_oid(odb, git_dir, config, p, blob, oid, true)
812 } else {
813 blob_text_for_diff(git_dir, config, p, blob, use_textconv)
814 };
815 parent_texts.push(t);
816 }
817 let tr = if textconv_for_patch {
818 blob_text_for_diff_with_oid(odb, git_dir, config, path, &result_blob, &roid, true)
819 } else {
820 blob_text_for_diff(git_dir, config, path, &result_blob, use_textconv)
821 };
822
823 let idx: Vec<String> = parent_oids.iter().map(|o| abbrev_hex(o, abbrev)).collect();
824 let ra = abbrev_hex(&roid, abbrev);
825 let kind = if use_cc_word { "cc" } else { "combined" };
826
827 let header_paths: Vec<String> = if combined_all_paths {
828 parent_paths.iter().map(|s| (*s).to_string()).collect()
829 } else {
830 Vec::new()
831 };
832
833 let mut out = String::new();
834 out.push_str(&format!("diff --{kind} {path}\n"));
835 out.push_str(&format!("index {}..{ra}\n", idx.join(",")));
836 if combined_all_paths {
837 push_combined_file_headers(
838 &mut out,
839 path,
840 &header_paths,
841 parent_sides,
842 true,
843 quote_path_fully,
844 );
845 } else {
846 push_combined_file_headers(&mut out, path, &[], parent_sides, false, quote_path_fully);
847 }
848 out.push_str(&format_combined_diff_body(
849 &parent_texts,
850 &tr,
851 context,
852 use_cc_word,
853 ws,
854 ));
855 Some(out)
856}
857
858#[allow(clippy::too_many_arguments)]
860pub fn format_worktree_conflict_combined(
861 git_dir: &Path,
862 config: &ConfigSet,
863 odb: &Odb,
864 path: &str,
865 stage1_oid: &ObjectId,
866 stage2_oid: &ObjectId,
867 stage3_oid: &ObjectId,
868 worktree_bytes: &[u8],
869 abbrev: usize,
870) -> String {
871 let ours_blob = read_blob(odb, stage2_oid);
872 let theirs_blob = read_blob(odb, stage3_oid);
873 let _base_blob = read_blob(odb, stage1_oid);
874
875 let use_conv = !worktree_bytes.contains(&0);
876 let textconv_cache_path = diff_textconv_active(git_dir, config, path);
877 let t_ours = if textconv_cache_path {
878 blob_text_for_diff_with_oid(odb, git_dir, config, path, &ours_blob, stage2_oid, true)
879 } else {
880 blob_text_for_diff(git_dir, config, path, &ours_blob, use_conv)
881 };
882 let t_theirs = if textconv_cache_path {
883 blob_text_for_diff_with_oid(odb, git_dir, config, path, &theirs_blob, stage3_oid, true)
884 } else {
885 blob_text_for_diff(git_dir, config, path, &theirs_blob, use_conv)
886 };
887 let wt_text = if textconv_cache_path || use_conv {
888 blob_text_for_diff(git_dir, config, path, worktree_bytes, true)
889 } else {
890 String::from_utf8_lossy(worktree_bytes).into_owned()
891 };
892 let wt_for_conflict = wt_text.clone();
893
894 let p1a = abbrev_hex(stage2_oid, abbrev);
895 let p2a = abbrev_hex(stage3_oid, abbrev);
896 let z = crate::diff::zero_oid();
897 let za = abbrev_hex(&z, abbrev);
898
899 let mut out = String::new();
900 out.push_str(&format!("diff --cc {path}\n"));
901 out.push_str(&format!("index {p1a},{p2a}..{za}\n"));
902 out.push_str(&format!("--- a/{path}\n"));
903 out.push_str(&format!("+++ b/{path}\n"));
904
905 if wt_text.contains("<<<<<<<") && wt_text.contains(">>>>>>>") {
906 out.push_str(&conflict_combined_body(&wt_for_conflict));
907 } else {
908 out.push_str(&format_combined_diff_body(
909 &[t_ours, t_theirs],
910 &wt_text,
911 3,
912 true,
913 CombinedDiffWsOptions::default(),
914 ));
915 }
916 out
917}
918
919fn conflict_combined_body(wt: &str) -> String {
921 let lines: Vec<&str> = wt.lines().collect();
922 let mut body = String::new();
923 let mut i = 0usize;
924 while i < lines.len() {
925 let line = lines[i];
926 if line.starts_with("<<<<<<< ") {
927 let mut hunk_new = 0u32;
928 let mut ours_count = 0u32;
929 let mut theirs_count = 0u32;
930 body.push_str(&format!("++{line}\n"));
931 hunk_new += 1;
932 i += 1;
933 while i < lines.len() && !lines[i].starts_with("=======") {
934 body.push_str(&format!(" +{}\n", lines[i]));
935 ours_count += 1;
936 hunk_new += 1;
937 i += 1;
938 }
939 if i < lines.len() && lines[i].starts_with("=======") {
940 body.push_str("++=======\n");
941 hunk_new += 1;
942 i += 1;
943 }
944 while i < lines.len() && !lines[i].starts_with(">>>>>>>") {
945 body.push_str(&format!("+ {}\n", lines[i]));
946 theirs_count += 1;
947 hunk_new += 1;
948 i += 1;
949 }
950 if i < lines.len() {
951 let closing = lines[i];
952 body.push_str(&format!("++{closing}\n"));
953 hunk_new += 1;
954 }
955 let header = format!(
956 "@@@ -1,{} -1,{} +1,{} @@@\n",
957 ours_count.max(1),
958 theirs_count.max(1),
959 hunk_new
960 );
961 return header + &body;
962 }
963 i += 1;
964 }
965 body
966}
967
968fn read_blob(odb: &Odb, oid: &ObjectId) -> Vec<u8> {
969 if *oid == crate::diff::zero_oid() {
970 return Vec::new();
971 }
972 odb.read(oid).map(|o| o.data).unwrap_or_default()
973}
974
975#[must_use]
978pub fn read_blob_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<Vec<u8>> {
979 let oid = blob_oid_at_path(odb, tree, path)?;
980 Some(read_blob(odb, &oid))
981}
982
983#[must_use]
986pub fn blob_oid_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<ObjectId> {
987 let mut current = *tree;
988 let bytes = crate::repo_relpath::storage_bytes(path);
989 let parts: Vec<&[u8]> = bytes.split(|&b| b == b'/').collect();
990 for (pi, part) in parts.iter().enumerate() {
991 let obj = odb.read(¤t).ok()?;
992 let entries = crate::objects::parse_tree(&obj.data).ok()?;
993 let found = entries.iter().find(|e| e.name == *part)?;
994 if pi + 1 == parts.len() {
995 return Some(found.oid);
996 }
997 if found.mode != 0o040000 {
998 return None;
999 }
1000 current = found.oid;
1001 }
1002 None
1003}
1004
1005fn abbrev_hex(oid: &ObjectId, abbrev: usize) -> String {
1006 let hex = oid.to_hex();
1007 let len = abbrev.min(hex.len());
1008 hex[..len].to_owned()
1009}
1010
1011#[cfg(test)]
1012mod line_coverage_tests {
1013 use super::*;
1014 use crate::combined_diff_patch::CombinedDiffWsOptions;
1015 use crate::combined_tree_diff::{CombinedDiffPath, CombinedParentSide, CombinedParentStatus};
1016 use crate::config::ConfigSet;
1017 use crate::objects::ObjectKind;
1018 use crate::odb::Odb;
1019 use tempfile::tempdir;
1020
1021 fn sample_merge(odb: &Odb) -> (ObjectId, Vec<ObjectId>, ObjectId) {
1022 let b0 = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
1023 let b1 = odb.write(ObjectKind::Blob, b"one\n").expect("blob");
1024 let b2 = odb.write(ObjectKind::Blob, b"two\n").expect("blob");
1025 let bm = odb.write(ObjectKind::Blob, b"merged\n").expect("blob");
1026 let tree = |entries: &[(&str, ObjectId)]| {
1027 let mut body = Vec::new();
1028 for (n, oid) in entries {
1029 body.extend_from_slice(format!("100644 {n}\0").as_bytes());
1030 body.extend_from_slice(oid.as_bytes());
1031 }
1032 odb.write(ObjectKind::Tree, &body).expect("tree")
1033 };
1034 let base_tree = tree(&[("f.txt", b0)]);
1035 let p1_tree = tree(&[("f.txt", b1), ("only-p1.txt", b0)]);
1036 let p2_tree = tree(&[("f.txt", b2), ("only-p2.txt", b0)]);
1037 let merge_tree = tree(&[("f.txt", bm), ("only-p1.txt", b1), ("only-p2.txt", b2)]);
1038 let commit = |tree: ObjectId, parents: &[ObjectId], msg: &str| {
1039 let mut body = format!("tree {tree}\n");
1040 for p in parents {
1041 body.push_str(&format!("parent {p}\n"));
1042 }
1043 body.push_str("author T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\n");
1044 body.push_str(msg);
1045 body.push('\n');
1046 odb.write(ObjectKind::Commit, body.as_bytes())
1047 .expect("commit")
1048 };
1049 let base = commit(base_tree, &[], "base");
1050 let p1 = commit(p1_tree, &[base], "p1");
1051 let p2 = commit(p2_tree, &[base], "p2");
1052 let merge = commit(merge_tree, &[p1, p2], "merge");
1053 (merge_tree, vec![p1, p2], merge)
1054 }
1055
1056 #[test]
1057 fn combined_formatters_and_nested_paths() {
1058 let dir = tempdir().expect("tempdir");
1059 let odb = Odb::new(dir.path());
1060 let git_dir = dir.path().join(".git");
1061 std::fs::create_dir_all(&git_dir).expect("git");
1062 let (merge_tree, parents, _merge) = sample_merge(&odb);
1063 let cfg = ConfigSet::new();
1064 let paths = combined_diff_paths(&odb, &merge_tree, &parents);
1065 assert!(!paths.is_empty());
1066 let nested = all_blob_paths_in_tree_order(&odb, &merge_tree);
1067 assert!(nested.iter().any(|p| p.contains("only-p1")));
1068 let p1_tree = {
1069 let c = odb.read(&parents[0]).expect("p1");
1070 crate::objects::parse_commit(&c.data).expect("parse").tree
1071 };
1072 let p1_oid = blob_oid_at_path(&odb, &p1_tree, "f.txt").expect("p1 blob");
1073 let merge_oid = blob_oid_at_path(&odb, &merge_tree, "f.txt").expect("merge blob");
1074 let mut path = CombinedDiffPath {
1075 path: "f.txt".into(),
1076 merge_mode: 0o100644,
1077 merge_oid,
1078 parents: vec![
1079 CombinedParentSide {
1080 status: CombinedParentStatus::Modified,
1081 mode: 0o100644,
1082 oid: p1_oid,
1083 rename_from: None,
1084 },
1085 CombinedParentSide {
1086 status: CombinedParentStatus::Modified,
1087 mode: 0o100644,
1088 oid: blob_oid_at_path(&odb, &merge_tree, "f.txt").expect("oid"),
1089 rename_from: Some("old-f.txt".into()),
1090 },
1091 ],
1092 };
1093 enrich_combined_path_renames(&odb, &mut path, &parents, &merge_tree, 50);
1094 let _ = format_combined_binary_header_n("f.txt", &[p1_oid], &merge_oid, 7, false);
1095 let _ = format_combined_textconv_patch(
1096 &git_dir,
1097 &cfg,
1098 &odb,
1099 "f.txt",
1100 &parents,
1101 &merge_tree,
1102 7,
1103 3,
1104 false,
1105 false,
1106 CombinedDiffWsOptions::default(),
1107 false,
1108 Some(&["f.txt".to_owned()]),
1109 &path.parents,
1110 true,
1111 );
1112 let blob = read_blob_at_path(&odb, &merge_tree, "only-p2.txt").expect("blob");
1113 let _ = convert_blob_to_worktree_for_path(
1114 &git_dir,
1115 dir.path(),
1116 None,
1117 &odb,
1118 "only-p2.txt",
1119 &blob,
1120 None,
1121 None,
1122 );
1123 let z = crate::diff::zero_oid();
1124 let _ = format_worktree_conflict_combined(
1125 &git_dir, &cfg, &odb, "f.txt", &z, &p1_oid, &merge_oid, b"plain\n", 12,
1126 );
1127 }
1128}