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::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/// Paths that differ between the merge result tree and **every** parent tree.
27#[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    // Paths removed from the merge result are not present in `commit_tree`, so a merge-tree walk
59    // alone would miss them. Git still lists them in combined diff when every parent changed
60    // (`t4057-diff-combined-paths` merge + `git rm` amend).
61    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/// Per-parent blob paths for a combined merge path when rename detection is enabled.
71///
72/// Returns `None` when no special mapping is needed (each parent reads `merge_path`).
73#[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        // Run the *full* per-parent diff (not restricted to `merge_path`): the rename source is a
101        // path that was deleted relative to this parent, so it lives outside `merge_path`. Git's
102        // `find_paths_generic` likewise runs a full rename-detecting diff against each parent and
103        // intersects on the result path.
104        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
130/// The entry at the `repo_relpath::storage_string` path `path` in `tree`; unreadable trees count
131/// as missing.
132fn 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
138/// Enrich a combined diff path with per-parent rename info (`-M`/`-C`).
139///
140/// For each parent that the merge-tree walk classified as `Added` (the result name does not exist
141/// in that parent), run a full rename-detecting diff against that parent. If the result path is the
142/// target of a rename, rewrite that parent side to `Renamed`, fill in the source blob's mode/OID,
143/// and record the source name. This mirrors git's `find_paths_generic`, which intersects per-parent
144/// rename-detecting diffs (producing `RR` with all source names under `--combined-all-paths`).
145pub 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/// All blob paths in `tree_oid`, depth-first in Git tree entry order (for `diff` / `log`
184/// `--rotate-to` / `--skip-to`).
185#[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
217/// List paths under `prefix` that appear in `want`, following merge-tree entry order (Git
218/// `traverse_trees` order), not lexicographic sorting.
219fn 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
251/// Load attributes for `path` using root `.gitattributes` and `info/attributes`.
252fn 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/// True if diff should treat this path as binary (NUL in blob or `-diff` / `diff=unset`).
265#[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
274/// True when `diff.<driver>.binary` is set for this path's `diff=<driver>` attribute.
275fn 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/// True when a path's `diff=<driver>` attribute pairs with an explicit `diff.<driver>.binary=false`.
283///
284/// Git resolves the driver before the big-file shortcut and keeps such paths textual even above
285/// `core.bigFileThreshold`.
286#[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/// Force `Binary files ... differ` when the path's diff driver sets `binary`, except for symlinks.
297///
298/// Matches Git's `diff_filespec_is_binary` driver flag: `diff.<name>.binary` applies to paths
299/// using that driver, but symlink modes (`120000`) still emit textual symlink-target patches
300/// (t4011).
301#[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
322/// True when Git would wrap the textconv command with `sh -c 'cmd "$@"' -- ...`
323/// (`prepare_shell_cmd` in Git's `run-command.c`).
324fn 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
353/// Run `diff.<driver>.textconv` on `input`; returns raw stdout on success.
354///
355/// Matches Git's `run_textconv` / `prepare_shell_cmd`: by default the blob is written to a
356/// temporary file and passed as an argument after `--`. Commands that contain shell
357/// metacharacters (including spaces) use `sh -c 'pgm "$@"' -- pgm <tempfile>`. Config lines
358/// ending with ` <` use stdin instead of a tempfile.
359pub 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
433/// Run `diff.<driver>.textconv` feeding `input` on stdin; returns UTF-8 lossy text on success.
434pub 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
444/// `diff_textconv_cmd_line` function.
445pub 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
455/// `diff_cachetextconv_enabled` function.
456pub 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/// Returns true when `path` has a `diff=<driver>` attribute and `diff.<driver>.textconv` is set.
464///
465/// When this holds, Git treats the path as textual for diff purposes (even if the blob contains
466/// NUL), running textconv instead of emitting `Binary files differ`.
467#[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/// True when a path carries the `-diff` (or `diff=unset` / `binary`) attribute,
477/// which forces Git to emit `Binary files ... differ` regardless of content.
478#[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/// True when a path carries a bare `diff` (set) attribute, which forces Git to
487/// produce a textual diff even when the blob contains NUL bytes (t4020 #65).
488#[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/// Resolved external diff driver from a path's `diff=<name>` attribute.
494///
495/// Mirrors Git's `userdiff_find_by_path` → `drv->external`: when a path has a
496/// `diff=<name>` attribute and `diff.<name>.command` is configured, that driver
497/// takes precedence over `GIT_EXTERNAL_DIFF` / `diff.external`. The boolean is
498/// `diff.<name>.trustExitCode`.
499#[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/// Like [`blob_text_for_diff`], but uses `refs/notes/textconv/<driver>` when
564/// `diff.<driver>.cachetextconv` is true and `blob_oid` is known.
565#[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/// Blob bytes after smudge/EOL conversion for `path`, using the same rules as checkout.
587///
588/// `index` is used to pick up `.gitattributes` from the index when the worktree file is
589/// missing; pass `None` to use only on-disk `.gitattributes` under `work_tree`.
590///
591/// When a [`crate::repo::Repository`] handle is available, checkout uses the same smudge path
592/// with destination-index attribute loading (see `WorktreeRules::from_checkout_prospective_index`).
593#[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
634/// Prepare blob bytes for diff: optional textconv when `use_textconv` and `diff=<driver>`.
635///
636/// Does not read or write the textconv notes cache; use [`blob_text_for_diff_with_oid`] when the
637/// blob OID is known (e.g. commit diffs with `cachetextconv`).
638pub 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
648/// Combined diff header: `diff --combined` or `diff --cc`.
649pub 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/// `index` line for N-parent combined/binary diffs (`p1,p2,...pn..result`).
660#[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
677/// Full combined diff for a binary path (two parents).
678pub 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            // Show `/dev/null` only when this parent genuinely lacks the path (a true add). When a
701            // per-parent rename was detected, `parent_paths[i]` holds the renamed-from path (which
702            // the parent *does* have), so emit that name even though the merge-tree walk classified
703            // the result side as "Added".
704            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/// Combined text diff with optional textconv (N parents, single merge path).
733///
734/// `parent_blob_paths` — when set, length must match `parent_trees`; each entry is the path
735/// used to read that parent's blob (for `--combined-all-paths` rename cases). When `None`,
736/// every parent uses `path`.
737#[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        // A parent that lacks the path contributes /dev/null (empty content, zero OID): the path
772        // was added relative to that parent. Git still emits the combined patch (`AA`/`new file`),
773        // so do not bail out here.
774        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/// `git diff` / `git diff --cc` during a conflict: worktree file with markers.
857#[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
917/// Format the combined hunk for a worktree file that still contains conflict markers.
918fn 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/// Read the blob at `path` in `tree`, or `None` if missing.
974/// `path` uses the reversible `repo_relpath::storage_string` representation.
975#[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/// OID of the blob at `path` in `tree`.
982/// `path` uses the reversible `repo_relpath::storage_string` representation.
983#[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}