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