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/// Mode of the blob at `path` in `tree`, or `None` if missing / not a blob.
131fn blob_mode_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<u32> {
132    let mut current = *tree;
133    let parts: Vec<&str> = path.split('/').collect();
134    for (pi, part) in parts.iter().enumerate() {
135        let obj = odb.read(&current).ok()?;
136        let entries = crate::objects::parse_tree(&obj.data).ok()?;
137        let found = entries
138            .iter()
139            .find(|e| std::str::from_utf8(&e.name).ok() == Some(*part))?;
140        if pi + 1 == parts.len() {
141            return Some(found.mode);
142        }
143        if found.mode != 0o040000 {
144            return None;
145        }
146        current = found.oid;
147    }
148    None
149}
150
151/// Enrich a combined diff path with per-parent rename info (`-M`/`-C`).
152///
153/// For each parent that the merge-tree walk classified as `Added` (the result name does not exist
154/// in that parent), run a full rename-detecting diff against that parent. If the result path is the
155/// target of a rename, rewrite that parent side to `Renamed`, fill in the source blob's mode/OID,
156/// and record the source name. This mirrors git's `find_paths_generic`, which intersects per-parent
157/// rename-detecting diffs (producing `RR` with all source names under `--combined-all-paths`).
158pub fn enrich_combined_path_renames(
159    odb: &Odb,
160    path: &mut crate::combined_tree_diff::CombinedDiffPath,
161    parent_trees: &[ObjectId],
162    result_tree: &ObjectId,
163    rename_threshold: u32,
164) {
165    use crate::combined_tree_diff::CombinedParentStatus;
166    if parent_trees.len() != path.parents.len() {
167        return;
168    }
169    let Some(parent_paths) = combined_merge_parent_blob_paths(
170        odb,
171        &path.path,
172        parent_trees,
173        result_tree,
174        rename_threshold,
175    ) else {
176        return;
177    };
178    for (i, side) in path.parents.iter_mut().enumerate() {
179        if side.status != CombinedParentStatus::Added {
180            continue;
181        }
182        let src = &parent_paths[i];
183        if src.is_empty() || src == &path.path {
184            continue;
185        }
186        let (Some(oid), Some(mode)) = (
187            blob_oid_at_path(odb, &parent_trees[i], src),
188            blob_mode_at_path(odb, &parent_trees[i], src),
189        ) else {
190            continue;
191        };
192        side.status = CombinedParentStatus::Renamed;
193        side.oid = oid;
194        side.mode = mode;
195        side.rename_from = Some(src.clone());
196    }
197}
198
199/// All blob paths in `tree_oid`, depth-first in Git tree entry order (for `diff` / `log`
200/// `--rotate-to` / `--skip-to`).
201#[must_use]
202pub fn all_blob_paths_in_tree_order(odb: &Odb, tree_oid: &ObjectId) -> Vec<String> {
203    all_blob_paths_dfs(odb, tree_oid, "")
204}
205
206fn all_blob_paths_dfs(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Vec<String> {
207    let Ok(obj) = odb.read(tree_oid) else {
208        return Vec::new();
209    };
210    if obj.kind != ObjectKind::Tree {
211        return Vec::new();
212    }
213    let Ok(entries) = parse_tree(&obj.data) else {
214        return Vec::new();
215    };
216    let mut out = Vec::new();
217    for e in entries {
218        let name = String::from_utf8_lossy(&e.name);
219        let path = if prefix.is_empty() {
220            name.into_owned()
221        } else {
222            format!("{prefix}/{name}")
223        };
224        if e.mode == 0o040000 {
225            out.extend(all_blob_paths_dfs(odb, &e.oid, &path));
226        } else {
227            out.push(path);
228        }
229    }
230    out
231}
232
233/// List paths under `prefix` that appear in `want`, following merge-tree entry order (Git
234/// `traverse_trees` order), not lexicographic sorting.
235fn paths_in_tree_order(
236    odb: &Odb,
237    tree_oid: &ObjectId,
238    prefix: &str,
239    want: &std::collections::HashSet<String>,
240) -> Vec<String> {
241    let Ok(obj) = odb.read(tree_oid) else {
242        return Vec::new();
243    };
244    if obj.kind != ObjectKind::Tree {
245        return Vec::new();
246    }
247    let Ok(entries) = parse_tree(&obj.data) else {
248        return Vec::new();
249    };
250    let mut out = Vec::new();
251    for e in entries {
252        let name = String::from_utf8_lossy(&e.name);
253        let path = if prefix.is_empty() {
254            name.into_owned()
255        } else {
256            format!("{prefix}/{name}")
257        };
258        if e.mode == 0o040000 {
259            out.extend(paths_in_tree_order(odb, &e.oid, &path, want));
260        } else if want.contains(&path) {
261            out.push(path);
262        }
263    }
264    out
265}
266
267/// Load attributes for `path` using root `.gitattributes` and `info/attributes`.
268fn attrs_for_repo_path(git_dir: &Path, path: &str) -> FileAttrs {
269    let work_tree = git_dir.parent().unwrap_or(git_dir);
270    let parsed = crate::crlf::load_gitattributes_parsed(work_tree);
271    let config = ConfigSet::load(
272        &crate::environment::Environment::empty(),
273        Some(git_dir),
274        true,
275    )
276    .unwrap_or_default();
277    crate::crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
278}
279
280/// True if diff should treat this path as binary (NUL in blob or `-diff` / `diff=unset`).
281#[must_use]
282pub fn is_binary_for_diff(git_dir: &Path, path: &str, blob: &[u8]) -> bool {
283    let fa = attrs_for_repo_path(git_dir, path);
284    if matches!(fa.diff_attr, DiffAttr::Unset) {
285        return true;
286    }
287    crate::crlf::is_binary(blob)
288}
289
290/// True when `diff.<driver>.binary` is set for this path's `diff=<driver>` attribute.
291fn diff_driver_binary_config(config: &ConfigSet, driver: &str) -> bool {
292    let key = format!("diff.{driver}.binary");
293    config
294        .get(&key)
295        .is_some_and(|v| parse_bool(v.as_str()).unwrap_or(false))
296}
297
298/// Force `Binary files ... differ` when the path's diff driver sets `binary`, except for symlinks.
299///
300/// Matches Git's `diff_filespec_is_binary` driver flag: `diff.<name>.binary` applies to paths
301/// using that driver, but symlink modes (`120000`) still emit textual symlink-target patches
302/// (t4011).
303#[must_use]
304pub fn diff_forced_binary_by_driver(
305    git_dir: &Path,
306    config: &ConfigSet,
307    path: &str,
308    old_mode: &str,
309    new_mode: &str,
310) -> bool {
311    let fa = attrs_for_repo_path(git_dir, path);
312    let DiffAttr::Driver(driver) = fa.diff_attr else {
313        return false;
314    };
315    if !diff_driver_binary_config(config, &driver) {
316        return false;
317    }
318    if old_mode == "120000" || new_mode == "120000" {
319        return false;
320    }
321    true
322}
323
324/// True when Git would wrap the textconv command with `sh -c 'cmd "$@"' -- ...`
325/// (`prepare_shell_cmd` in Git's `run-command.c`).
326fn textconv_cmd_needs_shell_wrapper(cmd_line: &str) -> bool {
327    cmd_line.chars().any(|c| {
328        matches!(
329            c,
330            '|' | '&'
331                | ';'
332                | '<'
333                | '>'
334                | '('
335                | ')'
336                | '$'
337                | '`'
338                | '\\'
339                | '"'
340                | '\''
341                | ' '
342                | '\t'
343                | '\n'
344                | '*'
345                | '?'
346                | '['
347                | '#'
348                | '~'
349                | '='
350                | '%'
351        )
352    })
353}
354
355/// Run `diff.<driver>.textconv` on `input`; returns raw stdout on success.
356///
357/// Matches Git's `run_textconv` / `prepare_shell_cmd`: by default the blob is written to a
358/// temporary file and passed as an argument after `--`. Commands that contain shell
359/// metacharacters (including spaces) use `sh -c 'pgm "$@"' -- pgm <tempfile>`. Config lines
360/// ending with ` <` use stdin instead of a tempfile.
361pub fn run_textconv_raw(
362    command_cwd: &Path,
363    config: &ConfigSet,
364    driver: &str,
365    input: &[u8],
366) -> Option<Vec<u8>> {
367    let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
368    cmd_line = cmd_line.trim_end().to_string();
369    let stdin_mode = if cmd_line.ends_with('<') {
370        let t = cmd_line.trim_end_matches('<').trim_end();
371        cmd_line = t.to_string();
372        true
373    } else {
374        false
375    };
376    let runner = system_command_runner();
377    if stdin_mode {
378        let out = run_sh_dash_c(
379            runner.as_ref(),
380            &cmd_line,
381            Some(command_cwd),
382            CommandStdin::Pipe(input.to_vec()),
383            CommandStdio::Null,
384        )
385        .ok()?;
386        return if out.status.success {
387            Some(out.stdout)
388        } else {
389            None
390        };
391    }
392
393    let mut tmp = NamedTempFile::new().ok()?;
394    tmp.write_all(input).ok()?;
395    tmp.flush().ok()?;
396    let path = tmp.path().to_owned();
397
398    let out = if textconv_cmd_needs_shell_wrapper(&cmd_line) {
399        let spec = CommandSpec {
400            program: "sh".into(),
401            args: Vec::new(),
402            shell: Some(ShellInvocation::DashC {
403                script: format!("{} \"$@\"", cmd_line),
404                argv0: Some(cmd_line.clone()),
405                args: vec![OsString::from(path.to_string_lossy().as_ref())],
406            }),
407            cwd: Some(command_cwd.to_path_buf()),
408            env: CommandEnvironment::inherit_process_only(),
409            stdin: CommandStdin::Null,
410            stdout: CommandStdio::Pipe,
411            stderr: CommandStdio::Null,
412        };
413        runner.spawn(&spec).ok()?.wait_with_output().ok()?
414    } else {
415        run_program(
416            runner.as_ref(),
417            Path::new("sh"),
418            &[
419                OsString::from(&cmd_line),
420                OsString::from(path.to_string_lossy().as_ref()),
421            ],
422            Some(command_cwd),
423            CommandStdin::Null,
424            CommandStdio::Null,
425        )
426        .ok()?
427    };
428
429    if !out.status.success {
430        return None;
431    }
432    Some(out.stdout)
433}
434
435/// Run `diff.<driver>.textconv` feeding `input` on stdin; returns UTF-8 lossy text on success.
436pub fn run_textconv(
437    command_cwd: &Path,
438    config: &ConfigSet,
439    driver: &str,
440    input: &[u8],
441) -> Option<String> {
442    run_textconv_raw(command_cwd, config, driver, input)
443        .map(|b| String::from_utf8_lossy(&b).into_owned())
444}
445
446/// `diff_textconv_cmd_line` function.
447pub fn diff_textconv_cmd_line(config: &ConfigSet, driver: &str) -> Option<String> {
448    let mut cmd_line = config.get(&format!("diff.{driver}.textconv"))?;
449    cmd_line = cmd_line.trim_end().to_string();
450    if cmd_line.ends_with('<') {
451        let t = cmd_line.trim_end_matches('<').trim_end();
452        cmd_line = t.to_string();
453    }
454    Some(cmd_line)
455}
456
457/// `diff_cachetextconv_enabled` function.
458pub fn diff_cachetextconv_enabled(config: &ConfigSet, driver: &str) -> bool {
459    config
460        .get(&format!("diff.{driver}.cachetextconv"))
461        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "yes" | "1" | "on"))
462        .unwrap_or(false)
463}
464
465/// Returns true when `path` has a `diff=<driver>` attribute and `diff.<driver>.textconv` is set.
466///
467/// When this holds, Git treats the path as textual for diff purposes (even if the blob contains
468/// NUL), running textconv instead of emitting `Binary files differ`.
469#[must_use]
470pub fn diff_textconv_active(git_dir: &Path, config: &ConfigSet, path: &str) -> bool {
471    let fa = attrs_for_repo_path(git_dir, path);
472    let DiffAttr::Driver(ref driver) = fa.diff_attr else {
473        return false;
474    };
475    diff_textconv_cmd_line(config, driver).is_some()
476}
477
478/// True when a path carries the `-diff` (or `diff=unset` / `binary`) attribute,
479/// which forces Git to emit `Binary files ... differ` regardless of content.
480#[must_use]
481pub fn diff_attr_forces_binary(git_dir: &Path, path: &str) -> bool {
482    matches!(
483        attrs_for_repo_path(git_dir, path).diff_attr,
484        DiffAttr::Unset
485    )
486}
487
488/// True when a path carries a bare `diff` (set) attribute, which forces Git to
489/// produce a textual diff even when the blob contains NUL bytes (t4020 #65).
490#[must_use]
491pub fn diff_attr_forces_text(git_dir: &Path, path: &str) -> bool {
492    matches!(attrs_for_repo_path(git_dir, path).diff_attr, DiffAttr::Set)
493}
494
495/// Resolved external diff driver from a path's `diff=<name>` attribute.
496///
497/// Mirrors Git's `userdiff_find_by_path` → `drv->external`: when a path has a
498/// `diff=<name>` attribute and `diff.<name>.command` is configured, that driver
499/// takes precedence over `GIT_EXTERNAL_DIFF` / `diff.external`. The boolean is
500/// `diff.<name>.trustExitCode`.
501#[must_use]
502pub fn diff_attr_external_driver(
503    git_dir: &Path,
504    config: &ConfigSet,
505    path: &str,
506) -> Option<(String, bool)> {
507    let fa = attrs_for_repo_path(git_dir, path);
508    let DiffAttr::Driver(ref driver) = fa.diff_attr else {
509        return None;
510    };
511    let cmd = config.get(&format!("diff.{driver}.command"))?;
512    if cmd.trim().is_empty() {
513        return None;
514    }
515    let trust = config
516        .get(&format!("diff.{driver}.trustExitCode"))
517        .and_then(|v| parse_bool(v.as_str()).ok())
518        .unwrap_or(false);
519    Some((cmd, trust))
520}
521
522fn textconv_command_cwd(git_dir: &Path) -> std::path::PathBuf {
523    git_dir.parent().unwrap_or(git_dir).to_path_buf()
524}
525
526fn blob_text_for_diff_inner(
527    odb: Option<&Odb>,
528    git_dir: &Path,
529    config: &ConfigSet,
530    path: &str,
531    blob: &[u8],
532    blob_oid: Option<&ObjectId>,
533    use_textconv: bool,
534) -> String {
535    if !use_textconv {
536        return String::from_utf8_lossy(blob).into_owned();
537    }
538    let fa = attrs_for_repo_path(git_dir, path);
539    let DiffAttr::Driver(ref driver) = fa.diff_attr else {
540        return String::from_utf8_lossy(blob).into_owned();
541    };
542    let Some(cmd_line) = diff_textconv_cmd_line(config, driver) else {
543        return String::from_utf8_lossy(blob).into_owned();
544    };
545    let want_cache = diff_cachetextconv_enabled(config, driver);
546    if want_cache {
547        if let (Some(odb), Some(oid)) = (odb, blob_oid) {
548            if let Some(bytes) = read_textconv_cache(odb, git_dir, driver, &cmd_line, oid) {
549                return String::from_utf8_lossy(&bytes).into_owned();
550            }
551        }
552    }
553    let cwd = textconv_command_cwd(git_dir);
554    let Some(t) = run_textconv(&cwd, config, driver, blob) else {
555        return String::from_utf8_lossy(blob).into_owned();
556    };
557    if want_cache {
558        if let (Some(odb), Some(oid)) = (odb, blob_oid) {
559            write_textconv_cache(odb, git_dir, driver, &cmd_line, oid, t.as_bytes());
560        }
561    }
562    t
563}
564
565/// Like [`blob_text_for_diff`], but uses `refs/notes/textconv/<driver>` when
566/// `diff.<driver>.cachetextconv` is true and `blob_oid` is known.
567#[must_use]
568pub fn blob_text_for_diff_with_oid(
569    odb: &Odb,
570    git_dir: &Path,
571    config: &ConfigSet,
572    path: &str,
573    blob: &[u8],
574    blob_oid: &ObjectId,
575    use_textconv: bool,
576) -> String {
577    blob_text_for_diff_inner(
578        Some(odb),
579        git_dir,
580        config,
581        path,
582        blob,
583        Some(blob_oid),
584        use_textconv,
585    )
586}
587
588/// Blob bytes after smudge/EOL conversion for `path`, using the same rules as checkout.
589///
590/// `index` is used to pick up `.gitattributes` from the index when the worktree file is
591/// missing; pass `None` to use only on-disk `.gitattributes` under `work_tree`.
592///
593/// When a [`crate::repo::Repository`] handle is available, checkout uses the same smudge path
594/// with destination-index attribute loading (see `WorktreeRules::from_checkout_prospective_index`).
595#[allow(clippy::too_many_arguments)]
596pub fn convert_blob_to_worktree_for_path(
597    git_dir: &Path,
598    work_tree: &Path,
599    index: Option<&crate::index::Index>,
600    odb: &Odb,
601    path: &str,
602    blob: &[u8],
603    oid_hex: Option<&str>,
604    filter_process: Option<&crate::filter_process::FilterProcessState>,
605) -> std::io::Result<Vec<u8>> {
606    let config = ConfigSet::load(
607        &crate::environment::Environment::empty(),
608        Some(git_dir),
609        true,
610    )
611    .unwrap_or_default();
612    let conv = crate::crlf::ConversionConfig::from_config(&config);
613    let parsed = match index {
614        Some(idx) => crate::crlf::load_gitattributes_for_checkout_parsed(work_tree, path, idx, odb),
615        None => crate::crlf::load_gitattributes_parsed(work_tree),
616    };
617    let file_attrs = crate::crlf::file_attrs_from_gitattributes(
618        &parsed.rules,
619        &parsed.macros,
620        path,
621        false,
622        &config,
623    );
624    crate::crlf::convert_to_worktree_eager(
625        blob,
626        path,
627        &conv,
628        &file_attrs,
629        oid_hex,
630        None,
631        filter_process,
632    )
633    .map_err(std::io::Error::other)
634}
635
636/// Prepare blob bytes for diff: optional textconv when `use_textconv` and `diff=<driver>`.
637///
638/// Does not read or write the textconv notes cache; use [`blob_text_for_diff_with_oid`] when the
639/// blob OID is known (e.g. commit diffs with `cachetextconv`).
640pub fn blob_text_for_diff(
641    git_dir: &Path,
642    config: &ConfigSet,
643    path: &str,
644    blob: &[u8],
645    use_textconv: bool,
646) -> String {
647    blob_text_for_diff_inner(None, git_dir, config, path, blob, None, use_textconv)
648}
649
650/// Combined diff header: `diff --combined` or `diff --cc`.
651pub fn format_combined_binary_header(
652    path: &str,
653    parent_oids: &[ObjectId],
654    result_oid: &ObjectId,
655    abbrev: usize,
656    use_cc_word: bool,
657) -> String {
658    format_combined_binary_header_n(path, parent_oids, result_oid, abbrev, use_cc_word)
659}
660
661/// `index` line for N-parent combined/binary diffs (`p1,p2,...pn..result`).
662#[must_use]
663pub fn format_combined_binary_header_n(
664    path: &str,
665    parent_oids: &[ObjectId],
666    result_oid: &ObjectId,
667    abbrev: usize,
668    use_cc_word: bool,
669) -> String {
670    let idx: Vec<String> = parent_oids.iter().map(|o| abbrev_hex(o, abbrev)).collect();
671    let res = abbrev_hex(result_oid, abbrev);
672    let kind = if use_cc_word { "cc" } else { "combined" };
673    format!(
674        "diff --{kind} {path}\nindex {}..{res}\nBinary files differ\n",
675        idx.join(",")
676    )
677}
678
679/// Full combined diff for a binary path (two parents).
680pub fn format_combined_binary(
681    path: &str,
682    parent_oids: &[ObjectId],
683    result_oid: &ObjectId,
684    abbrev: usize,
685    use_cc_word: bool,
686) -> String {
687    format_combined_binary_header_n(path, parent_oids, result_oid, abbrev, use_cc_word)
688}
689
690fn push_combined_file_headers(
691    out: &mut String,
692    merge_path: &str,
693    parent_paths: &[String],
694    parent_sides: &[CombinedParentSide],
695    combined_all_paths: bool,
696    quote_path_fully: bool,
697) {
698    let a_prefix = "a/";
699    let b_prefix = "b/";
700    if combined_all_paths {
701        for (i, p) in parent_paths.iter().enumerate() {
702            // Show `/dev/null` only when this parent genuinely lacks the path (a true add). When a
703            // per-parent rename was detected, `parent_paths[i]` holds the renamed-from path (which
704            // the parent *does* have), so emit that name even though the merge-tree walk classified
705            // the result side as "Added".
706            let added_no_rename = parent_sides.get(i).is_some_and(|s| {
707                s.status == crate::combined_tree_diff::CombinedParentStatus::Added
708            }) && (p.is_empty() || p == merge_path);
709            if added_no_rename {
710                out.push_str("--- /dev/null\n");
711            } else {
712                let line = format_diff_path_with_prefix(a_prefix, p, quote_path_fully);
713                out.push_str("--- ");
714                out.push_str(&line);
715                out.push('\n');
716            }
717        }
718        let line = format_diff_path_with_prefix(b_prefix, merge_path, quote_path_fully);
719        out.push_str("+++ ");
720        out.push_str(&line);
721        out.push('\n');
722    } else {
723        let la = format_diff_path_with_prefix(a_prefix, merge_path, quote_path_fully);
724        let lb = format_diff_path_with_prefix(b_prefix, merge_path, quote_path_fully);
725        out.push_str("--- ");
726        out.push_str(&la);
727        out.push('\n');
728        out.push_str("+++ ");
729        out.push_str(&lb);
730        out.push('\n');
731    }
732}
733
734/// Combined text diff with optional textconv (N parents, single merge path).
735///
736/// `parent_blob_paths` — when set, length must match `parent_trees`; each entry is the path
737/// used to read that parent's blob (for `--combined-all-paths` rename cases). When `None`,
738/// every parent uses `path`.
739#[allow(clippy::too_many_arguments)]
740pub fn format_combined_textconv_patch(
741    git_dir: &Path,
742    config: &ConfigSet,
743    odb: &Odb,
744    path: &str,
745    parent_trees: &[ObjectId],
746    result_tree: &ObjectId,
747    abbrev: usize,
748    context: usize,
749    use_cc_word: bool,
750    use_textconv: bool,
751    ws: CombinedDiffWsOptions,
752    combined_all_paths: bool,
753    parent_blob_paths: Option<&[String]>,
754    parent_sides: &[CombinedParentSide],
755    quote_path_fully: bool,
756) -> Option<String> {
757    if parent_trees.len() < 2 {
758        return None;
759    }
760    let parent_paths: Vec<&str> = if let Some(ps) = parent_blob_paths {
761        if ps.len() != parent_trees.len() {
762            return None;
763        }
764        ps.iter().map(|s| s.as_str()).collect()
765    } else {
766        vec![path; parent_trees.len()]
767    };
768
769    let mut parent_blobs = Vec::with_capacity(parent_trees.len());
770    let mut parent_oids = Vec::with_capacity(parent_trees.len());
771    for (i, t) in parent_trees.iter().enumerate() {
772        let p = parent_paths[i];
773        // A parent that lacks the path contributes /dev/null (empty content, zero OID): the path
774        // was added relative to that parent. Git still emits the combined patch (`AA`/`new file`),
775        // so do not bail out here.
776        match blob_oid_at_path(odb, t, p) {
777            Some(oid) => {
778                parent_blobs.push(read_blob(odb, &oid));
779                parent_oids.push(oid);
780            }
781            None => {
782                parent_blobs.push(Vec::new());
783                parent_oids.push(ObjectId::zero());
784            }
785        }
786    }
787    let result_blob = read_blob_at_path(odb, result_tree, path)?;
788    let roid = blob_oid_at_path(odb, result_tree, path)?;
789
790    let textconv_for_patch = use_textconv && diff_textconv_active(git_dir, config, path);
791    if !textconv_for_patch
792        && (parent_blobs
793            .iter()
794            .any(|b| is_binary_for_diff(git_dir, path, b))
795            || is_binary_for_diff(git_dir, path, &result_blob))
796    {
797        return Some(format_combined_binary(
798            path,
799            &parent_oids,
800            &roid,
801            abbrev,
802            use_cc_word,
803        ));
804    }
805
806    let mut parent_texts = Vec::with_capacity(parent_trees.len());
807    for (i, blob) in parent_blobs.iter().enumerate() {
808        let p = parent_paths[i];
809        let oid = &parent_oids[i];
810        let t = if textconv_for_patch {
811            blob_text_for_diff_with_oid(odb, git_dir, config, p, blob, oid, true)
812        } else {
813            blob_text_for_diff(git_dir, config, p, blob, use_textconv)
814        };
815        parent_texts.push(t);
816    }
817    let tr = if textconv_for_patch {
818        blob_text_for_diff_with_oid(odb, git_dir, config, path, &result_blob, &roid, true)
819    } else {
820        blob_text_for_diff(git_dir, config, path, &result_blob, use_textconv)
821    };
822
823    let idx: Vec<String> = parent_oids.iter().map(|o| abbrev_hex(o, abbrev)).collect();
824    let ra = abbrev_hex(&roid, abbrev);
825    let kind = if use_cc_word { "cc" } else { "combined" };
826
827    let header_paths: Vec<String> = if combined_all_paths {
828        parent_paths.iter().map(|s| (*s).to_string()).collect()
829    } else {
830        Vec::new()
831    };
832
833    let mut out = String::new();
834    out.push_str(&format!("diff --{kind} {path}\n"));
835    out.push_str(&format!("index {}..{ra}\n", idx.join(",")));
836    if combined_all_paths {
837        push_combined_file_headers(
838            &mut out,
839            path,
840            &header_paths,
841            parent_sides,
842            true,
843            quote_path_fully,
844        );
845    } else {
846        push_combined_file_headers(&mut out, path, &[], parent_sides, false, quote_path_fully);
847    }
848    out.push_str(&format_combined_diff_body(
849        &parent_texts,
850        &tr,
851        context,
852        use_cc_word,
853        ws,
854    ));
855    Some(out)
856}
857
858/// `git diff` / `git diff --cc` during a conflict: worktree file with markers.
859#[allow(clippy::too_many_arguments)]
860pub fn format_worktree_conflict_combined(
861    git_dir: &Path,
862    config: &ConfigSet,
863    odb: &Odb,
864    path: &str,
865    stage1_oid: &ObjectId,
866    stage2_oid: &ObjectId,
867    stage3_oid: &ObjectId,
868    worktree_bytes: &[u8],
869    abbrev: usize,
870) -> String {
871    let ours_blob = read_blob(odb, stage2_oid);
872    let theirs_blob = read_blob(odb, stage3_oid);
873    let _base_blob = read_blob(odb, stage1_oid);
874
875    let use_conv = !worktree_bytes.contains(&0);
876    let textconv_cache_path = diff_textconv_active(git_dir, config, path);
877    let t_ours = if textconv_cache_path {
878        blob_text_for_diff_with_oid(odb, git_dir, config, path, &ours_blob, stage2_oid, true)
879    } else {
880        blob_text_for_diff(git_dir, config, path, &ours_blob, use_conv)
881    };
882    let t_theirs = if textconv_cache_path {
883        blob_text_for_diff_with_oid(odb, git_dir, config, path, &theirs_blob, stage3_oid, true)
884    } else {
885        blob_text_for_diff(git_dir, config, path, &theirs_blob, use_conv)
886    };
887    let wt_text = if textconv_cache_path || use_conv {
888        blob_text_for_diff(git_dir, config, path, worktree_bytes, true)
889    } else {
890        String::from_utf8_lossy(worktree_bytes).into_owned()
891    };
892    let wt_for_conflict = wt_text.clone();
893
894    let p1a = abbrev_hex(stage2_oid, abbrev);
895    let p2a = abbrev_hex(stage3_oid, abbrev);
896    let z = crate::diff::zero_oid();
897    let za = abbrev_hex(&z, abbrev);
898
899    let mut out = String::new();
900    out.push_str(&format!("diff --cc {path}\n"));
901    out.push_str(&format!("index {p1a},{p2a}..{za}\n"));
902    out.push_str(&format!("--- a/{path}\n"));
903    out.push_str(&format!("+++ b/{path}\n"));
904
905    if wt_text.contains("<<<<<<<") && wt_text.contains(">>>>>>>") {
906        out.push_str(&conflict_combined_body(&wt_for_conflict));
907    } else {
908        out.push_str(&format_combined_diff_body(
909            &[t_ours, t_theirs],
910            &wt_text,
911            3,
912            true,
913            CombinedDiffWsOptions::default(),
914        ));
915    }
916    out
917}
918
919/// Format the combined hunk for a worktree file that still contains conflict markers.
920fn conflict_combined_body(wt: &str) -> String {
921    let lines: Vec<&str> = wt.lines().collect();
922    let mut body = String::new();
923    let mut i = 0usize;
924    while i < lines.len() {
925        let line = lines[i];
926        if line.starts_with("<<<<<<< ") {
927            let mut hunk_new = 0u32;
928            let mut ours_count = 0u32;
929            let mut theirs_count = 0u32;
930            body.push_str(&format!("++{line}\n"));
931            hunk_new += 1;
932            i += 1;
933            while i < lines.len() && !lines[i].starts_with("=======") {
934                body.push_str(&format!(" +{}\n", lines[i]));
935                ours_count += 1;
936                hunk_new += 1;
937                i += 1;
938            }
939            if i < lines.len() && lines[i].starts_with("=======") {
940                body.push_str("++=======\n");
941                hunk_new += 1;
942                i += 1;
943            }
944            while i < lines.len() && !lines[i].starts_with(">>>>>>>") {
945                body.push_str(&format!("+ {}\n", lines[i]));
946                theirs_count += 1;
947                hunk_new += 1;
948                i += 1;
949            }
950            if i < lines.len() {
951                let closing = lines[i];
952                body.push_str(&format!("++{closing}\n"));
953                hunk_new += 1;
954            }
955            let header = format!(
956                "@@@ -1,{} -1,{} +1,{} @@@\n",
957                ours_count.max(1),
958                theirs_count.max(1),
959                hunk_new
960            );
961            return header + &body;
962        }
963        i += 1;
964    }
965    body
966}
967
968fn read_blob(odb: &Odb, oid: &ObjectId) -> Vec<u8> {
969    if *oid == crate::diff::zero_oid() {
970        return Vec::new();
971    }
972    odb.read(oid).map(|o| o.data).unwrap_or_default()
973}
974
975/// Read the blob at `path` in `tree`, or `None` if missing.
976/// `path` uses the reversible `repo_relpath::storage_string` representation.
977#[must_use]
978pub fn read_blob_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<Vec<u8>> {
979    let oid = blob_oid_at_path(odb, tree, path)?;
980    Some(read_blob(odb, &oid))
981}
982
983/// OID of the blob at `path` in `tree`.
984/// `path` uses the reversible `repo_relpath::storage_string` representation.
985#[must_use]
986pub fn blob_oid_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Option<ObjectId> {
987    let mut current = *tree;
988    let bytes = crate::repo_relpath::storage_bytes(path);
989    let parts: Vec<&[u8]> = bytes.split(|&b| b == b'/').collect();
990    for (pi, part) in parts.iter().enumerate() {
991        let obj = odb.read(&current).ok()?;
992        let entries = crate::objects::parse_tree(&obj.data).ok()?;
993        let found = entries.iter().find(|e| e.name == *part)?;
994        if pi + 1 == parts.len() {
995            return Some(found.oid);
996        }
997        if found.mode != 0o040000 {
998            return None;
999        }
1000        current = found.oid;
1001    }
1002    None
1003}
1004
1005fn abbrev_hex(oid: &ObjectId, abbrev: usize) -> String {
1006    let hex = oid.to_hex();
1007    let len = abbrev.min(hex.len());
1008    hex[..len].to_owned()
1009}
1010
1011#[cfg(test)]
1012mod line_coverage_tests {
1013    use super::*;
1014    use crate::combined_diff_patch::CombinedDiffWsOptions;
1015    use crate::combined_tree_diff::{CombinedDiffPath, CombinedParentSide, CombinedParentStatus};
1016    use crate::config::ConfigSet;
1017    use crate::objects::ObjectKind;
1018    use crate::odb::Odb;
1019    use tempfile::tempdir;
1020
1021    fn sample_merge(odb: &Odb) -> (ObjectId, Vec<ObjectId>, ObjectId) {
1022        let b0 = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
1023        let b1 = odb.write(ObjectKind::Blob, b"one\n").expect("blob");
1024        let b2 = odb.write(ObjectKind::Blob, b"two\n").expect("blob");
1025        let bm = odb.write(ObjectKind::Blob, b"merged\n").expect("blob");
1026        let tree = |entries: &[(&str, ObjectId)]| {
1027            let mut body = Vec::new();
1028            for (n, oid) in entries {
1029                body.extend_from_slice(format!("100644 {n}\0").as_bytes());
1030                body.extend_from_slice(oid.as_bytes());
1031            }
1032            odb.write(ObjectKind::Tree, &body).expect("tree")
1033        };
1034        let base_tree = tree(&[("f.txt", b0)]);
1035        let p1_tree = tree(&[("f.txt", b1), ("only-p1.txt", b0)]);
1036        let p2_tree = tree(&[("f.txt", b2), ("only-p2.txt", b0)]);
1037        let merge_tree = tree(&[("f.txt", bm), ("only-p1.txt", b1), ("only-p2.txt", b2)]);
1038        let commit = |tree: ObjectId, parents: &[ObjectId], msg: &str| {
1039            let mut body = format!("tree {tree}\n");
1040            for p in parents {
1041                body.push_str(&format!("parent {p}\n"));
1042            }
1043            body.push_str("author T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\n");
1044            body.push_str(msg);
1045            body.push('\n');
1046            odb.write(ObjectKind::Commit, body.as_bytes())
1047                .expect("commit")
1048        };
1049        let base = commit(base_tree, &[], "base");
1050        let p1 = commit(p1_tree, &[base], "p1");
1051        let p2 = commit(p2_tree, &[base], "p2");
1052        let merge = commit(merge_tree, &[p1, p2], "merge");
1053        (merge_tree, vec![p1, p2], merge)
1054    }
1055
1056    #[test]
1057    fn combined_formatters_and_nested_paths() {
1058        let dir = tempdir().expect("tempdir");
1059        let odb = Odb::new(dir.path());
1060        let git_dir = dir.path().join(".git");
1061        std::fs::create_dir_all(&git_dir).expect("git");
1062        let (merge_tree, parents, _merge) = sample_merge(&odb);
1063        let cfg = ConfigSet::new();
1064        let paths = combined_diff_paths(&odb, &merge_tree, &parents);
1065        assert!(!paths.is_empty());
1066        let nested = all_blob_paths_in_tree_order(&odb, &merge_tree);
1067        assert!(nested.iter().any(|p| p.contains("only-p1")));
1068        let p1_tree = {
1069            let c = odb.read(&parents[0]).expect("p1");
1070            crate::objects::parse_commit(&c.data).expect("parse").tree
1071        };
1072        let p1_oid = blob_oid_at_path(&odb, &p1_tree, "f.txt").expect("p1 blob");
1073        let merge_oid = blob_oid_at_path(&odb, &merge_tree, "f.txt").expect("merge blob");
1074        let mut path = CombinedDiffPath {
1075            path: "f.txt".into(),
1076            merge_mode: 0o100644,
1077            merge_oid,
1078            parents: vec![
1079                CombinedParentSide {
1080                    status: CombinedParentStatus::Modified,
1081                    mode: 0o100644,
1082                    oid: p1_oid,
1083                    rename_from: None,
1084                },
1085                CombinedParentSide {
1086                    status: CombinedParentStatus::Modified,
1087                    mode: 0o100644,
1088                    oid: blob_oid_at_path(&odb, &merge_tree, "f.txt").expect("oid"),
1089                    rename_from: Some("old-f.txt".into()),
1090                },
1091            ],
1092        };
1093        enrich_combined_path_renames(&odb, &mut path, &parents, &merge_tree, 50);
1094        let _ = format_combined_binary_header_n("f.txt", &[p1_oid], &merge_oid, 7, false);
1095        let _ = format_combined_textconv_patch(
1096            &git_dir,
1097            &cfg,
1098            &odb,
1099            "f.txt",
1100            &parents,
1101            &merge_tree,
1102            7,
1103            3,
1104            false,
1105            false,
1106            CombinedDiffWsOptions::default(),
1107            false,
1108            Some(&["f.txt".to_owned()]),
1109            &path.parents,
1110            true,
1111        );
1112        let blob = read_blob_at_path(&odb, &merge_tree, "only-p2.txt").expect("blob");
1113        let _ = convert_blob_to_worktree_for_path(
1114            &git_dir,
1115            dir.path(),
1116            None,
1117            &odb,
1118            "only-p2.txt",
1119            &blob,
1120            None,
1121            None,
1122        );
1123        let z = crate::diff::zero_oid();
1124        let _ = format_worktree_conflict_combined(
1125            &git_dir, &cfg, &odb, "f.txt", &z, &p1_oid, &merge_oid, b"plain\n", 12,
1126        );
1127    }
1128}