Skip to main content

grit_lib/
merge_diff.rs

1//! Merge commit and combined (`--cc` / `-c`) diff helpers.
2//!
3//! These mirror the subset of Git's combine-diff output needed for porcelain
4//! commands (`git show`, `git diff` during conflicts, `git diff-tree -c`).
5
6use 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/// Paths that differ between the merge result tree and **every** parent tree.
24#[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    // Paths removed from the merge result are not present in `commit_tree`, so a merge-tree walk
56    // alone would miss them. Git still lists them in combined diff when every parent changed
57    // (`t4057-diff-combined-paths` merge + `git rm` amend).
58    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/// Per-parent blob paths for a combined merge path when rename detection is enabled.
68///
69/// Returns `None` when no special mapping is needed (each parent reads `merge_path`).
70#[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/// All blob paths in `tree_oid`, depth-first in Git tree entry order (for `diff` / `log`
123/// `--rotate-to` / `--skip-to`).
124#[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
156/// List paths under `prefix` that appear in `want`, following merge-tree entry order (Git
157/// `traverse_trees` order), not lexicographic sorting.
158fn 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
190/// Load attributes for `path` using root `.gitattributes` and `info/attributes`.
191fn 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/// True if diff should treat this path as binary (NUL in blob or `-diff` / `diff=unset`).
199#[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
208/// True when `diff.<driver>.binary` is set for this path's `diff=<driver>` attribute.
209fn 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/// Force `Binary files ... differ` when the path's diff driver sets `binary`, except for symlinks.
217///
218/// Matches Git's `diff_filespec_is_binary` driver flag: `diff.<name>.binary` applies to paths
219/// using that driver, but symlink modes (`120000`) still emit textual symlink-target patches
220/// (t4011).
221#[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
242/// True when Git would wrap the textconv command with `sh -c 'cmd "$@"' -- ...`
243/// (`prepare_shell_cmd` in Git's `run-command.c`).
244fn 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
273/// Run `diff.<driver>.textconv` on `input`; returns raw stdout on success.
274///
275/// Matches Git's `run_textconv` / `prepare_shell_cmd`: by default the blob is written to a
276/// temporary file and passed as an argument after `--`. Commands that contain shell
277/// metacharacters (including spaces) use `sh -c 'pgm "$@"' -- pgm <tempfile>`. Config lines
278/// ending with ` <` use stdin instead of a tempfile.
279pub 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
348/// Run `diff.<driver>.textconv` feeding `input` on stdin; returns UTF-8 lossy text on success.
349pub 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/// Returns true when `path` has a `diff=<driver>` attribute and `diff.<driver>.textconv` is set.
377///
378/// When this holds, Git treats the path as textual for diff purposes (even if the blob contains
379/// NUL), running textconv instead of emitting `Binary files differ`.
380#[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/// Like [`blob_text_for_diff`], but uses `refs/notes/textconv/<driver>` when
433/// `diff.<driver>.cachetextconv` is true and `blob_oid` is known.
434#[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
455/// Blob bytes after smudge/EOL conversion for `path`, using the same rules as checkout.
456///
457/// `index` is used to pick up `.gitattributes` from the index when the worktree file is
458/// missing; pass `None` to use only on-disk `.gitattributes` under `work_tree`.
459pub 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
479/// Prepare blob bytes for diff: optional textconv when `use_textconv` and `diff=<driver>`.
480///
481/// Does not read or write the textconv notes cache; use [`blob_text_for_diff_with_oid`] when the
482/// blob OID is known (e.g. commit diffs with `cachetextconv`).
483pub 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/// `diff --git` against parent `p` for merge commit `-m` output.
494#[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
563/// Combined diff header: `diff --combined` or `diff --cc`.
564pub 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/// `index` line for N-parent combined/binary diffs (`p1,p2,...pn..result`).
575#[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
592/// Full combined diff for a binary path (two parents).
593pub 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/// Combined text diff with optional textconv (N parents, single merge path).
644///
645/// `parent_blob_paths` — when set, length must match `parent_trees`; each entry is the path
646/// used to read that parent's blob (for `--combined-all-paths` rename cases). When `None`,
647/// every parent uses `path`.
648#[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/// Combined `diff --cc` for an unmerged **gitlink** path when stage blobs are absent from the ODB
759/// (e.g. `t4027` synthetic `1ff…` / `2ff…` OIDs). Uses full hex in `Subproject commit` lines like Git.
760#[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/// `git diff` / `git diff --cc` during a conflict: worktree file with markers.
787#[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
847/// Format the combined hunk for a worktree file that still contains conflict markers.
848fn 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/// For each line of `result`, whether that line is absent from `parent` per a line-oriented diff.
897#[allow(dead_code)] // Reserved for tighter `--cc` hunk alignment with Git's `dump_sline`.
898fn 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/// Combined hunk body for two parents (Git `dump_sline` / `diff --cc` line prefixes).
923#[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/// Read the blob at `path` in `tree`, or `None` if missing.
962#[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/// OID of the blob at `path` in `tree`.
969#[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(&current).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}