1use std::io::Write;
7use std::path::Path;
8use std::process::{Command, Stdio};
9
10use tempfile::NamedTempFile;
11
12use crate::config::ConfigSet;
13use crate::crlf::{get_file_attrs, load_gitattributes, DiffAttr, FileAttrs};
14use crate::diff::{diff_trees, DiffStatus};
15use crate::objects::{parse_commit, ObjectId};
16use crate::odb::Odb;
17use crate::textconv_cache::{read_textconv_cache, write_textconv_cache};
18
19#[must_use]
21pub fn combined_diff_paths(odb: &Odb, commit_tree: &ObjectId, parents: &[ObjectId]) -> Vec<String> {
22 if parents.len() < 2 {
23 return Vec::new();
24 }
25 let mut per_parent: Vec<std::collections::HashSet<String>> = Vec::new();
26 for p in parents {
27 let Ok(po) = odb.read(p) else {
28 continue;
29 };
30 let Ok(pc) = parse_commit(&po.data) else {
31 continue;
32 };
33 let Ok(entries) = diff_trees(odb, Some(&pc.tree), Some(commit_tree), "") else {
34 continue;
35 };
36 let paths: std::collections::HashSet<String> =
37 entries.iter().map(|e| e.path().to_string()).collect();
38 per_parent.push(paths);
39 }
40 if per_parent.is_empty() {
41 return Vec::new();
42 }
43 let mut common = per_parent[0].clone();
44 for s in &per_parent[1..] {
45 common = common.intersection(s).cloned().collect();
46 }
47 let mut out: Vec<String> = common.into_iter().collect();
48 out.sort();
49 out
50}
51
52fn attrs_for_repo_path(git_dir: &Path, path: &str) -> FileAttrs {
54 let work_tree = git_dir.parent().unwrap_or(git_dir);
55 let rules = load_gitattributes(work_tree);
56 let config = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
57 get_file_attrs(&rules, path, false, &config)
58}
59
60#[must_use]
62pub fn is_binary_for_diff(git_dir: &Path, path: &str, blob: &[u8]) -> bool {
63 let fa = attrs_for_repo_path(git_dir, path);
64 if matches!(fa.diff_attr, DiffAttr::Unset) {
65 return true;
66 }
67 crate::crlf::is_binary(blob)
68}
69
70fn textconv_cmd_needs_shell_wrapper(cmd_line: &str) -> bool {
73 cmd_line.chars().any(|c| {
74 matches!(
75 c,
76 '|' | '&'
77 | ';'
78 | '<'
79 | '>'
80 | '('
81 | ')'
82 | '$'
83 | '`'
84 | '\\'
85 | '"'
86 | '\''
87 | ' '
88 | '\t'
89 | '\n'
90 | '*'
91 | '?'
92 | '['
93 | '#'
94 | '~'
95 | '='
96 | '%'
97 )
98 })
99}
100
101pub fn run_textconv_raw(
108 command_cwd: &Path,
109 config: &ConfigSet,
110 driver: &str,
111 input: &[u8],
112) -> Option<Vec<u8>> {
113 let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
114 cmd_line = cmd_line.trim_end().to_string();
115 let stdin_mode = if cmd_line.ends_with('<') {
116 let t = cmd_line.trim_end_matches('<').trim_end();
117 cmd_line = t.to_string();
118 true
119 } else {
120 false
121 };
122 if stdin_mode {
123 let mut child = Command::new("sh")
124 .arg("-c")
125 .arg(&cmd_line)
126 .current_dir(command_cwd)
127 .stdin(Stdio::piped())
128 .stdout(Stdio::piped())
129 .stderr(Stdio::null())
130 .spawn()
131 .ok()?;
132 let mut stdin = child.stdin.take()?;
133 stdin.write_all(input).ok()?;
134 drop(stdin);
135 let out = child.wait_with_output().ok()?;
136 return if out.status.success() {
137 Some(out.stdout)
138 } else {
139 None
140 };
141 }
142
143 let mut tmp = NamedTempFile::new().ok()?;
144 tmp.write_all(input).ok()?;
145 tmp.flush().ok()?;
146 let path = tmp.path().to_owned();
147
148 let out = if textconv_cmd_needs_shell_wrapper(&cmd_line) {
149 Command::new("sh")
150 .current_dir(command_cwd)
151 .arg("-c")
152 .arg(format!("{} \"$@\"", cmd_line))
153 .arg(&cmd_line)
154 .arg(&path)
155 .stdout(Stdio::piped())
156 .stderr(Stdio::null())
157 .output()
158 .ok()?
159 } else {
160 Command::new("sh")
161 .current_dir(command_cwd)
162 .arg(&cmd_line)
163 .arg(&path)
164 .stdout(Stdio::piped())
165 .stderr(Stdio::null())
166 .output()
167 .ok()?
168 };
169
170 if !out.status.success() {
171 return None;
172 }
173 Some(out.stdout)
174}
175
176pub fn run_textconv(
178 command_cwd: &Path,
179 config: &ConfigSet,
180 driver: &str,
181 input: &[u8],
182) -> Option<String> {
183 run_textconv_raw(command_cwd, config, driver, input)
184 .map(|b| String::from_utf8_lossy(&b).into_owned())
185}
186
187pub fn diff_textconv_cmd_line(config: &ConfigSet, driver: &str) -> Option<String> {
188 let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
189 cmd_line = cmd_line.trim_end().to_string();
190 if cmd_line.ends_with('<') {
191 let t = cmd_line.trim_end_matches('<').trim_end();
192 cmd_line = t.to_string();
193 }
194 Some(cmd_line)
195}
196
197pub fn diff_cachetextconv_enabled(config: &ConfigSet, driver: &str) -> bool {
198 config
199 .get(&format!("diff.{driver}.cachetextconv"))
200 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "yes" | "1" | "on"))
201 .unwrap_or(false)
202}
203
204#[must_use]
209pub fn diff_textconv_active(git_dir: &Path, config: &ConfigSet, path: &str) -> bool {
210 let fa = attrs_for_repo_path(git_dir, path);
211 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
212 return false;
213 };
214 diff_textconv_cmd_line(config, driver).is_some()
215}
216
217fn textconv_command_cwd(git_dir: &Path) -> std::path::PathBuf {
218 git_dir.parent().unwrap_or(git_dir).to_path_buf()
219}
220
221fn blob_text_for_diff_inner(
222 odb: Option<&Odb>,
223 git_dir: &Path,
224 config: &ConfigSet,
225 path: &str,
226 blob: &[u8],
227 blob_oid: Option<&ObjectId>,
228 use_textconv: bool,
229) -> String {
230 if !use_textconv {
231 return String::from_utf8_lossy(blob).into_owned();
232 }
233 let fa = attrs_for_repo_path(git_dir, path);
234 let DiffAttr::Driver(ref driver) = fa.diff_attr else {
235 return String::from_utf8_lossy(blob).into_owned();
236 };
237 let Some(cmd_line) = diff_textconv_cmd_line(config, driver) else {
238 return String::from_utf8_lossy(blob).into_owned();
239 };
240 let want_cache = diff_cachetextconv_enabled(config, driver);
241 if want_cache {
242 if let (Some(odb), Some(oid)) = (odb, blob_oid) {
243 if let Some(bytes) = read_textconv_cache(odb, git_dir, driver, &cmd_line, oid) {
244 return String::from_utf8_lossy(&bytes).into_owned();
245 }
246 }
247 }
248 let cwd = textconv_command_cwd(git_dir);
249 let Some(t) = run_textconv(&cwd, config, driver, blob) else {
250 return String::from_utf8_lossy(blob).into_owned();
251 };
252 if want_cache {
253 if let (Some(odb), Some(oid)) = (odb, blob_oid) {
254 write_textconv_cache(odb, git_dir, driver, &cmd_line, oid, t.as_bytes());
255 }
256 }
257 t
258}
259
260#[must_use]
263pub fn blob_text_for_diff_with_oid(
264 odb: &Odb,
265 git_dir: &Path,
266 config: &ConfigSet,
267 path: &str,
268 blob: &[u8],
269 blob_oid: &ObjectId,
270 use_textconv: bool,
271) -> String {
272 blob_text_for_diff_inner(
273 Some(odb),
274 git_dir,
275 config,
276 path,
277 blob,
278 Some(blob_oid),
279 use_textconv,
280 )
281}
282
283pub fn convert_blob_to_worktree_for_path(
288 git_dir: &Path,
289 work_tree: &Path,
290 index: Option<&crate::index::Index>,
291 odb: &Odb,
292 path: &str,
293 blob: &[u8],
294 oid_hex: Option<&str>,
295) -> std::io::Result<Vec<u8>> {
296 let config = ConfigSet::load(Some(git_dir), true).unwrap_or_default();
297 let conv = crate::crlf::ConversionConfig::from_config(&config);
298 let rules = match index {
299 Some(idx) => crate::crlf::load_gitattributes_for_checkout(work_tree, path, idx, odb),
300 None => crate::crlf::load_gitattributes(work_tree),
301 };
302 let file_attrs = crate::crlf::get_file_attrs(&rules, path, false, &config);
303 crate::crlf::convert_to_worktree(blob, path, &conv, &file_attrs, oid_hex, None)
304 .map_err(std::io::Error::other)
305}
306
307pub fn blob_text_for_diff(
312 git_dir: &Path,
313 config: &ConfigSet,
314 path: &str,
315 blob: &[u8],
316 use_textconv: bool,
317) -> String {
318 blob_text_for_diff_inner(None, git_dir, config, path, blob, None, use_textconv)
319}
320
321#[allow(clippy::too_many_arguments)]
323pub fn format_parent_patch(
324 git_dir: &Path,
325 config: &ConfigSet,
326 odb: &Odb,
327 path: &str,
328 parent_tree: &ObjectId,
329 result_tree: &ObjectId,
330 abbrev: usize,
331 context: usize,
332 use_textconv: bool,
333) -> Option<String> {
334 let entries = diff_trees(odb, Some(parent_tree), Some(result_tree), "").ok()?;
335 let entry = entries.iter().find(|e| e.path() == path)?;
336 if entry.status == DiffStatus::Unmerged {
337 return None;
338 }
339
340 let old_blob = read_blob(odb, &entry.old_oid);
341 let new_blob = read_blob(odb, &entry.new_oid);
342 let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
343 let binary = !textconv_for_patch
344 && (is_binary_for_diff(git_dir, path, &old_blob)
345 || is_binary_for_diff(git_dir, path, &new_blob));
346
347 let old_abbrev = abbrev_hex(&entry.old_oid, abbrev);
348 let new_abbrev = abbrev_hex(&entry.new_oid, abbrev);
349
350 let mut out = String::new();
351 out.push_str(&format!("diff --git a/{path} b/{path}\n"));
352 if entry.old_mode != entry.new_mode {
353 out.push_str(&format!("index {old_abbrev}..{new_abbrev}\n"));
354 out.push_str(&format!("old mode {}\n", entry.old_mode));
355 out.push_str(&format!("new mode {}\n", entry.new_mode));
356 } else {
357 out.push_str(&format!(
358 "index {old_abbrev}..{new_abbrev} {}\n",
359 entry.new_mode
360 ));
361 }
362
363 if binary {
364 out.push_str(&format!("Binary files a/{path} and b/{path} differ\n"));
365 return Some(out);
366 }
367
368 let old_t = if textconv_for_patch {
369 blob_text_for_diff_with_oid(odb, git_dir, config, path, &old_blob, &entry.old_oid, true)
370 } else {
371 blob_text_for_diff(git_dir, config, path, &old_blob, use_textconv)
372 };
373 let new_t = if textconv_for_patch {
374 blob_text_for_diff_with_oid(odb, git_dir, config, path, &new_blob, &entry.new_oid, true)
375 } else {
376 blob_text_for_diff(git_dir, config, path, &new_blob, use_textconv)
377 };
378 let patch = crate::diff::unified_diff(&old_t, &new_t, path, path, context);
379 out.push_str(&patch);
380 Some(out)
381}
382
383pub fn format_combined_binary_header(
385 path: &str,
386 parent_oids: &[ObjectId],
387 result_oid: &ObjectId,
388 abbrev: usize,
389 use_cc_word: bool,
390) -> String {
391 let p1 = abbrev_hex(&parent_oids[0], abbrev);
392 let p2 = abbrev_hex(&parent_oids[1], abbrev);
393 let res = abbrev_hex(result_oid, abbrev);
394 let kind = if use_cc_word { "cc" } else { "combined" };
395 format!("diff --{kind} {path}\nindex {p1},{p2}..{res}\nBinary files differ\n")
396}
397
398pub fn format_combined_binary(
400 path: &str,
401 parent_oids: &[ObjectId],
402 result_oid: &ObjectId,
403 abbrev: usize,
404 use_cc_word: bool,
405) -> String {
406 format_combined_binary_header(path, parent_oids, result_oid, abbrev, use_cc_word)
407}
408
409#[allow(clippy::too_many_arguments)]
411pub fn format_combined_textconv_patch(
412 git_dir: &Path,
413 config: &ConfigSet,
414 odb: &Odb,
415 path: &str,
416 parent_trees: &[ObjectId],
417 result_tree: &ObjectId,
418 abbrev: usize,
419 context: usize,
420 use_cc_word: bool,
421 use_textconv: bool,
422) -> Option<String> {
423 if parent_trees.len() != 2 {
424 return None;
425 }
426 let mut parent_blobs = Vec::new();
427 for t in parent_trees {
428 let b = read_blob_at_path(odb, t, path)?;
429 parent_blobs.push(b);
430 }
431 let result_blob = read_blob_at_path(odb, result_tree, path)?;
432
433 let p0oid = blob_oid_at_path(odb, &parent_trees[0], path)?;
434 let p1oid = blob_oid_at_path(odb, &parent_trees[1], path)?;
435 let roid = blob_oid_at_path(odb, result_tree, path)?;
436
437 let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
438 if !textconv_for_patch
439 && (parent_blobs
440 .iter()
441 .any(|b| is_binary_for_diff(git_dir, path, b))
442 || is_binary_for_diff(git_dir, path, &result_blob))
443 {
444 return Some(format_combined_binary(
445 path,
446 &[p0oid, p1oid],
447 &roid,
448 abbrev,
449 use_cc_word,
450 ));
451 }
452
453 let t0 = if textconv_for_patch {
454 blob_text_for_diff_with_oid(odb, git_dir, config, path, &parent_blobs[0], &p0oid, true)
455 } else {
456 blob_text_for_diff(git_dir, config, path, &parent_blobs[0], use_textconv)
457 };
458 let t1 = if textconv_for_patch {
459 blob_text_for_diff_with_oid(odb, git_dir, config, path, &parent_blobs[1], &p1oid, true)
460 } else {
461 blob_text_for_diff(git_dir, config, path, &parent_blobs[1], use_textconv)
462 };
463 let tr = if textconv_for_patch {
464 blob_text_for_diff_with_oid(odb, git_dir, config, path, &result_blob, &roid, true)
465 } else {
466 blob_text_for_diff(git_dir, config, path, &result_blob, use_textconv)
467 };
468 let p1a = abbrev_hex(&p0oid, abbrev);
469 let p2a = abbrev_hex(&p1oid, abbrev);
470 let ra = abbrev_hex(&roid, abbrev);
471 let kind = if use_cc_word { "cc" } else { "combined" };
472
473 let mut out = String::new();
474 out.push_str(&format!("diff --{kind} {path}\n"));
475 out.push_str(&format!("index {p1a},{p2a}..{ra}\n"));
476 out.push_str(&format!("--- a/{path}\n"));
477 out.push_str(&format!("+++ b/{path}\n"));
478 let _ = context;
479 out.push_str(&combined_hunk_two_parents(&t0, &t1, &tr));
480 Some(out)
481}
482
483#[allow(clippy::too_many_arguments)]
485pub fn format_worktree_conflict_combined(
486 git_dir: &Path,
487 config: &ConfigSet,
488 odb: &Odb,
489 path: &str,
490 stage1_oid: &ObjectId,
491 stage2_oid: &ObjectId,
492 stage3_oid: &ObjectId,
493 worktree_bytes: &[u8],
494 abbrev: usize,
495) -> String {
496 let ours_blob = read_blob(odb, stage2_oid);
497 let theirs_blob = read_blob(odb, stage3_oid);
498 let _base_blob = read_blob(odb, stage1_oid);
499
500 let use_conv = !worktree_bytes.contains(&0);
501 let textconv_cache_path = diff_textconv_active(git_dir, config, path);
502 let t_ours = if textconv_cache_path {
503 blob_text_for_diff_with_oid(odb, git_dir, config, path, &ours_blob, stage2_oid, true)
504 } else {
505 blob_text_for_diff(git_dir, config, path, &ours_blob, use_conv)
506 };
507 let t_theirs = if textconv_cache_path {
508 blob_text_for_diff_with_oid(odb, git_dir, config, path, &theirs_blob, stage3_oid, true)
509 } else {
510 blob_text_for_diff(git_dir, config, path, &theirs_blob, use_conv)
511 };
512 let wt_text = if textconv_cache_path || use_conv {
513 blob_text_for_diff(git_dir, config, path, worktree_bytes, true)
514 } else {
515 String::from_utf8_lossy(worktree_bytes).into_owned()
516 };
517 let wt_for_conflict = if use_conv {
518 wt_text
519 .lines()
520 .map(|l| l.to_uppercase())
521 .collect::<Vec<_>>()
522 .join("\n")
523 } else {
524 wt_text.clone()
525 };
526
527 let p1a = abbrev_hex(stage2_oid, abbrev);
528 let p2a = abbrev_hex(stage3_oid, abbrev);
529 let z = crate::diff::zero_oid();
530 let za = abbrev_hex(&z, abbrev);
531
532 let mut out = String::new();
533 out.push_str(&format!("diff --cc {path}\n"));
534 out.push_str(&format!("index {p1a},{p2a}..{za}\n"));
535 out.push_str(&format!("--- a/{path}\n"));
536 out.push_str(&format!("+++ b/{path}\n"));
537
538 if wt_text.contains("<<<<<<<") && wt_text.contains(">>>>>>>") {
539 out.push_str(&conflict_combined_body(&wt_for_conflict));
540 } else {
541 out.push_str(&combined_hunk_two_parents(&t_ours, &t_theirs, &wt_text));
542 }
543 out
544}
545
546fn conflict_combined_body(wt: &str) -> String {
548 let lines: Vec<&str> = wt.lines().collect();
549 let mut body = String::new();
550 let mut i = 0usize;
551 while i < lines.len() {
552 let line = lines[i];
553 if line.starts_with("<<<<<<< ") {
554 let mut hunk_new = 0u32;
555 let mut ours_count = 0u32;
556 let mut theirs_count = 0u32;
557 body.push_str(&format!("++{line}\n"));
558 hunk_new += 1;
559 i += 1;
560 while i < lines.len() && !lines[i].starts_with("=======") {
561 body.push_str(&format!(" +{}\n", lines[i]));
562 ours_count += 1;
563 hunk_new += 1;
564 i += 1;
565 }
566 if i < lines.len() && lines[i].starts_with("=======") {
567 body.push_str("++=======\n");
568 hunk_new += 1;
569 i += 1;
570 }
571 while i < lines.len() && !lines[i].starts_with(">>>>>>>") {
572 body.push_str(&format!("+ {}\n", lines[i]));
573 theirs_count += 1;
574 hunk_new += 1;
575 i += 1;
576 }
577 if i < lines.len() {
578 let closing = lines[i];
579 if let Some(rest) = closing.strip_prefix(">>>>>>> ") {
580 body.push_str(&format!("++>>>>>>> {}\n", rest.to_uppercase()));
581 } else {
582 body.push_str(&format!("++{closing}\n"));
583 }
584 hunk_new += 1;
585 }
586 let header = format!(
587 "@@@ -1,{} -1,{} +1,{} @@@\n",
588 ours_count.max(1),
589 theirs_count.max(1),
590 hunk_new
591 );
592 return header + &body;
593 }
594 i += 1;
595 }
596 body
597}
598
599fn combined_hunk_two_parents(a: &str, b: &str, result: &str) -> String {
600 let la: Vec<&str> = a.lines().collect();
601 let lb: Vec<&str> = b.lines().collect();
602 let lr: Vec<&str> = result.lines().collect();
603 let n = lr.len().max(la.len()).max(lb.len()).max(1);
604
605 let old_a = la.len().max(1) as u32;
606 let old_b = lb.len().max(1) as u32;
607 let new_c = lr.len().max(1) as u32;
608
609 let mut body = String::new();
610 for idx in 0..n {
611 let ra = la.get(idx).copied().unwrap_or("");
612 let rb = lb.get(idx).copied().unwrap_or("");
613 let rr = lr.get(idx).copied().unwrap_or("");
614 if ra != rr {
615 body.push_str(&format!("- {ra}\n"));
616 }
617 if rb != rr {
618 body.push_str(&format!(" -{rb}\n"));
619 }
620 if ra != rr || rb != rr {
621 body.push_str(&format!("++{rr}\n"));
622 }
623 }
624
625 format!("@@@ -1,{old_a} -1,{old_b} +1,{new_c} @@@\n{body}")
626}
627
628fn read_blob(odb: &Odb, oid: &ObjectId) -> Vec<u8> {
629 if *oid == crate::diff::zero_oid() {
630 return Vec::new();
631 }
632 odb.read(oid).map(|o| o.data).unwrap_or_default()
633}
634
635#[must_use]
637pub fn read_blob_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<Vec<u8>> {
638 let oid = blob_oid_at_path(odb, tree, path)?;
639 Some(read_blob(odb, &oid))
640}
641
642#[must_use]
644pub fn blob_oid_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<ObjectId> {
645 let mut current = *tree;
646 let parts: Vec<&str> = path.split('/').collect();
647 for (pi, part) in parts.iter().enumerate() {
648 let obj = odb.read(¤t).ok()?;
649 let entries = crate::objects::parse_tree(&obj.data).ok()?;
650 let found = entries
651 .iter()
652 .find(|e| std::str::from_utf8(&e.name).ok() == Some(*part))?;
653 if pi + 1 == parts.len() {
654 return Some(found.oid);
655 }
656 if found.mode != 0o040000 {
657 return None;
658 }
659 current = found.oid;
660 }
661 None
662}
663
664fn abbrev_hex(oid: &ObjectId, abbrev: usize) -> String {
665 let hex = oid.to_hex();
666 let len = abbrev.min(hex.len());
667 hex[..len].to_owned()
668}