Skip to main content

grit_lib/
diff.rs

1//! Diff machinery — compare trees, index entries, and working tree files.
2//!
3//! # Overview
4//!
5//! This module provides the core diffing infrastructure shared by `diff`,
6//! `diff-index`, `status`, `log`, `show`, `commit`, and `merge`.
7//!
8//! ## Levels of comparison
9//!
10//! 1. **Tree-to-tree** — compare two tree objects (e.g. for `log`/`show`).
11//! 2. **Tree-to-index** — compare a tree (usually HEAD) against the index
12//!    (staged changes, used by `diff --cached` and `status`).
13//! 3. **Index-to-worktree** — compare index against the working directory
14//!    (unstaged changes, used by `diff` and `status`).
15//!
16//! ## Content diff
17//!
18//! Line-level diffing uses the `similar` crate (Myers, patience, minimal) and,
19//! for Git's `histogram` algorithm, `imara-diff` for output compatible with upstream Git.
20//! Output formats: unified patch, raw (`:old-mode new-mode ...`), stat,
21//! numstat.
22
23use std::fs;
24#[cfg(unix)]
25use std::os::unix::fs::MetadataExt;
26use std::path::{Path, PathBuf};
27
28use crate::config::ConfigSet;
29use crate::diff_indent_heuristic;
30use crate::error::{Error, Result};
31use crate::index::{Index, IndexEntry};
32use crate::objects::{parse_commit, parse_tree, CommitData, ObjectId, ObjectKind, TreeEntry};
33use crate::odb::Odb;
34use crate::userdiff::FuncnameMatcher;
35
36/// Splits imara-diff unified body (concatenated hunks) into per-hunk slices for post-processing.
37fn imara_unified_hunk_slices(body: &str) -> Vec<&str> {
38    let mut starts: Vec<usize> = Vec::new();
39    if body.starts_with("@@") {
40        starts.push(0);
41    }
42    for (idx, _) in body.match_indices("\n@@ ") {
43        starts.push(idx + 1);
44    }
45    starts.push(body.len());
46    starts.windows(2).map(|w| &body[w[0]..w[1]]).collect()
47}
48
49fn histogram_unified_body_raw(
50    old_content: &str,
51    new_content: &str,
52    context_lines: usize,
53    inter_hunk_context: usize,
54) -> String {
55    use imara_diff::{Algorithm, Diff, Hunk, InternedInput};
56    use std::fmt::Write as _;
57
58    let input = InternedInput::new(old_content, new_content);
59    let mut diff = Diff::compute(Algorithm::Histogram, &input);
60    diff.postprocess_lines(&input);
61
62    // Assemble hunks ourselves: imara's `UnifiedDiff` printer starts the first
63    // hunk's context at line 0 whenever the first change is within
64    // `2 * context_len` of the file start, emitting more leading context than
65    // its own header claims (t4061), and its gap threshold cannot express
66    // Git's odd `2 * U + inter_hunk_context` fuse limits (t4032).
67    let hunks: Vec<Hunk> = diff.hunks().collect();
68    if hunks.is_empty() {
69        return String::new();
70    }
71
72    let ctx = context_lines.min(u32::MAX as usize) as u32;
73    let max_gap = (2usize.saturating_mul(context_lines))
74        .saturating_add(inter_hunk_context)
75        .min(u32::MAX as usize) as u32;
76    let before_len = input.before.len() as u32;
77    let after_len = input.after.len() as u32;
78
79    // Fuse hunks whose unchanged gap is at most `max_gap` (Git xdl_get_hunk).
80    let mut groups: Vec<&[Hunk]> = Vec::new();
81    let mut group_start = 0usize;
82    for i in 1..hunks.len() {
83        if hunks[i].before.start - hunks[i - 1].before.end > max_gap {
84            groups.push(&hunks[group_start..i]);
85            group_start = i;
86        }
87    }
88    groups.push(&hunks[group_start..]);
89
90    fn push_line(out: &mut String, prefix: char, text: &str) {
91        out.push(prefix);
92        out.push_str(text);
93        if !text.ends_with('\n') {
94            out.push('\n');
95        }
96    }
97
98    // Git hunk header range: 1-based start (the preceding line when the range
99    // is empty) with the `,count` part omitted when the count is exactly 1.
100    fn fmt_side(start: u32, count: u32) -> String {
101        let shown_start = if count == 0 { start } else { start + 1 };
102        if count == 1 {
103            format!("{shown_start}")
104        } else {
105            format!("{shown_start},{count}")
106        }
107    }
108
109    let mut out = String::new();
110    for group in groups {
111        let first = &group[0];
112        let last = &group[group.len() - 1];
113        let b_start = first.before.start.saturating_sub(ctx);
114        let a_start = first.after.start.saturating_sub(ctx);
115        let b_end = (last.before.end.saturating_add(ctx)).min(before_len);
116        let a_end = (last.after.end.saturating_add(ctx)).min(after_len);
117
118        let _ = writeln!(
119            out,
120            "@@ -{} +{} @@",
121            fmt_side(b_start, b_end - b_start),
122            fmt_side(a_start, a_end - a_start)
123        );
124
125        let mut pos = b_start;
126        for hunk in group {
127            for &token in &input.before[pos as usize..hunk.before.start as usize] {
128                push_line(&mut out, ' ', input.interner[token]);
129            }
130            for &token in &input.before[hunk.before.start as usize..hunk.before.end as usize] {
131                push_line(&mut out, '-', input.interner[token]);
132            }
133            for &token in &input.after[hunk.after.start as usize..hunk.after.end as usize] {
134                push_line(&mut out, '+', input.interner[token]);
135            }
136            pos = hunk.before.end;
137        }
138        for &token in &input.before[pos as usize..b_end as usize] {
139            push_line(&mut out, ' ', input.interner[token]);
140        }
141    }
142
143    out
144}
145
146/// Build the unified-diff body (no `---`/`+++` header) using Git's histogram
147/// algorithm, but applying `--ignore-blank-lines` / `-I` semantics through a
148/// Git-compatible implementation of the xdiff change-record machinery
149/// (`xdl_mark_ignorable_lines` + `xdl_get_hunk` + `xdl_emit_diff`).
150///
151/// `is_ignorable_change` is called with the slices of removed and added lines
152/// for one change record (each item is the line text without its trailing
153/// newline); it returns `true` when the whole change is ignorable (Git's
154/// `xch->ignore`). Ignorable changes that are far from substantive changes are
155/// dropped, and hunk boundaries are computed with Git's `max_ignorable`
156/// distance rules so the line numbers and surviving context match Git exactly.
157///
158/// Returns the body only; an empty string means no substantive change survives.
159/// Optional `--function-context` (`-W`) hunk expansion is applied *after*
160/// ignorable change-record pruning so the boundaries/funcname header match Git.
161#[allow(clippy::too_many_arguments)]
162pub(crate) fn histogram_unified_body_ignore_fc<F>(
163    old_content: &str,
164    new_content: &str,
165    context_lines: usize,
166    inter_hunk_context: usize,
167    function_context: bool,
168    funcname_matcher: Option<&FuncnameMatcher>,
169    is_ignorable_change: F,
170) -> String
171where
172    F: Fn(&[&str], &[&str]) -> bool,
173{
174    use imara_diff::{Algorithm, Diff, Hunk, InternedInput};
175    use std::fmt::Write as _;
176
177    let input = InternedInput::new(old_content, new_content);
178    let mut diff = Diff::compute(Algorithm::Histogram, &input);
179    diff.postprocess_lines(&input);
180
181    let raw_hunks: Vec<Hunk> = diff.hunks().collect();
182    if raw_hunks.is_empty() {
183        return String::new();
184    }
185
186    let before_len = input.before.len() as i64;
187    let after_len = input.after.len() as i64;
188    let ctxlen = context_lines as i64;
189    let interhunk = inter_hunk_context as i64;
190    // Git's xdl_get_hunk fuse limit: 2*ctxlen + interhunkctxlen.
191    let max_common = ctxlen.saturating_add(ctxlen).saturating_add(interhunk);
192    let max_ignorable = ctxlen;
193
194    // A change record mirrors Git's xdchange_t.
195    struct Change {
196        i1: i64,
197        chg1: i64,
198        i2: i64,
199        chg2: i64,
200        ignore: bool,
201    }
202
203    let mut changes: Vec<Change> = Vec::with_capacity(raw_hunks.len());
204    for h in &raw_hunks {
205        let i1 = h.before.start as i64;
206        let chg1 = (h.before.end - h.before.start) as i64;
207        let i2 = h.after.start as i64;
208        let chg2 = (h.after.end - h.after.start) as i64;
209        let removed: Vec<&str> = input.before[h.before.start as usize..h.before.end as usize]
210            .iter()
211            .map(|&t| input.interner[t])
212            .collect();
213        let added: Vec<&str> = input.after[h.after.start as usize..h.after.end as usize]
214            .iter()
215            .map(|&t| input.interner[t])
216            .collect();
217        let ignore = is_ignorable_change(&removed, &added);
218        changes.push(Change {
219            i1,
220            chg1,
221            i2,
222            chg2,
223            ignore,
224        });
225    }
226
227    // Helper to fetch a line of either side (token interner over lines).
228    let before_line = |idx: i64| -> &str { input.interner[input.before[idx as usize]] };
229    let after_line = |idx: i64| -> &str { input.interner[input.after[idx as usize]] };
230
231    // Port of xdl_get_hunk: starting at change index `start`, return
232    // `(new_start, last)` — the possibly-advanced first change index (leading
233    // ignorable changes dropped) and the index of the last change to include in
234    // this hunk — or `None` when the leading ignorable changes are all dropped
235    // and nothing remains.
236    let get_hunk = |start: usize| -> Option<(usize, usize)> {
237        // Remove ignorable changes that are too far before other changes.
238        // Faithful port of xdl_get_hunk's leading loop: walk every leading
239        // ignorable change; whenever its successor is absent or far enough away
240        // (>= max_ignorable), advance the hunk start past it. `scr` is the new
241        // start index, or `changes.len()` when everything is dropped.
242        let mut scr = start;
243        let mut xchp = start;
244        while xchp < changes.len() && changes[xchp].ignore {
245            let next = xchp + 1;
246            if next >= changes.len()
247                || changes[next].i1 - (changes[xchp].i1 + changes[xchp].chg1) >= max_ignorable
248            {
249                scr = next;
250            }
251            xchp = next;
252        }
253        if scr >= changes.len() {
254            return None;
255        }
256
257        let mut lxch = scr;
258        let mut ignored: i64 = 0;
259        let mut xchp = scr;
260        let mut idx = scr + 1;
261        while idx < changes.len() {
262            let xch = &changes[idx];
263            let prev = &changes[xchp];
264            let distance = xch.i1 - (prev.i1 + prev.chg1);
265            if distance > max_common {
266                break;
267            }
268            if distance < max_ignorable && (!xch.ignore || lxch == xchp) {
269                lxch = idx;
270                ignored = 0;
271            } else if distance < max_ignorable && xch.ignore {
272                ignored += xch.chg2;
273            } else if lxch != xchp
274                && xch.i1 + ignored - (changes[lxch].i1 + changes[lxch].chg1) > max_common
275            {
276                break;
277            } else if !xch.ignore {
278                lxch = idx;
279                ignored = 0;
280            } else {
281                ignored += xch.chg2;
282            }
283            xchp = idx;
284            idx += 1;
285        }
286        Some((scr, lxch))
287    };
288
289    fn push_line(out: &mut String, prefix: char, text: &str) {
290        out.push(prefix);
291        out.push_str(text);
292        if !text.ends_with('\n') {
293            out.push('\n');
294        }
295    }
296
297    // Git hunk header range (xdl_emit_hunk_hdr): `,count` part omitted when 1.
298    fn fmt_side(start: i64, count: i64) -> String {
299        if count == 1 {
300            format!("{start}")
301        } else {
302            format!("{start},{count}")
303        }
304    }
305
306    let mut out = String::new();
307    let mut cursor = 0usize;
308    while cursor < changes.len() {
309        let Some((xch_idx, xche_idx)) = get_hunk(cursor) else {
310            break;
311        };
312        let xch = &changes[xch_idx];
313        let xche = &changes[xche_idx];
314
315        // pre-context
316        let mut s1 = (xch.i1 - ctxlen).max(0);
317        let mut s2 = (xch.i2 - ctxlen).max(0);
318        // post-context (clamped so it does not run past either side's EOF).
319        let mut lctx = ctxlen;
320        lctx = lctx.min(before_len - (xche.i1 + xche.chg1));
321        lctx = lctx.min(after_len - (xche.i2 + xche.chg2));
322        let mut e1 = xche.i1 + xche.chg1 + lctx;
323        let mut e2 = xche.i2 + xche.chg2 + lctx;
324
325        // `--function-context`: expand the hunk up to the enclosing function's
326        // header and down to the line before the next function (Git's
327        // XDL_EMIT_FUNCCONTEXT in xemit.c), using the already ignore-pruned
328        // change boundaries.
329        if function_context {
330            // Upward: find the function line at or before xch.i1, then climb to
331            // the start of that function (past non-empty non-func lines).
332            let mut fs1 = xch.i1;
333            while fs1 >= 0 && !is_func_line(before_line(fs1), funcname_matcher) {
334                fs1 -= 1;
335            }
336            while fs1 > 0
337                && before_line(fs1 - 1).trim().is_empty() == false
338                && !is_func_line(before_line(fs1 - 1), funcname_matcher)
339            {
340                fs1 -= 1;
341            }
342            if fs1 < 0 {
343                fs1 = 0;
344            }
345            if fs1 < s1 {
346                s2 = (s2 - (s1 - fs1)).max(0);
347                s1 = fs1;
348            }
349            // Downward: find the next function line after the hunk end, climb up
350            // past trailing empty lines, and extend e1/e2 to it.
351            let end1 = xche.i1 + xche.chg1;
352            let mut fe1 = end1;
353            while fe1 < before_len && !is_func_line(before_line(fe1), funcname_matcher) {
354                fe1 += 1;
355            }
356            while fe1 > 0 && before_line(fe1 - 1).trim().is_empty() {
357                fe1 -= 1;
358            }
359            if fe1 > before_len {
360                fe1 = before_len;
361            }
362            if fe1 > e1 {
363                e2 = (e2 + (fe1 - e1)).min(after_len);
364                e1 = fe1;
365            }
366        }
367
368        let _ = writeln!(
369            out,
370            "@@ -{} +{} @@",
371            fmt_side(s1 + 1, e1 - s1),
372            fmt_side(s2 + 1, e2 - s2)
373        );
374
375        // Emit pre-context from the new side.
376        let mut s2c = s2;
377        while s2c < xch.i2 {
378            push_line(&mut out, ' ', after_line(s2c));
379            s2c += 1;
380        }
381
382        // Emit each change record and the context between merged records.
383        let mut s1c = xch.i1;
384        let mut s2c = xch.i2;
385        let mut k = xch_idx;
386        loop {
387            let c = &changes[k];
388            // Context between previous and current change atom.
389            while s1c < c.i1 && s2c < c.i2 {
390                push_line(&mut out, ' ', after_line(s2c));
391                s1c += 1;
392                s2c += 1;
393            }
394            // Removed lines from old side.
395            let mut r = c.i1;
396            while r < c.i1 + c.chg1 {
397                push_line(&mut out, '-', before_line(r));
398                r += 1;
399            }
400            // Added lines from new side.
401            let mut a = c.i2;
402            while a < c.i2 + c.chg2 {
403                push_line(&mut out, '+', after_line(a));
404                a += 1;
405            }
406            if k == xche_idx {
407                break;
408            }
409            s1c = c.i1 + c.chg1;
410            s2c = c.i2 + c.chg2;
411            k += 1;
412        }
413
414        // Post-context.
415        let mut s2p = xche.i2 + xche.chg2;
416        while s2p < e2 {
417            push_line(&mut out, ' ', after_line(s2p));
418            s2p += 1;
419        }
420
421        cursor = xche_idx + 1;
422    }
423
424    out
425}
426
427/// Unified diff hunks for Git's histogram algorithm (no `---` / `+++` lines).
428///
429/// Used by `--no-index` when whitespace normalization is off so the patch matches upstream Git.
430#[must_use]
431pub fn unified_diff_histogram_hunks_only(
432    old_content: &str,
433    new_content: &str,
434    context_lines: usize,
435    inter_hunk_context: usize,
436) -> String {
437    histogram_unified_body_raw(old_content, new_content, context_lines, inter_hunk_context)
438}
439
440/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm.
441#[must_use]
442pub fn unified_diff_histogram_with_prefix_and_funcname(
443    old_content: &str,
444    new_content: &str,
445    old_path: &str,
446    new_path: &str,
447    context_lines: usize,
448    inter_hunk_context: usize,
449    src_prefix: &str,
450    dst_prefix: &str,
451    funcname_matcher: Option<&FuncnameMatcher>,
452    quote_path_fully: bool,
453) -> String {
454    use crate::quote_path::format_diff_path_with_prefix;
455
456    let body =
457        histogram_unified_body_raw(old_content, new_content, context_lines, inter_hunk_context);
458
459    let mut output = String::new();
460    if old_path == "/dev/null" {
461        output.push_str("--- /dev/null\n");
462    } else if src_prefix.is_empty() {
463        output.push_str(&format!("--- {old_path}\n"));
464    } else {
465        output.push_str("--- ");
466        output.push_str(&format_diff_path_with_prefix(
467            src_prefix,
468            old_path,
469            quote_path_fully,
470        ));
471        output.push('\n');
472    }
473    if new_path == "/dev/null" {
474        output.push_str("+++ /dev/null\n");
475    } else if dst_prefix.is_empty() {
476        output.push_str(&format!("+++ {new_path}\n"));
477    } else {
478        output.push_str("+++ ");
479        output.push_str(&format_diff_path_with_prefix(
480            dst_prefix,
481            new_path,
482            quote_path_fully,
483        ));
484        output.push('\n');
485    }
486
487    let old_lines: Vec<&str> = old_content.lines().collect();
488    for hunk_str in imara_unified_hunk_slices(&body) {
489        if hunk_str.is_empty() {
490            continue;
491        }
492        if let Some(first_newline) = hunk_str.find('\n') {
493            let header_line = &hunk_str[..first_newline];
494            let rest = &hunk_str[first_newline..];
495            if let Some(func_ctx) =
496                extract_function_context(header_line, &old_lines, funcname_matcher)
497            {
498                output.push_str(header_line);
499                output.push(' ');
500                output.push_str(&func_ctx);
501                output.push_str(rest);
502            } else {
503                output.push_str(hunk_str);
504            }
505        } else {
506            output.push_str(hunk_str);
507        }
508    }
509
510    output
511}
512
513/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm,
514/// applying `--ignore-blank-lines` / `-I` change-record suppression
515/// ([`histogram_unified_body_ignore`]) and then attaching function-name hunk
516/// headers exactly like [`unified_diff_histogram_with_prefix_and_funcname`].
517///
518/// `is_ignorable_change` is given the removed/added line slices (without
519/// trailing newlines) of one change record and returns whether the whole change
520/// is ignorable. Returns an empty string when no substantive change survives so
521/// the caller can hide the file entry.
522#[allow(clippy::too_many_arguments)]
523pub fn unified_diff_histogram_ignore_with_prefix_and_funcname<F>(
524    old_content: &str,
525    new_content: &str,
526    old_path: &str,
527    new_path: &str,
528    context_lines: usize,
529    inter_hunk_context: usize,
530    src_prefix: &str,
531    dst_prefix: &str,
532    funcname_matcher: Option<&FuncnameMatcher>,
533    quote_path_fully: bool,
534    function_context: bool,
535    is_ignorable_change: F,
536) -> String
537where
538    F: Fn(&[&str], &[&str]) -> bool,
539{
540    use crate::quote_path::format_diff_path_with_prefix;
541
542    let body = histogram_unified_body_ignore_fc(
543        old_content,
544        new_content,
545        context_lines,
546        inter_hunk_context,
547        function_context,
548        funcname_matcher,
549        is_ignorable_change,
550    );
551    if body.is_empty() {
552        return String::new();
553    }
554
555    let mut output = String::new();
556    if old_path == "/dev/null" {
557        output.push_str("--- /dev/null\n");
558    } else if src_prefix.is_empty() {
559        output.push_str(&format!("--- {old_path}\n"));
560    } else {
561        output.push_str("--- ");
562        output.push_str(&format_diff_path_with_prefix(
563            src_prefix,
564            old_path,
565            quote_path_fully,
566        ));
567        output.push('\n');
568    }
569    if new_path == "/dev/null" {
570        output.push_str("+++ /dev/null\n");
571    } else if dst_prefix.is_empty() {
572        output.push_str(&format!("+++ {new_path}\n"));
573    } else {
574        output.push_str("+++ ");
575        output.push_str(&format_diff_path_with_prefix(
576            dst_prefix,
577            new_path,
578            quote_path_fully,
579        ));
580        output.push('\n');
581    }
582
583    let old_lines: Vec<&str> = old_content.lines().collect();
584    for hunk_str in imara_unified_hunk_slices(&body) {
585        if hunk_str.is_empty() {
586            continue;
587        }
588        if let Some(first_newline) = hunk_str.find('\n') {
589            let header_line = &hunk_str[..first_newline];
590            let rest = &hunk_str[first_newline..];
591            if let Some(func_ctx) =
592                extract_function_context(header_line, &old_lines, funcname_matcher)
593            {
594                output.push_str(header_line);
595                output.push(' ');
596                output.push_str(&func_ctx);
597                output.push_str(rest);
598            } else {
599                output.push_str(hunk_str);
600            }
601        } else {
602            output.push_str(hunk_str);
603        }
604    }
605
606    output
607}
608
609/// `diff.indentHeuristic` from config (Git defaults to true when unset).
610#[must_use]
611pub fn indent_heuristic_from_config(config: &ConfigSet) -> bool {
612    match config.get_bool("diff.indentHeuristic") {
613        Some(Ok(b)) => b,
614        Some(Err(_)) | None => true,
615    }
616}
617
618/// Resolve indent heuristic: `--no-indent-heuristic` and `--indent-heuristic` override config.
619#[must_use]
620pub fn resolve_indent_heuristic(
621    config: &ConfigSet,
622    cli_indent_heuristic: bool,
623    cli_no_indent_heuristic: bool,
624) -> bool {
625    if cli_no_indent_heuristic {
626        false
627    } else if cli_indent_heuristic {
628        true
629    } else {
630        indent_heuristic_from_config(config)
631    }
632}
633
634/// Parse `--indent-heuristic` / `--no-indent-heuristic` from a plumbing argv slice (last occurrence wins).
635#[must_use]
636pub fn parse_indent_heuristic_cli_flags(argv: &[String]) -> (bool, bool) {
637    let mut indent_heuristic = false;
638    let mut no_indent_heuristic = false;
639    for a in argv {
640        match a.as_str() {
641            "--indent-heuristic" => {
642                indent_heuristic = true;
643                no_indent_heuristic = false;
644            }
645            "--no-indent-heuristic" => {
646                no_indent_heuristic = true;
647                indent_heuristic = false;
648            }
649            _ => {}
650        }
651    }
652    (indent_heuristic, no_indent_heuristic)
653}
654
655// ---------------------------------------------------------------------------
656// Git-compatible implementation of the xdiff Myers engine,
657// restricted to what the word-diff path needs (no rename/ignore-whitespace
658// flags). Git's word diff runs the *default* xdiff Myers over the per-word
659// token streams; matching Git's exact change-record selection requires
660// reproducing its preprocessing (record classification + `xdl_cleanup_records`
661// + `xdl_trim_ends`) and its divide-and-conquer split, because `imara-diff`
662// (and `similar`) pick different — but equally minimal — alignments for inputs
663// with many repeated tokens (e.g. the `{`/`}`/`,` in the `bibtex` driver).
664mod git_xdiff {
665    const XDL_MAX_COST_MIN: i64 = 256;
666    const XDL_HEUR_MIN_COST: i64 = 256;
667    const XDL_SNAKE_CNT: i64 = 20;
668    const XDL_K_HEUR: i64 = 4;
669    const XDL_LINE_MAX: i64 = i64::MAX;
670    const XDL_KPDIS_RUN: i64 = 4;
671    const XDL_MAX_EQLIMIT: i64 = 1024;
672    const XDL_SIMSCAN_WINDOW: i64 = 100;
673
674    // record-classification actions (xprepare.c)
675    const DISCARD: u8 = 0;
676    const KEEP: u8 = 1;
677    const INVESTIGATE: u8 = 2;
678
679    /// Classical integer square root approximation using shifts (`xdl_bogosqrt`).
680    fn bogosqrt(mut n: i64) -> i64 {
681        let mut i: i64 = 1;
682        while n > 0 {
683            i <<= 1;
684            n >>= 2;
685        }
686        i
687    }
688
689    struct XdAlgoEnv {
690        mxcost: i64,
691        snake_cnt: i64,
692        heur_min: i64,
693    }
694
695    struct XdpSplit {
696        i1: i64,
697        i2: i64,
698        min_lo: bool,
699        min_hi: bool,
700    }
701
702    /// A single side of the diff: token ids plus the `changed` flags and the
703    /// `reference_index` mapping from effective record -> original record.
704    struct XdFile<'a> {
705        ids: &'a [u32],
706        changed: Vec<bool>,
707        reference_index: Vec<usize>,
708        dstart: i64,
709        dend: i64,
710        nreff: i64,
711    }
712
713    /// Hash/id lookup over the *effective* records: index into `reference_index`.
714    #[inline]
715    fn get_id(xdf: &XdFile<'_>, idx: i64) -> u32 {
716        xdf.ids[xdf.reference_index[idx as usize]]
717    }
718
719    /// Faithful port of `xdl_split` (the middle-snake finder). `kvdf`/`kvdb` are
720    /// the forward/backward k-vectors, offset so index 0 maps to diagonal 0.
721    #[allow(clippy::too_many_arguments)]
722    fn xdl_split(
723        xdf1: &XdFile<'_>,
724        off1: i64,
725        lim1: i64,
726        xdf2: &XdFile<'_>,
727        off2: i64,
728        lim2: i64,
729        kvdf: &mut [i64],
730        kvdb: &mut [i64],
731        koff: i64,
732        need_min: bool,
733        spl: &mut XdpSplit,
734        xenv: &XdAlgoEnv,
735    ) -> i64 {
736        let dmin = off1 - lim2;
737        let dmax = lim1 - off2;
738        let fmid = off1 - off2;
739        let bmid = lim1 - lim2;
740        let odd = ((fmid - bmid) & 1) != 0;
741        let mut fmin = fmid;
742        let mut fmax = fmid;
743        let mut bmin = bmid;
744        let mut bmax = bmid;
745
746        let kf = |k: i64| -> usize { (k + koff) as usize };
747
748        kvdf[kf(fmid)] = off1;
749        kvdb[kf(bmid)] = lim1;
750
751        let mut ec: i64 = 1;
752        loop {
753            let mut got_snake = false;
754
755            if fmin > dmin {
756                fmin -= 1;
757                kvdf[kf(fmin - 1)] = -1;
758            } else {
759                fmin += 1;
760            }
761            if fmax < dmax {
762                fmax += 1;
763                kvdf[kf(fmax + 1)] = -1;
764            } else {
765                fmax -= 1;
766            }
767
768            let mut d = fmax;
769            while d >= fmin {
770                let mut i1 = if kvdf[kf(d - 1)] >= kvdf[kf(d + 1)] {
771                    kvdf[kf(d - 1)] + 1
772                } else {
773                    kvdf[kf(d + 1)]
774                };
775                let prev1 = i1;
776                let mut i2 = i1 - d;
777                while i1 < lim1 && i2 < lim2 && get_id(xdf1, i1) == get_id(xdf2, i2) {
778                    i1 += 1;
779                    i2 += 1;
780                }
781                if i1 - prev1 > xenv.snake_cnt {
782                    got_snake = true;
783                }
784                kvdf[kf(d)] = i1;
785                if odd && bmin <= d && d <= bmax && kvdb[kf(d)] <= i1 {
786                    spl.i1 = i1;
787                    spl.i2 = i2;
788                    spl.min_lo = true;
789                    spl.min_hi = true;
790                    return ec;
791                }
792                d -= 2;
793            }
794
795            if bmin > dmin {
796                bmin -= 1;
797                kvdb[kf(bmin - 1)] = XDL_LINE_MAX;
798            } else {
799                bmin += 1;
800            }
801            if bmax < dmax {
802                bmax += 1;
803                kvdb[kf(bmax + 1)] = XDL_LINE_MAX;
804            } else {
805                bmax -= 1;
806            }
807
808            let mut d = bmax;
809            while d >= bmin {
810                let mut i1 = if kvdb[kf(d - 1)] < kvdb[kf(d + 1)] {
811                    kvdb[kf(d - 1)]
812                } else {
813                    kvdb[kf(d + 1)] - 1
814                };
815                let prev1 = i1;
816                let mut i2 = i1 - d;
817                while i1 > off1 && i2 > off2 && get_id(xdf1, i1 - 1) == get_id(xdf2, i2 - 1) {
818                    i1 -= 1;
819                    i2 -= 1;
820                }
821                if prev1 - i1 > xenv.snake_cnt {
822                    got_snake = true;
823                }
824                kvdb[kf(d)] = i1;
825                if !odd && fmin <= d && d <= fmax && i1 <= kvdf[kf(d)] {
826                    spl.i1 = i1;
827                    spl.i2 = i2;
828                    spl.min_lo = true;
829                    spl.min_hi = true;
830                    return ec;
831                }
832                d -= 2;
833            }
834
835            if need_min {
836                ec += 1;
837                continue;
838            }
839
840            if got_snake && ec > xenv.heur_min {
841                let mut best = 0i64;
842                let mut d = fmax;
843                while d >= fmin {
844                    let dd = if d > fmid { d - fmid } else { fmid - d };
845                    let i1 = kvdf[kf(d)];
846                    let i2 = i1 - d;
847                    let v = (i1 - off1) + (i2 - off2) - dd;
848                    if v > XDL_K_HEUR * ec
849                        && v > best
850                        && off1 + xenv.snake_cnt <= i1
851                        && i1 < lim1
852                        && off2 + xenv.snake_cnt <= i2
853                        && i2 < lim2
854                    {
855                        let mut k = 1i64;
856                        while get_id(xdf1, i1 - k) == get_id(xdf2, i2 - k) {
857                            if k == xenv.snake_cnt {
858                                best = v;
859                                spl.i1 = i1;
860                                spl.i2 = i2;
861                                break;
862                            }
863                            k += 1;
864                        }
865                    }
866                    d -= 2;
867                }
868                if best > 0 {
869                    spl.min_lo = true;
870                    spl.min_hi = false;
871                    return ec;
872                }
873
874                let mut best = 0i64;
875                let mut d = bmax;
876                while d >= bmin {
877                    let dd = if d > bmid { d - bmid } else { bmid - d };
878                    let i1 = kvdb[kf(d)];
879                    let i2 = i1 - d;
880                    let v = (lim1 - i1) + (lim2 - i2) - dd;
881                    if v > XDL_K_HEUR * ec
882                        && v > best
883                        && off1 < i1
884                        && i1 <= lim1 - xenv.snake_cnt
885                        && off2 < i2
886                        && i2 <= lim2 - xenv.snake_cnt
887                    {
888                        let mut k = 0i64;
889                        while get_id(xdf1, i1 + k) == get_id(xdf2, i2 + k) {
890                            if k == xenv.snake_cnt - 1 {
891                                best = v;
892                                spl.i1 = i1;
893                                spl.i2 = i2;
894                                break;
895                            }
896                            k += 1;
897                        }
898                    }
899                    d -= 2;
900                }
901                if best > 0 {
902                    spl.min_lo = false;
903                    spl.min_hi = true;
904                    return ec;
905                }
906            }
907
908            if ec >= xenv.mxcost {
909                let mut fbest = -1i64;
910                let mut fbest1 = -1i64;
911                let mut d = fmax;
912                while d >= fmin {
913                    let mut i1 = kvdf[kf(d)].min(lim1);
914                    let mut i2 = i1 - d;
915                    if lim2 < i2 {
916                        i1 = lim2 + d;
917                        i2 = lim2;
918                    }
919                    if fbest < i1 + i2 {
920                        fbest = i1 + i2;
921                        fbest1 = i1;
922                    }
923                    d -= 2;
924                }
925
926                let mut bbest = XDL_LINE_MAX;
927                let mut bbest1 = XDL_LINE_MAX;
928                let mut d = bmax;
929                while d >= bmin {
930                    let mut i1 = off1.max(kvdb[kf(d)]);
931                    let mut i2 = i1 - d;
932                    if i2 < off2 {
933                        i1 = off2 + d;
934                        i2 = off2;
935                    }
936                    if i1 + i2 < bbest {
937                        bbest = i1 + i2;
938                        bbest1 = i1;
939                    }
940                    d -= 2;
941                }
942
943                if (lim1 + lim2) - bbest < fbest - (off1 + off2) {
944                    spl.i1 = fbest1;
945                    spl.i2 = fbest - fbest1;
946                    spl.min_lo = true;
947                    spl.min_hi = false;
948                } else {
949                    spl.i1 = bbest1;
950                    spl.i2 = bbest - bbest1;
951                    spl.min_lo = false;
952                    spl.min_hi = true;
953                }
954                return ec;
955            }
956
957            ec += 1;
958        }
959    }
960
961    /// Faithful port of `xdl_recs_cmp` (divide & conquer). Marks `changed` flags.
962    #[allow(clippy::too_many_arguments)]
963    fn xdl_recs_cmp(
964        xdf1: &mut XdFile<'_>,
965        mut off1: i64,
966        mut lim1: i64,
967        xdf2: &mut XdFile<'_>,
968        mut off2: i64,
969        mut lim2: i64,
970        kvdf: &mut [i64],
971        kvdb: &mut [i64],
972        koff: i64,
973        need_min: bool,
974        xenv: &XdAlgoEnv,
975    ) {
976        while off1 < lim1 && off2 < lim2 && get_id(xdf1, off1) == get_id(xdf2, off2) {
977            off1 += 1;
978            off2 += 1;
979        }
980        while off1 < lim1 && off2 < lim2 && get_id(xdf1, lim1 - 1) == get_id(xdf2, lim2 - 1) {
981            lim1 -= 1;
982            lim2 -= 1;
983        }
984
985        if off1 == lim1 {
986            while off2 < lim2 {
987                let r = xdf2.reference_index[off2 as usize];
988                xdf2.changed[r] = true;
989                off2 += 1;
990            }
991        } else if off2 == lim2 {
992            while off1 < lim1 {
993                let r = xdf1.reference_index[off1 as usize];
994                xdf1.changed[r] = true;
995                off1 += 1;
996            }
997        } else {
998            let mut spl = XdpSplit {
999                i1: 0,
1000                i2: 0,
1001                min_lo: false,
1002                min_hi: false,
1003            };
1004            xdl_split(
1005                xdf1, off1, lim1, xdf2, off2, lim2, kvdf, kvdb, koff, need_min, &mut spl, xenv,
1006            );
1007            xdl_recs_cmp(
1008                xdf1, off1, spl.i1, xdf2, off2, spl.i2, kvdf, kvdb, koff, spl.min_lo, xenv,
1009            );
1010            xdl_recs_cmp(
1011                xdf1, spl.i1, lim1, xdf2, spl.i2, lim2, kvdf, kvdb, koff, spl.min_hi, xenv,
1012            );
1013        }
1014    }
1015
1016    /// `xdl_clean_mmatch`: decide whether a multimatch record should be discarded.
1017    fn clean_mmatch(action: &[u8], i: i64, mut s: i64, mut e: i64) -> bool {
1018        if i - s > XDL_SIMSCAN_WINDOW {
1019            s = i - XDL_SIMSCAN_WINDOW;
1020        }
1021        if e - i > XDL_SIMSCAN_WINDOW {
1022            e = i + XDL_SIMSCAN_WINDOW;
1023        }
1024
1025        let mut rdis0 = 0i64;
1026        let mut rpdis0 = 1i64;
1027        let mut r = 1i64;
1028        while i - r >= s {
1029            match action[(i - r) as usize] {
1030                DISCARD => rdis0 += 1,
1031                INVESTIGATE => rpdis0 += 1,
1032                _ => break, // KEEP
1033            }
1034            r += 1;
1035        }
1036        if rdis0 == 0 {
1037            return false;
1038        }
1039        let mut rdis1 = 0i64;
1040        let mut rpdis1 = 1i64;
1041        let mut r = 1i64;
1042        while i + r <= e {
1043            match action[(i + r) as usize] {
1044                DISCARD => rdis1 += 1,
1045                INVESTIGATE => rpdis1 += 1,
1046                _ => break, // KEEP
1047            }
1048            r += 1;
1049        }
1050        if rdis1 == 0 {
1051            return false;
1052        }
1053        rdis1 += rdis0;
1054        rpdis1 += rpdis0;
1055        rpdis1 * XDL_KPDIS_RUN < (rpdis1 + rdis1)
1056    }
1057
1058    /// Build the `changed` flags for both token streams using Git's xdiff Myers
1059    /// pipeline. `ids1`/`ids2` are interned token ids (equal id <=> equal token).
1060    /// Returns `(changed1, changed2)`, one bool per original token.
1061    pub fn changed_flags(ids1: &[u32], ids2: &[u32]) -> (Vec<bool>, Vec<bool>) {
1062        let nrec1 = ids1.len();
1063        let nrec2 = ids2.len();
1064
1065        // Per-token occurrence counts on each side (class len1/len2).
1066        use std::collections::HashMap;
1067        let mut count1: HashMap<u32, i64> = HashMap::new();
1068        let mut count2: HashMap<u32, i64> = HashMap::new();
1069        for &id in ids1 {
1070            *count1.entry(id).or_insert(0) += 1;
1071        }
1072        for &id in ids2 {
1073            *count2.entry(id).or_insert(0) += 1;
1074        }
1075
1076        let mut xdf1 = XdFile {
1077            ids: ids1,
1078            changed: vec![false; nrec1],
1079            reference_index: Vec::new(),
1080            dstart: 0,
1081            dend: nrec1 as i64 - 1,
1082            nreff: 0,
1083        };
1084        let mut xdf2 = XdFile {
1085            ids: ids2,
1086            changed: vec![false; nrec2],
1087            reference_index: Vec::new(),
1088            dstart: 0,
1089            dend: nrec2 as i64 - 1,
1090            nreff: 0,
1091        };
1092
1093        // xdl_trim_ends: trim leading/trailing matching records.
1094        let lim = nrec1.min(nrec2) as i64;
1095        let mut i = 0i64;
1096        while i < lim && ids1[i as usize] == ids2[i as usize] {
1097            i += 1;
1098        }
1099        xdf1.dstart = i;
1100        xdf2.dstart = i;
1101        let mut j = 0i64;
1102        let rem = lim - i;
1103        while j < rem && ids1[nrec1 - 1 - j as usize] == ids2[nrec2 - 1 - j as usize] {
1104            j += 1;
1105        }
1106        xdf1.dend = nrec1 as i64 - j - 1;
1107        xdf2.dend = nrec2 as i64 - j - 1;
1108
1109        // xdl_cleanup_records: classify and reduce to effective records.
1110        let mut action1 = vec![0u8; nrec1 + 1];
1111        let mut action2 = vec![0u8; nrec2 + 1];
1112
1113        let mut mlim = bogosqrt(nrec1 as i64);
1114        if mlim > XDL_MAX_EQLIMIT {
1115            mlim = XDL_MAX_EQLIMIT;
1116        }
1117        let mut idx = xdf1.dstart;
1118        while idx <= xdf1.dend {
1119            let id = ids1[idx as usize];
1120            let nm = *count2.get(&id).unwrap_or(&0);
1121            action1[idx as usize] = if nm == 0 {
1122                DISCARD
1123            } else if nm >= mlim {
1124                INVESTIGATE
1125            } else {
1126                KEEP
1127            };
1128            idx += 1;
1129        }
1130
1131        let mut mlim = bogosqrt(nrec2 as i64);
1132        if mlim > XDL_MAX_EQLIMIT {
1133            mlim = XDL_MAX_EQLIMIT;
1134        }
1135        let mut idx = xdf2.dstart;
1136        while idx <= xdf2.dend {
1137            let id = ids2[idx as usize];
1138            let nm = *count1.get(&id).unwrap_or(&0);
1139            action2[idx as usize] = if nm == 0 {
1140                DISCARD
1141            } else if nm >= mlim {
1142                INVESTIGATE
1143            } else {
1144                KEEP
1145            };
1146            idx += 1;
1147        }
1148
1149        let mut idx = xdf1.dstart;
1150        while idx <= xdf1.dend {
1151            let a = action1[idx as usize];
1152            if a == KEEP
1153                || (a == INVESTIGATE && !clean_mmatch(&action1, idx, xdf1.dstart, xdf1.dend))
1154            {
1155                xdf1.reference_index.push(idx as usize);
1156            } else {
1157                xdf1.changed[idx as usize] = true;
1158            }
1159            idx += 1;
1160        }
1161        xdf1.nreff = xdf1.reference_index.len() as i64;
1162
1163        let mut idx = xdf2.dstart;
1164        while idx <= xdf2.dend {
1165            let a = action2[idx as usize];
1166            if a == KEEP
1167                || (a == INVESTIGATE && !clean_mmatch(&action2, idx, xdf2.dstart, xdf2.dend))
1168            {
1169                xdf2.reference_index.push(idx as usize);
1170            } else {
1171                xdf2.changed[idx as usize] = true;
1172            }
1173            idx += 1;
1174        }
1175        xdf2.nreff = xdf2.reference_index.len() as i64;
1176
1177        // Allocate K vectors (xdl_do_diff). koff lets us use negative diagonals.
1178        let ndiags = xdf1.nreff + xdf2.nreff + 3;
1179        let kvd_len = (2 * ndiags + 2) as usize;
1180        let mut kvd = vec![0i64; kvd_len];
1181        // kvdf base offset = nreff2 + 1; kvdb base offset = ndiags + nreff2 + 1.
1182        let koff = xdf2.nreff + 1;
1183        let (kvdf_slice, kvdb_slice) = kvd.split_at_mut(ndiags as usize);
1184        // Both slices are indexed as [k + koff]; their length covers the diagonal range.
1185
1186        let xenv = XdAlgoEnv {
1187            mxcost: bogosqrt(ndiags).max(XDL_MAX_COST_MIN),
1188            snake_cnt: XDL_SNAKE_CNT,
1189            heur_min: XDL_HEUR_MIN_COST,
1190        };
1191
1192        let nreff1 = xdf1.nreff;
1193        let nreff2 = xdf2.nreff;
1194        xdl_recs_cmp(
1195            &mut xdf1, 0, nreff1, &mut xdf2, 0, nreff2, kvdf_slice, kvdb_slice, koff, false, &xenv,
1196        );
1197
1198        (xdf1.changed, xdf2.changed)
1199    }
1200}
1201
1202/// Convert per-token `changed` flags (Git xdiff style) into `similar::DiffOp`s.
1203fn changed_flags_to_ops(
1204    changed1: &[bool],
1205    changed2: &[bool],
1206    old_len: usize,
1207    new_len: usize,
1208) -> Vec<similar::DiffOp> {
1209    use similar::DiffOp;
1210    let mut ops: Vec<DiffOp> = Vec::new();
1211    let mut i = 0usize;
1212    let mut j = 0usize;
1213    while i < old_len || j < new_len {
1214        let del = i < old_len && changed1[i];
1215        let ins = j < new_len && changed2[j];
1216        if !del && !ins {
1217            // Equal run.
1218            let start_i = i;
1219            let start_j = j;
1220            while i < old_len && j < new_len && !changed1[i] && !changed2[j] {
1221                i += 1;
1222                j += 1;
1223            }
1224            ops.push(DiffOp::Equal {
1225                old_index: start_i,
1226                new_index: start_j,
1227                len: i - start_i,
1228            });
1229        } else {
1230            // Changed run: consecutive deletions then/and insertions.
1231            let start_i = i;
1232            let start_j = j;
1233            while i < old_len && changed1[i] {
1234                i += 1;
1235            }
1236            while j < new_len && changed2[j] {
1237                j += 1;
1238            }
1239            let dlen = i - start_i;
1240            let ilen = j - start_j;
1241            if dlen > 0 && ilen > 0 {
1242                ops.push(DiffOp::Replace {
1243                    old_index: start_i,
1244                    old_len: dlen,
1245                    new_index: start_j,
1246                    new_len: ilen,
1247                });
1248            } else if dlen > 0 {
1249                ops.push(DiffOp::Delete {
1250                    old_index: start_i,
1251                    old_len: dlen,
1252                    new_index: start_j,
1253                });
1254            } else if ilen > 0 {
1255                ops.push(DiffOp::Insert {
1256                    old_index: start_i,
1257                    new_index: start_j,
1258                    new_len: ilen,
1259                });
1260            }
1261        }
1262    }
1263    ops
1264}
1265
1266/// Diff two token streams with a Git-compatible implementation of the xdiff Myers engine and return the
1267/// result as `similar::DiffOp`s. Used by the word-diff machinery, where matching Git's exact
1268/// record selection and tie-breaking matters: `imara-diff`/`similar` pick different — but
1269/// equally minimal — alignments for streams with many repeated tokens (e.g. the `bibtex`
1270/// driver's `{`/`}`/`,`), mismatching Git's reference output.
1271#[must_use]
1272pub fn word_diff_ops_imara(old_words: &[&str], new_words: &[&str]) -> Vec<similar::DiffOp> {
1273    use std::collections::HashMap;
1274
1275    // Intern tokens to ids (equal id <=> equal token), mirroring xdiff's record hashing.
1276    let mut interner: HashMap<&str, u32> = HashMap::new();
1277    let mut ids1: Vec<u32> = Vec::with_capacity(old_words.len());
1278    for &w in old_words {
1279        let next = interner.len() as u32;
1280        let id = *interner.entry(w).or_insert(next);
1281        ids1.push(id);
1282    }
1283    let mut ids2: Vec<u32> = Vec::with_capacity(new_words.len());
1284    for &w in new_words {
1285        let next = interner.len() as u32;
1286        let id = *interner.entry(w).or_insert(next);
1287        ids2.push(id);
1288    }
1289
1290    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2);
1291    let ops = changed_flags_to_ops(&changed1, &changed2, old_words.len(), new_words.len());
1292
1293    // Slide changed runs to Git's canonical position (`xdl_change_compact`); the word
1294    // diff never enables the indent heuristic.
1295    diff_indent_heuristic::apply_change_compact_to_ops(&ops, old_words, new_words, false)
1296}
1297
1298/// Line-diff ops for string slices after Git `xdl_change_compact` (and optional indent heuristic).
1299#[must_use]
1300pub fn diff_slice_ops_compacted(
1301    old_lines: &[&str],
1302    new_lines: &[&str],
1303    algorithm: similar::Algorithm,
1304    indent_heuristic: bool,
1305) -> Vec<similar::DiffOp> {
1306    diff_indent_heuristic::diff_slice_ops_compacted(
1307        old_lines,
1308        new_lines,
1309        algorithm,
1310        indent_heuristic,
1311    )
1312}
1313
1314/// Map each line in `new_joined` to its origin in `old_joined` after Git-style compaction (for blame).
1315#[must_use]
1316pub fn map_new_to_old_lines_compacted(
1317    old_joined: &str,
1318    new_joined: &str,
1319    algorithm: similar::Algorithm,
1320    indent_heuristic: bool,
1321    new_line_count: usize,
1322) -> Vec<Option<usize>> {
1323    let ops = diff_indent_heuristic::diff_lines_ops_compacted(
1324        old_joined,
1325        new_joined,
1326        algorithm,
1327        indent_heuristic,
1328    );
1329    diff_indent_heuristic::map_new_to_old_from_ops(&ops, new_line_count)
1330}
1331
1332/// The kind of change between two sides of a diff.
1333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1334pub enum DiffStatus {
1335    /// File was added.
1336    Added,
1337    /// File was deleted.
1338    Deleted,
1339    /// File was modified (content or mode change).
1340    Modified,
1341    /// File was renamed (with optional content change).
1342    Renamed,
1343    /// File was copied.
1344    Copied,
1345    /// File type changed (e.g. regular → symlink).
1346    TypeChanged,
1347    /// Unmerged (conflict).
1348    Unmerged,
1349}
1350
1351impl DiffStatus {
1352    /// Single-character status letter used in raw diff output.
1353    #[must_use]
1354    pub fn letter(&self) -> char {
1355        match self {
1356            Self::Added => 'A',
1357            Self::Deleted => 'D',
1358            Self::Modified => 'M',
1359            Self::Renamed => 'R',
1360            Self::Copied => 'C',
1361            Self::TypeChanged => 'T',
1362            Self::Unmerged => 'U',
1363        }
1364    }
1365}
1366
1367/// A single diff entry representing one changed path.
1368#[derive(Debug, Clone, PartialEq, Eq)]
1369pub struct DiffEntry {
1370    /// The status of this change.
1371    pub status: DiffStatus,
1372    /// Path in the "old" side (None for Added).
1373    pub old_path: Option<String>,
1374    /// Path in the "new" side (None for Deleted).
1375    pub new_path: Option<String>,
1376    /// Old file mode (as octal string, e.g. "100644").
1377    pub old_mode: String,
1378    /// New file mode.
1379    pub new_mode: String,
1380    /// Old object ID (zero OID for Added).
1381    pub old_oid: ObjectId,
1382    /// New object ID (zero OID for Deleted).
1383    pub new_oid: ObjectId,
1384    /// Similarity score (0–100) for renames/copies.
1385    pub score: Option<u32>,
1386}
1387
1388impl DiffEntry {
1389    /// The primary path for display (new_path for adds, old_path for deletes).
1390    #[must_use]
1391    pub fn path(&self) -> &str {
1392        self.new_path
1393            .as_deref()
1394            .or(self.old_path.as_deref())
1395            .unwrap_or("")
1396    }
1397
1398    /// Return a human-oriented path display for this entry.
1399    ///
1400    /// For renames and copies this returns `old -> new`; for all other entry
1401    /// kinds this returns the primary path.
1402    #[must_use]
1403    pub fn display_path(&self) -> String {
1404        match self.status {
1405            DiffStatus::Renamed | DiffStatus::Copied => {
1406                let old = self.old_path.as_deref().unwrap_or("");
1407                let new = self.new_path.as_deref().unwrap_or("");
1408                if old.is_empty() || new.is_empty() {
1409                    self.path().to_owned()
1410                } else {
1411                    format!("{old} -> {new}")
1412                }
1413            }
1414            _ => self.path().to_owned(),
1415        }
1416    }
1417}
1418
1419/// The zero (null) object ID used for "no object" in diff output.
1420pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
1421
1422/// Return the zero ObjectId.
1423#[must_use]
1424pub fn zero_oid() -> ObjectId {
1425    ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
1426        // This should never fail since we pass exactly 20 bytes
1427        panic!("internal error: failed to create zero OID");
1428    })
1429}
1430
1431/// Return the ObjectId for the empty blob object.
1432#[must_use]
1433pub fn empty_blob_oid() -> ObjectId {
1434    ObjectId::from_hex("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap_or_else(|_| {
1435        // This should never fail since the object ID literal is valid.
1436        panic!("internal error: failed to create empty blob OID");
1437    })
1438}
1439
1440// ── Tree-to-tree diff ───────────────────────────────────────────────
1441
1442/// Compare two trees and return the list of changed entries.
1443///
1444/// # Parameters
1445///
1446/// - `odb` — object database to read tree objects from.
1447/// - `old_tree_oid` — OID of the old tree (or `None` for comparison against empty).
1448/// - `new_tree_oid` — OID of the new tree (or `None` for comparison against empty).
1449/// - `prefix` — path prefix for nested tree recursion (empty string for root).
1450///
1451/// # Errors
1452///
1453/// Returns errors from object database reads.
1454pub fn diff_trees(
1455    odb: &Odb,
1456    old_tree_oid: Option<&ObjectId>,
1457    new_tree_oid: Option<&ObjectId>,
1458    prefix: &str,
1459) -> Result<Vec<DiffEntry>> {
1460    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, false)
1461}
1462
1463/// Like `diff_trees` but with `show_trees` flag: when true, emit entries for
1464/// tree objects themselves in addition to their recursive contents (the `-t`
1465/// flag of `diff-tree`).
1466pub fn diff_trees_show_tree_entries(
1467    odb: &Odb,
1468    old_tree_oid: Option<&ObjectId>,
1469    new_tree_oid: Option<&ObjectId>,
1470    prefix: &str,
1471) -> Result<Vec<DiffEntry>> {
1472    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, true)
1473}
1474
1475fn diff_trees_opts(
1476    odb: &Odb,
1477    old_tree_oid: Option<&ObjectId>,
1478    new_tree_oid: Option<&ObjectId>,
1479    prefix: &str,
1480    show_trees: bool,
1481) -> Result<Vec<DiffEntry>> {
1482    let old_entries = match old_tree_oid {
1483        Some(oid) => read_tree(odb, oid)?,
1484        None => Vec::new(),
1485    };
1486    let new_entries = match new_tree_oid {
1487        Some(oid) => read_tree(odb, oid)?,
1488        None => Vec::new(),
1489    };
1490
1491    let mut result = Vec::new();
1492    diff_tree_entries_opts(
1493        odb,
1494        &old_entries,
1495        &new_entries,
1496        prefix,
1497        show_trees,
1498        &mut result,
1499    )?;
1500    Ok(result)
1501}
1502
1503/// Read and parse a tree object from the ODB.
1504fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
1505    let obj = odb.read(oid)?;
1506    if obj.kind != ObjectKind::Tree {
1507        return Err(Error::CorruptObject(format!(
1508            "expected tree, got {}",
1509            obj.kind.as_str()
1510        )));
1511    }
1512    parse_tree(&obj.data)
1513}
1514
1515/// Compare two sorted lists of tree entries, recursing into subtrees.
1516fn diff_tree_entries_opts(
1517    odb: &Odb,
1518    old: &[TreeEntry],
1519    new: &[TreeEntry],
1520    prefix: &str,
1521    show_trees: bool,
1522    result: &mut Vec<DiffEntry>,
1523) -> Result<()> {
1524    let mut oi = 0;
1525    let mut ni = 0;
1526
1527    while oi < old.len() || ni < new.len() {
1528        match (old.get(oi), new.get(ni)) {
1529            (Some(o), Some(n)) => {
1530                let cmp = crate::objects::tree_entry_cmp(
1531                    &o.name,
1532                    is_tree_mode(o.mode),
1533                    &n.name,
1534                    is_tree_mode(n.mode),
1535                );
1536                match cmp {
1537                    std::cmp::Ordering::Less => {
1538                        // Old entry not in new → deleted
1539                        emit_deleted_opts(odb, o, prefix, show_trees, result)?;
1540                        oi += 1;
1541                    }
1542                    std::cmp::Ordering::Greater => {
1543                        // New entry not in old → added
1544                        emit_added_opts(odb, n, prefix, show_trees, result)?;
1545                        ni += 1;
1546                    }
1547                    std::cmp::Ordering::Equal => {
1548                        // Both present — check for changes
1549                        if o.oid != n.oid || o.mode != n.mode {
1550                            let name_str = String::from_utf8_lossy(&o.name);
1551                            let path = format_path(prefix, &name_str);
1552                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
1553                                // Both are trees
1554                                if show_trees {
1555                                    result.push(DiffEntry {
1556                                        status: DiffStatus::Modified,
1557                                        old_path: Some(path.clone()),
1558                                        new_path: Some(path.clone()),
1559                                        old_mode: format_mode(o.mode),
1560                                        new_mode: format_mode(n.mode),
1561                                        old_oid: o.oid,
1562                                        new_oid: n.oid,
1563                                        score: None,
1564                                    });
1565                                }
1566                                // Recurse
1567                                let nested = diff_trees_opts(
1568                                    odb,
1569                                    Some(&o.oid),
1570                                    Some(&n.oid),
1571                                    &path,
1572                                    show_trees,
1573                                )?;
1574                                result.extend(nested);
1575                            } else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
1576                                // Tree → blob: delete tree contents, add blob
1577                                emit_deleted_opts(odb, o, prefix, show_trees, result)?;
1578                                emit_added_opts(odb, n, prefix, show_trees, result)?;
1579                            } else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
1580                                // Blob → tree: delete blob, add tree contents
1581                                emit_deleted_opts(odb, o, prefix, show_trees, result)?;
1582                                emit_added_opts(odb, n, prefix, show_trees, result)?;
1583                            } else {
1584                                // Both blobs — modified.
1585                                // A mode-only change (e.g. chmod) is Modified.
1586                                // TypeChanged is only for actual type changes (blob ↔ symlink).
1587                                let old_type = o.mode & 0o170000;
1588                                let new_type = n.mode & 0o170000;
1589                                result.push(DiffEntry {
1590                                    status: if old_type != new_type {
1591                                        DiffStatus::TypeChanged
1592                                    } else {
1593                                        DiffStatus::Modified
1594                                    },
1595                                    old_path: Some(path.clone()),
1596                                    new_path: Some(path),
1597                                    old_mode: format_mode(o.mode),
1598                                    new_mode: format_mode(n.mode),
1599                                    old_oid: o.oid,
1600                                    new_oid: n.oid,
1601                                    score: None,
1602                                });
1603                            }
1604                        }
1605                        oi += 1;
1606                        ni += 1;
1607                    }
1608                }
1609            }
1610            (Some(o), None) => {
1611                emit_deleted_opts(odb, o, prefix, show_trees, result)?;
1612                oi += 1;
1613            }
1614            (None, Some(n)) => {
1615                emit_added_opts(odb, n, prefix, show_trees, result)?;
1616                ni += 1;
1617            }
1618            (None, None) => break,
1619        }
1620    }
1621
1622    Ok(())
1623}
1624
1625fn emit_deleted_opts(
1626    odb: &Odb,
1627    entry: &TreeEntry,
1628    prefix: &str,
1629    show_trees: bool,
1630    result: &mut Vec<DiffEntry>,
1631) -> Result<()> {
1632    let name_str = String::from_utf8_lossy(&entry.name);
1633    let path = format_path(prefix, &name_str);
1634    if is_tree_mode(entry.mode) {
1635        if show_trees {
1636            result.push(DiffEntry {
1637                status: DiffStatus::Deleted,
1638                old_path: Some(path.clone()),
1639                new_path: None,
1640                old_mode: format_mode(entry.mode),
1641                new_mode: "000000".to_owned(),
1642                old_oid: entry.oid,
1643                new_oid: zero_oid(),
1644                score: None,
1645            });
1646        }
1647        // Recurse into deleted tree
1648        let nested = diff_trees_opts(odb, Some(&entry.oid), None, &path, show_trees)?;
1649        result.extend(nested);
1650    } else {
1651        result.push(DiffEntry {
1652            status: DiffStatus::Deleted,
1653            old_path: Some(path.clone()),
1654            new_path: None,
1655            old_mode: format_mode(entry.mode),
1656            new_mode: "000000".to_owned(),
1657            old_oid: entry.oid,
1658            new_oid: zero_oid(),
1659            score: None,
1660        });
1661    }
1662    Ok(())
1663}
1664
1665fn emit_added_opts(
1666    odb: &Odb,
1667    entry: &TreeEntry,
1668    prefix: &str,
1669    show_trees: bool,
1670    result: &mut Vec<DiffEntry>,
1671) -> Result<()> {
1672    let name_str = String::from_utf8_lossy(&entry.name);
1673    let path = format_path(prefix, &name_str);
1674    if is_tree_mode(entry.mode) {
1675        if show_trees {
1676            result.push(DiffEntry {
1677                status: DiffStatus::Added,
1678                old_path: None,
1679                new_path: Some(path.clone()),
1680                old_mode: "000000".to_owned(),
1681                new_mode: format_mode(entry.mode),
1682                old_oid: zero_oid(),
1683                new_oid: entry.oid,
1684                score: None,
1685            });
1686        }
1687        // Recurse into added tree
1688        let nested = diff_trees_opts(odb, None, Some(&entry.oid), &path, show_trees)?;
1689        result.extend(nested);
1690    } else {
1691        result.push(DiffEntry {
1692            status: DiffStatus::Added,
1693            old_path: None,
1694            new_path: Some(path),
1695            old_mode: "000000".to_owned(),
1696            new_mode: format_mode(entry.mode),
1697            old_oid: zero_oid(),
1698            new_oid: entry.oid,
1699            score: None,
1700        });
1701    }
1702    Ok(())
1703}
1704
1705// ── Index-to-tree diff (staged changes) ─────────────────────────────
1706
1707/// Compare the index against a tree (usually HEAD's tree).
1708///
1709/// This shows "staged" changes — what would be committed.
1710///
1711/// # Parameters
1712///
1713/// - `odb` — object database.
1714/// - `index` — the current index.
1715/// - `tree_oid` — the tree to compare against (e.g. HEAD's tree), or `None`
1716///   for comparison against an empty tree (initial commit).
1717///
1718/// # Errors
1719///
1720/// Returns errors from ODB reads.
1721///
1722/// When `ignore_submodules` is true, gitlink (`160000`) paths are omitted from the diff, matching
1723/// Git's `require_clean_work_tree(..., ignore_submodules=1)` used by `git rebase` / `git pull`.
1724pub fn diff_index_to_tree(
1725    odb: &Odb,
1726    index: &Index,
1727    tree_oid: Option<&ObjectId>,
1728    ignore_submodules: bool,
1729) -> Result<Vec<DiffEntry>> {
1730    // Flatten the tree into a sorted list of (path, mode, oid)
1731    let tree_entries = match tree_oid {
1732        Some(oid) => flatten_tree(odb, oid, "")?,
1733        None => Vec::new(),
1734    };
1735
1736    // Build maps keyed by path
1737    let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
1738        std::collections::BTreeMap::new();
1739    for entry in &tree_entries {
1740        tree_map.insert(&entry.path, entry);
1741    }
1742
1743    let mut result = Vec::new();
1744    let mut stage0_paths = std::collections::BTreeSet::new();
1745    let mut unmerged_modes: std::collections::BTreeMap<String, (u8, u32)> =
1746        std::collections::BTreeMap::new();
1747
1748    // Check index entries against tree
1749    for ie in &index.entries {
1750        let path = String::from_utf8_lossy(&ie.path).to_string();
1751        if ie.stage() == 0 && ie.intent_to_add() {
1752            // Intent-to-add entries are not "staged" for diff-index / status
1753            // (matches Git: `git diff --cached` is empty for `-N` paths).
1754            continue;
1755        }
1756        if ie.stage() != 0 {
1757            let rank = match ie.stage() {
1758                2 => 0u8,
1759                3 => 1u8,
1760                1 => 2u8,
1761                _ => 3u8,
1762            };
1763            match unmerged_modes.get(&path) {
1764                Some((existing_rank, _)) if *existing_rank <= rank => {}
1765                _ => {
1766                    unmerged_modes.insert(path, (rank, ie.mode));
1767                }
1768            }
1769            continue;
1770        }
1771        if ignore_submodules && ie.mode == 0o160000 {
1772            let _ = tree_map.remove(path.as_str());
1773            stage0_paths.insert(path.clone());
1774            continue;
1775        }
1776        stage0_paths.insert(path.clone());
1777        match tree_map.remove(path.as_str()) {
1778            Some(te) => {
1779                // Present in both — check for differences
1780                if te.oid != ie.oid || te.mode != ie.mode {
1781                    result.push(DiffEntry {
1782                        status: DiffStatus::Modified,
1783                        old_path: Some(path.clone()),
1784                        new_path: Some(path),
1785                        old_mode: format_mode(te.mode),
1786                        new_mode: format_mode(ie.mode),
1787                        old_oid: te.oid,
1788                        new_oid: ie.oid,
1789                        score: None,
1790                    });
1791                }
1792            }
1793            None => {
1794                // In index but not tree → added
1795                result.push(DiffEntry {
1796                    status: DiffStatus::Added,
1797                    old_path: None,
1798                    new_path: Some(path),
1799                    old_mode: "000000".to_owned(),
1800                    new_mode: format_mode(ie.mode),
1801                    old_oid: zero_oid(),
1802                    new_oid: ie.oid,
1803                    score: None,
1804                });
1805            }
1806        }
1807    }
1808
1809    for (path, (_, mode)) in &unmerged_modes {
1810        if stage0_paths.contains(path) {
1811            continue;
1812        }
1813        tree_map.remove(path.as_str());
1814        result.push(DiffEntry {
1815            status: DiffStatus::Unmerged,
1816            old_path: Some(path.clone()),
1817            new_path: Some(path.clone()),
1818            old_mode: "000000".to_owned(),
1819            new_mode: format_mode(*mode),
1820            old_oid: zero_oid(),
1821            new_oid: zero_oid(),
1822            score: None,
1823        });
1824    }
1825
1826    // Remaining tree entries not in index → deleted
1827    for (path, te) in tree_map {
1828        if ignore_submodules && te.mode == 0o160000 {
1829            continue;
1830        }
1831        result.push(DiffEntry {
1832            status: DiffStatus::Deleted,
1833            old_path: Some(path.to_owned()),
1834            new_path: None,
1835            old_mode: format_mode(te.mode),
1836            new_mode: "000000".to_owned(),
1837            old_oid: te.oid,
1838            new_oid: zero_oid(),
1839            score: None,
1840        });
1841    }
1842
1843    result.sort_by(|a, b| a.path().cmp(b.path()));
1844    Ok(result)
1845}
1846
1847// ── Index-to-worktree diff (unstaged changes) ───────────────────────
1848
1849/// Compare the index against the working tree.
1850///
1851/// This shows "unstaged" changes — modifications not yet staged.
1852///
1853/// Entries with [`IndexEntry::assume_unchanged`] or [`IndexEntry::skip_worktree`] are treated as
1854/// matching the work tree without examining the filesystem (Git `CE_VALID` / skip-worktree).
1855///
1856/// # Parameters
1857///
1858/// - `odb` — object database (for hashing worktree files).
1859/// - `index` — the current index.
1860/// - `work_tree` — path to the working tree root.
1861/// - `ignore_submodule_untracked` — when true, gitlink entries are not dirty solely from untracked
1862///   files inside the submodule (matches `git status -uno`).
1863/// - `simplify_gitlinks` — when true, nested gitlink entries only compare the submodule checkout
1864///   HEAD to the recorded OID (ignore dirty work trees inside nested submodules). Used when
1865///   computing `submodule_porcelain_flags` so untracked files under a nested submodule do not set
1866///   the parent submodule's `modified` bit (Git `DIRTY_SUBMODULE_MODIFIED`; t7506).
1867///
1868/// # Errors
1869///
1870/// Returns errors from I/O or hashing.
1871pub fn diff_index_to_worktree(
1872    odb: &Odb,
1873    index: &Index,
1874    work_tree: &Path,
1875    ignore_submodule_untracked: bool,
1876    simplify_gitlinks: bool,
1877) -> Result<Vec<DiffEntry>> {
1878    diff_index_to_worktree_with_options(
1879        odb,
1880        index,
1881        work_tree,
1882        DiffIndexToWorktreeOptions {
1883            ignore_submodule_untracked,
1884            simplify_gitlinks,
1885            ..DiffIndexToWorktreeOptions::default()
1886        },
1887    )
1888}
1889
1890/// Additional inputs for [`diff_index_to_worktree_with_options`].
1891#[derive(Debug, Clone, Copy, Default)]
1892pub struct DiffIndexToWorktreeOptions {
1893    /// Optional index mtime pair `(sec, nsec)` sampled when the index was read.
1894    ///
1895    /// When provided, entries with matching stat data are still considered dirty candidates if
1896    /// their recorded mtime is "racy" (at or after this timestamp), matching Git's
1897    /// `is_racy_timestamp` behavior.
1898    pub index_mtime: Option<(u32, u32)>,
1899    /// When true, gitlink entries are not dirty solely from untracked files inside the submodule.
1900    pub ignore_submodule_untracked: bool,
1901    /// When true, nested gitlink entries only compare the submodule checkout HEAD to the recorded OID.
1902    pub simplify_gitlinks: bool,
1903    /// When true, a populated gitlink checkout whose `.git` indirection cannot resolve to a HEAD
1904    /// is returned as an error instead of a normal modified gitlink.
1905    pub error_on_broken_gitlinks: bool,
1906}
1907
1908/// Compare the index against the working tree with optional racy-timestamp context.
1909///
1910/// This variant enables a stat-trust fast path: if an entry's stat tuple matches and the mode is
1911/// unchanged, the worktree blob hash is skipped unless the entry is racy relative to the supplied
1912/// index mtime.
1913///
1914/// # Parameters
1915///
1916/// - `odb` — object database (for hashing worktree files).
1917/// - `index` — the current index.
1918/// - `work_tree` — path to the working tree root.
1919/// - `options` — optional context for racy timestamp checks.
1920///
1921/// # Errors
1922///
1923/// Returns errors from I/O or hashing.
1924pub fn diff_index_to_worktree_with_options(
1925    odb: &Odb,
1926    index: &Index,
1927    work_tree: &Path,
1928    options: DiffIndexToWorktreeOptions,
1929) -> Result<Vec<DiffEntry>> {
1930    use crate::config::ConfigSet;
1931    use crate::crlf;
1932
1933    let ignore_submodule_untracked = options.ignore_submodule_untracked;
1934    let simplify_gitlinks = options.simplify_gitlinks;
1935
1936    let git_dir = work_tree.join(".git");
1937    let config = ConfigSet::load(Some(&git_dir), true).unwrap_or_else(|_| ConfigSet::new());
1938    let conv = crlf::ConversionConfig::from_config(&config);
1939    let attrs = crlf::load_gitattributes(work_tree);
1940
1941    let mut result = Vec::new();
1942    let mut unmerged_base: std::collections::BTreeMap<String, (u8, &IndexEntry)> =
1943        std::collections::BTreeMap::new();
1944
1945    // Cache of ancestor-directory symlink-ness so we stat each directory at most
1946    // once, rather than re-lstat'ing every ancestor of every index entry
1947    // (O(unique dirs) instead of O(files × depth)).
1948    let mut dir_symlinks = SymlinkDirCache::default();
1949
1950    for ie in &index.entries {
1951        if ie.stage() != 0 {
1952            let path = String::from_utf8_lossy(&ie.path).to_string();
1953            let rank = match ie.stage() {
1954                2 => 0u8,
1955                3 => 1u8,
1956                1 => 2u8,
1957                _ => 3u8,
1958            };
1959            match unmerged_base.get(&path) {
1960                Some((existing_rank, _)) if *existing_rank <= rank => {}
1961                _ => {
1962                    unmerged_base.insert(path, (rank, ie));
1963                }
1964            }
1965            continue;
1966        }
1967        // Sparse checkout: paths outside the cone are not expected on disk; `assume_unchanged`
1968        // is treated as clean without reading the filesystem (wt-status.c).
1969        if ie.skip_worktree() || ie.assume_unchanged() {
1970            continue;
1971        }
1972        // Use str slice directly to avoid allocation for path joining;
1973        // only allocate String if we need it for DiffEntry output.
1974        let path_str_ref = std::str::from_utf8(&ie.path).unwrap_or("");
1975        let is_intent_to_add = ie.intent_to_add();
1976
1977        // Gitlink entries (submodules): Git's `diff-index` reports `M` when the recorded
1978        // commit differs from the submodule checkout **or** when the submodule work tree is
1979        // dirty (staged/unstaged/untracked) even if HEAD still matches the gitlink. For the
1980        // latter case the "new" OID column is the null OID (see `git diff-index` / t7506).
1981        if ie.mode == 0o160000 {
1982            let sub_dir = work_tree.join(path_str_ref);
1983            let sub_head_oid = read_submodule_head_oid(&sub_dir);
1984            // A gitlink whose worktree directory is entirely absent is a deleted submodule. Git's
1985            // `check_removed` (diff-lib.c) `lstat`s the path first: a missing directory is reported
1986            // as a removal (`D`, new mode 000000), *before* the submodule "not checked out" special
1987            // case (which only applies when the directory exists). An empty directory that exists is
1988            // a placeholder and stays unchanged. Skipped when `simplify_gitlinks` so callers that
1989            // only compare recorded HEADs keep their behaviour. (t4060 #50/#51.)
1990            if !simplify_gitlinks && sub_head_oid.is_none() && !sub_dir.exists() {
1991                let path_owned = path_str_ref.to_owned();
1992                result.push(DiffEntry {
1993                    status: DiffStatus::Deleted,
1994                    old_path: Some(path_owned.clone()),
1995                    new_path: Some(path_owned),
1996                    old_mode: format_mode(ie.mode),
1997                    new_mode: "000000".to_owned(),
1998                    old_oid: ie.oid,
1999                    new_oid: zero_oid(),
2000                    score: None,
2001                });
2002                continue;
2003            }
2004            let ref_matches = if let Some(oid) = sub_head_oid {
2005                oid == ie.oid
2006            } else {
2007                let is_placeholder = submodule_worktree_is_unpopulated_placeholder(&sub_dir);
2008                if options.error_on_broken_gitlinks
2009                    && !is_placeholder
2010                    && submodule_embedded_git_dir(&sub_dir).is_some()
2011                {
2012                    return Err(Error::ConfigError(format!(
2013                        "could not read submodule HEAD for '{path_str_ref}'"
2014                    )));
2015                }
2016                is_placeholder
2017            };
2018            if simplify_gitlinks {
2019                if !ref_matches {
2020                    let path_owned = path_str_ref.to_owned();
2021                    let new_oid = sub_head_oid.unwrap_or_else(zero_oid);
2022                    result.push(DiffEntry {
2023                        status: DiffStatus::Modified,
2024                        old_path: Some(path_owned.clone()),
2025                        new_path: Some(path_owned),
2026                        old_mode: format_mode(ie.mode),
2027                        new_mode: format_mode(ie.mode),
2028                        old_oid: ie.oid,
2029                        new_oid,
2030                        score: None,
2031                    });
2032                }
2033                continue;
2034            }
2035            // A populated submodule whose HEAD points at a commit object that is missing from its
2036            // own object store is corrupt. Git's `is_submodule_modified` shells `git status` into
2037            // the submodule, which fails, and Git aborts the surrounding status/diff. Mirror that:
2038            // a broken submodule is a hard error rather than a silently-clean gitlink (t5526 #38).
2039            if sub_head_oid.is_some() && submodule_head_object_broken(&sub_dir) {
2040                return Err(Error::ConfigError(format!(
2041                    "'git status --porcelain=2' failed in submodule {path_str_ref}"
2042                )));
2043            }
2044            let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
2045            if ignore_submodule_untracked {
2046                flags.untracked = false;
2047            }
2048            let inner_dirty = flags.modified || flags.untracked;
2049            if !ref_matches || inner_dirty {
2050                let path_owned = path_str_ref.to_owned();
2051                let new_oid = if !ref_matches {
2052                    sub_head_oid.unwrap_or_else(zero_oid)
2053                } else {
2054                    zero_oid()
2055                };
2056                result.push(DiffEntry {
2057                    status: DiffStatus::Modified,
2058                    old_path: Some(path_owned.clone()),
2059                    new_path: Some(path_owned),
2060                    old_mode: format_mode(ie.mode),
2061                    new_mode: format_mode(ie.mode),
2062                    old_oid: ie.oid,
2063                    new_oid,
2064                    score: None,
2065                });
2066            }
2067            continue;
2068        }
2069
2070        let file_path = work_tree.join(path_str_ref);
2071
2072        if is_intent_to_add {
2073            match fs::symlink_metadata(&file_path) {
2074                Ok(meta) => {
2075                    let file_attrs = crlf::get_file_attrs(&attrs, path_str_ref, false, &config);
2076                    let worktree_oid = hash_worktree_file(
2077                        odb,
2078                        &file_path,
2079                        &meta,
2080                        &conv,
2081                        &file_attrs,
2082                        path_str_ref,
2083                        None,
2084                    )?;
2085                    let worktree_mode = mode_from_metadata(&meta);
2086                    result.push(DiffEntry {
2087                        status: DiffStatus::Added,
2088                        old_path: None,
2089                        new_path: Some(path_str_ref.to_owned()),
2090                        old_mode: "000000".to_owned(),
2091                        new_mode: format_mode(worktree_mode),
2092                        // `ita_invisible_in_index`: null OID on the index side for patch output
2093                        // (`index 0000000..`, t2203); index entry still stores the empty blob.
2094                        old_oid: zero_oid(),
2095                        new_oid: worktree_oid,
2096                        score: None,
2097                    });
2098                }
2099                Err(e)
2100                    if e.kind() == std::io::ErrorKind::NotFound
2101                        || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
2102                {
2103                    result.push(DiffEntry {
2104                        status: DiffStatus::Deleted,
2105                        old_path: Some(path_str_ref.to_owned()),
2106                        new_path: None,
2107                        old_mode: format_mode(ie.mode),
2108                        new_mode: "000000".to_owned(),
2109                        old_oid: ie.oid,
2110                        new_oid: zero_oid(),
2111                        score: None,
2112                    });
2113                }
2114                Err(e) => return Err(Error::Io(e)),
2115            }
2116            continue;
2117        }
2118
2119        // If any parent component of the path is a symlink, the file is effectively
2120        // deleted from the working tree (a symlink replaced a directory).
2121        if dir_symlinks.has_symlink_in_path(work_tree, path_str_ref) {
2122            result.push(DiffEntry {
2123                status: DiffStatus::Deleted,
2124                old_path: Some(path_str_ref.to_owned()),
2125                new_path: None,
2126                old_mode: format_mode(ie.mode),
2127                new_mode: "000000".to_owned(),
2128                old_oid: ie.oid,
2129                new_oid: zero_oid(),
2130                score: None,
2131            });
2132            continue;
2133        }
2134
2135        match fs::symlink_metadata(&file_path) {
2136            Ok(meta) if meta.is_dir() => {
2137                // A directory exists where the index expects a file. A populated submodule
2138                // checkout (`.git` present) is a blob→gitlink typechange with the submodule HEAD on
2139                // the new side (raw output re-zeros it); otherwise the indexed file is effectively
2140                // deleted. See t4041/t4060 #13.
2141                if file_path.join(".git").exists() {
2142                    let head = read_submodule_head_oid(&file_path).unwrap_or_else(zero_oid);
2143                    let path_owned = path_str_ref.to_owned();
2144                    result.push(DiffEntry {
2145                        status: DiffStatus::TypeChanged,
2146                        old_path: Some(path_owned.clone()),
2147                        new_path: Some(path_owned),
2148                        old_mode: format_mode(ie.mode),
2149                        new_mode: format_mode(0o160000),
2150                        old_oid: ie.oid,
2151                        new_oid: head,
2152                        score: None,
2153                    });
2154                    continue;
2155                }
2156                result.push(DiffEntry {
2157                    status: DiffStatus::Deleted,
2158                    old_path: Some(path_str_ref.to_owned()),
2159                    new_path: None,
2160                    old_mode: format_mode(ie.mode),
2161                    new_mode: String::new(),
2162                    old_oid: ie.oid,
2163                    new_oid: zero_oid(),
2164                    score: None,
2165                });
2166            }
2167            Ok(meta) => {
2168                let worktree_mode = mode_from_metadata(&meta);
2169                let stat_same = stat_matches(ie, &meta);
2170                // Mode-only change: stat still matches the index entry but executable bit differs.
2171                if stat_same && worktree_mode != ie.mode {
2172                    let path_owned = path_str_ref.to_owned();
2173                    result.push(DiffEntry {
2174                        status: DiffStatus::Modified,
2175                        old_path: Some(path_owned.clone()),
2176                        new_path: Some(path_owned),
2177                        old_mode: format_mode(ie.mode),
2178                        new_mode: format_mode(worktree_mode),
2179                        old_oid: ie.oid,
2180                        new_oid: ie.oid,
2181                        score: None,
2182                    });
2183                    continue;
2184                }
2185
2186                // Fast path: unchanged stat + unchanged mode + non-racy timestamp means this entry
2187                // is clean without re-hashing blob data.
2188                if stat_same && worktree_mode == ie.mode && !entry_is_racy(ie, options.index_mtime) {
2189                    continue;
2190                }
2191
2192                // Hash the worktree blob for uncertain/racy entries.
2193                let file_attrs = crlf::get_file_attrs(&attrs, path_str_ref, false, &config);
2194                let worktree_oid = hash_worktree_file(
2195                    odb,
2196                    &file_path,
2197                    &meta,
2198                    &conv,
2199                    &file_attrs,
2200                    path_str_ref,
2201                    Some(ie),
2202                )?;
2203
2204                // If clean conversion disagrees with the index but raw bytes match the
2205                // blob (e.g. mixed line endings committed with autocrlf off), Git reports
2206                // no diff (t0020: touch + git diff --exit-code).
2207                let mut eff_oid = worktree_oid;
2208                if eff_oid != ie.oid {
2209                    if let Ok(raw) = fs::read(&file_path) {
2210                        let raw_oid = Odb::hash_object_data(ObjectKind::Blob, &raw);
2211                        if raw_oid == ie.oid {
2212                            eff_oid = ie.oid;
2213                        }
2214                    }
2215                }
2216
2217                if eff_oid != ie.oid || worktree_mode != ie.mode {
2218                    let path_owned = path_str_ref.to_owned();
2219                    result.push(DiffEntry {
2220                        status: DiffStatus::Modified,
2221                        old_path: Some(path_owned.clone()),
2222                        new_path: Some(path_owned),
2223                        old_mode: format_mode(ie.mode),
2224                        new_mode: format_mode(worktree_mode),
2225                        old_oid: ie.oid,
2226                        new_oid: eff_oid,
2227                    score: None,
2228                    });
2229                }
2230            }
2231            Err(e) if e.kind() == std::io::ErrorKind::NotFound
2232                || e.raw_os_error() == Some(20) /* ENOTDIR */ => {
2233                // File deleted from working tree (or parent replaced by a file)
2234                result.push(DiffEntry {
2235                    status: DiffStatus::Deleted,
2236                    old_path: Some(path_str_ref.to_owned()),
2237                    new_path: None,
2238                    old_mode: format_mode(ie.mode),
2239                    new_mode: "000000".to_owned(),
2240                    old_oid: ie.oid,
2241                    new_oid: zero_oid(),
2242                    score: None,
2243                });
2244            }
2245            Err(e) => return Err(Error::Io(e)),
2246        }
2247    }
2248
2249    for (path, (_, base_entry)) in unmerged_base {
2250        let file_path = work_tree.join(&path);
2251        let wt_meta = match fs::symlink_metadata(&file_path) {
2252            Ok(meta) => Some(meta),
2253            Err(e)
2254                if e.kind() == std::io::ErrorKind::NotFound
2255                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
2256            {
2257                None
2258            }
2259            Err(e) => return Err(Error::Io(e)),
2260        };
2261
2262        let new_mode = wt_meta.as_ref().map_or_else(
2263            || "000000".to_owned(),
2264            |meta| format_mode(mode_from_metadata(meta)),
2265        );
2266        result.push(DiffEntry {
2267            status: DiffStatus::Unmerged,
2268            old_path: Some(path.clone()),
2269            new_path: Some(path.clone()),
2270            old_mode: "000000".to_owned(),
2271            new_mode,
2272            old_oid: zero_oid(),
2273            new_oid: zero_oid(),
2274            score: None,
2275        });
2276
2277        if let Some(meta) = wt_meta {
2278            let file_attrs = crlf::get_file_attrs(&attrs, &path, false, &config);
2279            let wt_oid = hash_worktree_file(
2280                odb,
2281                &file_path,
2282                &meta,
2283                &conv,
2284                &file_attrs,
2285                &path,
2286                Some(base_entry),
2287            )?;
2288            let wt_mode = mode_from_metadata(&meta);
2289            if wt_oid != base_entry.oid || wt_mode != base_entry.mode {
2290                result.push(DiffEntry {
2291                    status: DiffStatus::Modified,
2292                    old_path: Some(path.clone()),
2293                    new_path: Some(path),
2294                    old_mode: format_mode(base_entry.mode),
2295                    new_mode: format_mode(wt_mode),
2296                    old_oid: base_entry.oid,
2297                    new_oid: wt_oid,
2298                    score: None,
2299                });
2300            }
2301        }
2302    }
2303
2304    Ok(result)
2305}
2306
2307/// Memoized cache of which ancestor directories are symlinks, so each directory
2308/// is lstat'd at most once per `diff_index_to_worktree` call.
2309#[derive(Default)]
2310struct SymlinkDirCache {
2311    /// Relative dir prefixes confirmed to be symlinks.
2312    symlink: std::collections::HashSet<String>,
2313    /// Relative dir prefixes confirmed not to be symlinks.
2314    plain: std::collections::HashSet<String>,
2315}
2316
2317impl SymlinkDirCache {
2318    /// Whether any parent component of `rel_path` is a symlink.
2319    fn has_symlink_in_path(&mut self, work_tree: &Path, rel_path: &str) -> bool {
2320        let components: Vec<&str> = rel_path.split('/').collect();
2321        let mut prefix = String::new();
2322        // Check every ancestor directory (all components except the file itself).
2323        for component in &components[..components.len().saturating_sub(1)] {
2324            if !prefix.is_empty() {
2325                prefix.push('/');
2326            }
2327            prefix.push_str(component);
2328            if self.symlink.contains(&prefix) {
2329                return true;
2330            }
2331            if self.plain.contains(&prefix) {
2332                continue;
2333            }
2334            match fs::symlink_metadata(work_tree.join(&prefix)) {
2335                Ok(meta) if meta.file_type().is_symlink() => {
2336                    self.symlink.insert(prefix.clone());
2337                    return true;
2338                }
2339                _ => {
2340                    self.plain.insert(prefix.clone());
2341                }
2342            }
2343        }
2344        false
2345    }
2346}
2347
2348fn entry_is_racy(ie: &IndexEntry, index_mtime: Option<(u32, u32)>) -> bool {
2349    let Some((index_mtime_sec, index_mtime_nsec)) = index_mtime else {
2350        return false;
2351    };
2352    if index_mtime_sec == 0 {
2353        return false;
2354    }
2355    index_mtime_sec < ie.mtime_sec
2356        || (index_mtime_sec == ie.mtime_sec && index_mtime_nsec <= ie.mtime_nsec)
2357}
2358
2359/// Quick stat check: does the index entry's cached stat data match the file?
2360/// Returns true when the file at `ie`'s path differs from the index entry (mode or blob).
2361///
2362/// Used by commands such as `git mv` to detect "dirty" paths under sparse checkout.
2363/// Symlinks and submodules are compared in a Git-compatible way.
2364///
2365/// `ignore_submodule_untracked` mirrors [`diff_index_to_worktree`]'s same flag for gitlinks.
2366pub fn worktree_differs_from_index_entry(
2367    odb: &Odb,
2368    work_tree: &Path,
2369    ie: &IndexEntry,
2370    ignore_submodule_untracked: bool,
2371) -> Result<bool> {
2372    use crate::config::ConfigSet;
2373    use crate::crlf;
2374
2375    let path_str_ref = std::str::from_utf8(&ie.path).unwrap_or("");
2376    let file_path = work_tree.join(path_str_ref);
2377
2378    if ie.mode == 0o160000 {
2379        let sub_head_oid = read_submodule_head(&file_path);
2380        let ref_matches = match sub_head_oid {
2381            Some(oid) => oid == ie.oid,
2382            None => submodule_worktree_is_unpopulated_placeholder(&file_path),
2383        };
2384        let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
2385        if ignore_submodule_untracked {
2386            flags.untracked = false;
2387        }
2388        return Ok(!ref_matches || flags.modified || flags.untracked);
2389    }
2390
2391    let meta = match fs::symlink_metadata(&file_path) {
2392        Ok(m) => m,
2393        Err(e)
2394            if e.kind() == std::io::ErrorKind::NotFound
2395                || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
2396        {
2397            return Ok(true);
2398        }
2399        Err(e) => return Err(Error::Io(e)),
2400    };
2401
2402    if meta.is_dir() {
2403        return Ok(true);
2404    }
2405
2406    let worktree_mode = mode_from_metadata(&meta);
2407    if worktree_mode != ie.mode {
2408        return Ok(true);
2409    }
2410
2411    let git_dir = work_tree.join(".git");
2412    let config = ConfigSet::load(Some(&git_dir), true).unwrap_or_else(|_| ConfigSet::new());
2413    let conv = crlf::ConversionConfig::from_config(&config);
2414    let attrs = crlf::load_gitattributes(work_tree);
2415    let file_attrs = crlf::get_file_attrs(&attrs, path_str_ref, false, &config);
2416    let worktree_oid = hash_worktree_file(
2417        odb,
2418        &file_path,
2419        &meta,
2420        &conv,
2421        &file_attrs,
2422        path_str_ref,
2423        Some(ie),
2424    )?;
2425
2426    let mut eff_oid = worktree_oid;
2427    if eff_oid != ie.oid {
2428        if let Ok(raw) = fs::read(&file_path) {
2429            let raw_oid = Odb::hash_object_data(ObjectKind::Blob, &raw);
2430            if raw_oid == ie.oid {
2431                eff_oid = ie.oid;
2432            }
2433        }
2434    }
2435
2436    Ok(eff_oid != ie.oid)
2437}
2438
2439pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
2440    // Compare size
2441    if meta.len() as u32 != ie.size {
2442        return false;
2443    }
2444    #[cfg(unix)]
2445    {
2446        use std::os::unix::fs::MetadataExt;
2447        // Compare mtime (seconds + nanoseconds)
2448        if meta.mtime() as u32 != ie.mtime_sec {
2449            return false;
2450        }
2451        if meta.mtime_nsec() as u32 != ie.mtime_nsec {
2452            return false;
2453        }
2454        // Compare ctime (seconds + nanoseconds)
2455        if meta.ctime() as u32 != ie.ctime_sec {
2456            return false;
2457        }
2458        if meta.ctime_nsec() as u32 != ie.ctime_nsec {
2459            return false;
2460        }
2461        // Compare inode. Device (`st_dev`) is deliberately NOT compared: Git
2462        // leaves `USE_STDEV` off by default because device numbers aren't stable
2463        // (across remounts, and some index writers store 0), so comparing it
2464        // forces a needless re-hash of the whole tree. Match Git and ignore it.
2465        if meta.ino() as u32 != ie.ino {
2466            return false;
2467        }
2468    }
2469    #[cfg(not(unix))]
2470    {
2471        use std::time::UNIX_EPOCH;
2472        if let Ok(mtime) = meta.modified() {
2473            if let Ok(dur) = mtime.duration_since(UNIX_EPOCH) {
2474                if dur.as_secs() as u32 != ie.mtime_sec {
2475                    return false;
2476                }
2477                if dur.subsec_nanos() != ie.mtime_nsec {
2478                    return false;
2479                }
2480            }
2481        }
2482    }
2483    true
2484}
2485
2486/// Refresh cached stat data for stage-0 file/symlink entries whose worktree content still matches
2487/// the recorded OID but whose on-disk stat went stale.
2488///
2489/// This mirrors Git's `refresh_index` / `refresh_cache_ent`: an entry is only marked clean (stat
2490/// adopted from the worktree) after its content is re-verified against the index OID. A genuinely
2491/// modified entry keeps its stale stat so `diff-files` / `status` continue to report it. Operations
2492/// that rewrite the worktree (`status`, `reset --mixed`, `stash`) call this before writing the
2493/// index so a subsequent `git diff-files` sees refreshed entries as clean.
2494///
2495/// Gitlinks, sparse (`skip_worktree`), `assume_unchanged` and intent-to-add entries are skipped.
2496/// The blob comparison is a raw-content hash, so a CRLF-smudged match is conservatively missed
2497/// (the entry simply stays stat-dirty and is re-hashed next time — never the reverse).
2498///
2499/// `index_mtime` is the on-disk index file's `(mtime_sec, mtime_nsec)` (see
2500/// `entry_is_racy` / Git `is_racy_timestamp`); pass `None` when unknown — racy detection is
2501/// then skipped, which is conservative for tree-built indexes whose zeroed stat never matches.
2502///
2503/// Returns `true` when at least one entry was refreshed or invalidated, so callers can write
2504/// the index opportunistically (Git only persists a refresh that changed something).
2505pub fn refresh_index_stat_content_verified(
2506    index: &mut Index,
2507    work_tree: &Path,
2508    index_mtime: Option<(u32, u32)>,
2509) -> bool {
2510    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
2511    let mut changed = false;
2512    for ie in &mut index.entries {
2513        if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
2514            continue;
2515        }
2516        if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
2517            continue;
2518        }
2519        let Ok(path) = std::str::from_utf8(&ie.path) else {
2520            continue;
2521        };
2522        let abs = work_tree.join(path);
2523        let Ok(meta) = fs::symlink_metadata(&abs) else {
2524            continue;
2525        };
2526        if stat_matches(ie, &meta) {
2527            // Git `ie_match_stat`: a clean stat is trusted without reading the file unless the
2528            // entry is racy (written within the index's own mtime). Only then re-verify content;
2529            // stat can be refreshed from the work tree without matching the indexed blob (e.g.
2530            // after merge stat refresh while local edits remain) — invalidate so diff/status
2531            // re-hash.
2532            if entry_is_racy(ie, index_mtime)
2533                && !worktree_content_matches_index_oid(ie, &abs, &meta)
2534            {
2535                invalidate_index_stat_cache(ie);
2536                changed = true;
2537            }
2538            continue;
2539        }
2540        if !worktree_content_matches_index_oid(ie, &abs, &meta) {
2541            continue;
2542        }
2543        let refreshed = crate::index::entry_from_metadata(&meta, &ie.path, ie.oid, ie.mode);
2544        ie.ctime_sec = refreshed.ctime_sec;
2545        ie.ctime_nsec = refreshed.ctime_nsec;
2546        ie.mtime_sec = refreshed.mtime_sec;
2547        ie.mtime_nsec = refreshed.mtime_nsec;
2548        ie.dev = refreshed.dev;
2549        ie.ino = refreshed.ino;
2550        ie.uid = refreshed.uid;
2551        ie.gid = refreshed.gid;
2552        ie.size = refreshed.size;
2553        changed = true;
2554    }
2555    changed
2556}
2557
2558/// Symlink target as the byte string Git hashes for the blob OID.
2559///
2560/// On Unix the raw `OsStr` bytes are used verbatim. On Windows `OsStr` is WTF-8
2561/// and has no stable byte view, so the lossy UTF-8 form is used (symlink targets
2562/// in Git trees are UTF-8 in practice).
2563fn symlink_target_bytes(target: &Path) -> Vec<u8> {
2564    #[cfg(unix)]
2565    {
2566        use std::os::unix::ffi::OsStrExt as _;
2567        target.as_os_str().as_bytes().to_vec()
2568    }
2569    #[cfg(not(unix))]
2570    {
2571        target.as_os_str().to_string_lossy().as_bytes().to_vec()
2572    }
2573}
2574
2575/// Whether the work tree blob at `abs` matches the index entry OID (raw bytes, no CRLF smudge).
2576fn worktree_content_matches_index_oid(ie: &IndexEntry, abs: &Path, meta: &fs::Metadata) -> bool {
2577    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
2578    if ie.mode == MODE_SYMLINK {
2579        if !meta.file_type().is_symlink() {
2580            return false;
2581        }
2582        fs::read_link(abs)
2583            .map(|t| Odb::hash_object_data(ObjectKind::Blob, &symlink_target_bytes(&t)) == ie.oid)
2584            .unwrap_or(false)
2585    } else if ie.mode == MODE_REGULAR || ie.mode == MODE_EXECUTABLE {
2586        if !meta.file_type().is_file() {
2587            return false;
2588        }
2589        fs::read(abs)
2590            .map(|bytes| Odb::hash_object_data(ObjectKind::Blob, &bytes) == ie.oid)
2591            .unwrap_or(false)
2592    } else {
2593        false
2594    }
2595}
2596
2597/// Clear cached stat fields so the next diff/status pass re-reads the work tree.
2598fn invalidate_index_stat_cache(ie: &mut IndexEntry) {
2599    ie.ctime_sec = 0;
2600    ie.ctime_nsec = 0;
2601    ie.mtime_sec = 0;
2602    ie.mtime_nsec = 0;
2603    ie.dev = 0;
2604    ie.ino = 0;
2605    ie.size = 0;
2606}
2607
2608pub fn hash_worktree_file(
2609    odb: &Odb,
2610    path: &Path,
2611    meta: &fs::Metadata,
2612    conv: &crate::crlf::ConversionConfig,
2613    file_attrs: &crate::crlf::FileAttrs,
2614    rel_path: &str,
2615    index_entry: Option<&IndexEntry>,
2616) -> Result<ObjectId> {
2617    let prior_blob: Option<Vec<u8>> = index_entry
2618        .filter(|e| e.oid != zero_oid())
2619        .and_then(|e| odb.read(&e.oid).ok().map(|o| o.data));
2620    let data = if meta.file_type().is_symlink() {
2621        // For symlinks, hash the target path
2622        let target = fs::read_link(path)?;
2623        target.to_string_lossy().into_owned().into_bytes()
2624    } else if meta.is_dir() {
2625        // `read()` on a directory fails with EISDIR; unmerged paths may leave an empty
2626        // placeholder directory (e.g. t4027 combined submodule conflict).
2627        Vec::new()
2628    } else {
2629        let raw = fs::read(path)?;
2630        // Apply clean conversion (CRLF→LF) so hash matches index blob.
2631        // Do not run safecrlf here: diff/commit use this for hashing and must not print warnings.
2632        let opts = crate::crlf::ConvertToGitOpts {
2633            index_blob: prior_blob.as_deref(),
2634            renormalize: false,
2635            check_safecrlf: false,
2636        };
2637        crate::crlf::convert_to_git_with_opts(&raw, rel_path, conv, file_attrs, opts).unwrap_or(raw)
2638    };
2639
2640    Ok(Odb::hash_object_data(ObjectKind::Blob, &data))
2641}
2642
2643/// Derive a Git file mode from filesystem metadata.
2644pub fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
2645    if meta.file_type().is_symlink() {
2646        0o120000
2647    } else {
2648        #[cfg(unix)]
2649        {
2650            if meta.mode() & 0o111 != 0 {
2651                return 0o100755;
2652            }
2653        }
2654        0o100644
2655    }
2656}
2657
2658/// Compare a tree against the working tree.
2659///
2660/// Shows changes from `tree_oid` to the current working directory state.
2661/// Files tracked in the index but not in the tree are shown as Added.
2662/// Files in the tree but missing from the working tree are shown as Deleted.
2663///
2664/// # Parameters
2665///
2666/// - `odb` — object database.
2667/// - `tree_oid` — the tree to compare against (`None` for empty tree).
2668/// - `work_tree` — path to the working tree root.
2669/// - `index` — current index (used to discover new tracked files not in tree).
2670///
2671/// # Errors
2672///
2673/// Returns errors from ODB reads or I/O.
2674pub fn diff_tree_to_worktree(
2675    odb: &Odb,
2676    tree_oid: Option<&ObjectId>,
2677    work_tree: &Path,
2678    index: &Index,
2679) -> Result<Vec<DiffEntry>> {
2680    use crate::config::ConfigSet;
2681    use crate::crlf;
2682
2683    let git_dir = work_tree.join(".git");
2684    let config = ConfigSet::load(Some(&git_dir), true).unwrap_or_else(|_| ConfigSet::new());
2685    let conv = crlf::ConversionConfig::from_config(&config);
2686    let attrs = crlf::load_gitattributes(work_tree);
2687
2688    // Flatten the tree into a BTreeMap keyed by path
2689    let tree_flat = match tree_oid {
2690        Some(oid) => flatten_tree(odb, oid, "")?,
2691        None => Vec::new(),
2692    };
2693    let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
2694        tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
2695
2696    // Build index lookup: path → &IndexEntry (stage 0 only)
2697    let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
2698        std::collections::BTreeMap::new();
2699    let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2700    let mut stage0_paths: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
2701    for ie in &index.entries {
2702        if ie.stage() != 0 {
2703            continue;
2704        }
2705        let path = String::from_utf8_lossy(&ie.path).to_string();
2706        index_entries.insert(&ie.path, ie);
2707        index_paths.insert(path);
2708        stage0_paths.insert(ie.path.clone());
2709    }
2710
2711    // Paths with only unmerged stages (1–3) and no stage 0 — `git diff <rev>` must still list them
2712    // so combined `diff --cc` conflict hunks can be emitted (`t4108-apply-threeway`).
2713    let mut unmerged_only_paths: std::collections::BTreeSet<String> =
2714        std::collections::BTreeSet::new();
2715    for ie in &index.entries {
2716        if !(1..=3).contains(&ie.stage()) {
2717            continue;
2718        }
2719        if stage0_paths.contains(&ie.path) {
2720            continue;
2721        }
2722        unmerged_only_paths.insert(String::from_utf8_lossy(&ie.path).into_owned());
2723    }
2724
2725    // Union of tree paths + index paths
2726    let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2727    all_paths.extend(tree_map.keys().cloned());
2728    all_paths.extend(index_paths.iter().cloned());
2729    all_paths.extend(unmerged_only_paths.iter().cloned());
2730
2731    let mut result = Vec::new();
2732
2733    for path in &all_paths {
2734        if index_entries
2735            .get(path.as_bytes())
2736            .is_some_and(|ie| ie.skip_worktree())
2737        {
2738            // Sparse checkout: `git diff <rev>` does not report tree↔worktree drift for
2739            // skip-worktree paths (they are outside the sparse cone). Matches t7012 stash flow.
2740            continue;
2741        }
2742
2743        let tree_entry = tree_map.get(path.as_str());
2744
2745        // Gitlink entries (submodules) — compare HEAD commit, not file content.
2746        let is_gitlink = tree_entry.is_some_and(|te| te.mode == 0o160000)
2747            || index_entries
2748                .get(path.as_bytes())
2749                .is_some_and(|ie| ie.mode == 0o160000);
2750        if is_gitlink {
2751            let sub_dir = work_tree.join(path);
2752            let index_gitlink_oid = index_entries
2753                .get(path.as_bytes())
2754                .filter(|ie| ie.mode == 0o160000)
2755                .map(|ie| ie.oid);
2756            match (tree_entry, index_gitlink_oid) {
2757                (Some(te), _) => {
2758                    let sub_head = read_submodule_head_oid(&sub_dir);
2759                    // A gitlink whose worktree directory no longer exists is a deleted submodule:
2760                    // Git's `diff-lib.c` reports it as a deletion (status `D`, new mode 000000) and
2761                    // `--submodule` renders the `(submodule deleted)` summary (t4041 #46).
2762                    if sub_head.is_none() && !sub_dir.exists() {
2763                        result.push(DiffEntry {
2764                            status: DiffStatus::Deleted,
2765                            old_path: Some(path.clone()),
2766                            new_path: Some(path.clone()),
2767                            old_mode: format_mode(te.mode),
2768                            new_mode: "000000".to_string(),
2769                            old_oid: te.oid,
2770                            new_oid: zero_oid(),
2771                            score: None,
2772                        });
2773                        continue;
2774                    }
2775                    let index_matches_tree = index_gitlink_oid.is_some_and(|oid| oid == te.oid);
2776                    let head_differs = sub_head.as_ref() != Some(&te.oid);
2777                    let dirty_while_aligned = index_matches_tree
2778                        && !head_differs
2779                        && submodule_has_dirty_worktree_for_super_diff(work_tree, path, &te.oid);
2780                    if head_differs || dirty_while_aligned {
2781                        // Raw `git diff <tree>` lines use a null OID on the worktree side when the
2782                        // checked-out submodule HEAD differs from the tree's gitlink; patch output
2783                        // still resolves the real commit from the submodule directory.
2784                        let new_oid = if head_differs { zero_oid() } else { te.oid };
2785                        result.push(DiffEntry {
2786                            status: DiffStatus::Modified,
2787                            old_path: Some(path.clone()),
2788                            new_path: Some(path.clone()),
2789                            old_mode: format_mode(te.mode),
2790                            new_mode: format_mode(te.mode),
2791                            old_oid: te.oid,
2792                            new_oid,
2793                            score: None,
2794                        });
2795                    }
2796                }
2797                (None, Some(idx_oid)) => {
2798                    // Gitlink staged in the index but absent from the tree: a new submodule. Git
2799                    // reports it as an addition (status `A`) with the index gitlink on the new side,
2800                    // so `--submodule` renders `(new submodule)` (t4041 #46). The patch renderer
2801                    // resolves the real commit from the index OID / submodule HEAD.
2802                    let new_oid = read_submodule_head_oid(&sub_dir).unwrap_or(idx_oid);
2803                    result.push(DiffEntry {
2804                        status: DiffStatus::Added,
2805                        old_path: Some(path.clone()),
2806                        new_path: Some(path.clone()),
2807                        old_mode: "000000".to_string(),
2808                        new_mode: format_mode(0o160000),
2809                        old_oid: zero_oid(),
2810                        new_oid,
2811                        score: None,
2812                    });
2813                }
2814                (None, None) => {}
2815            }
2816            continue;
2817        }
2818
2819        let file_path = work_tree.join(path);
2820
2821        let wt_meta = match fs::symlink_metadata(&file_path) {
2822            Ok(m) => Some(m),
2823            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
2824            Err(e) => return Err(Error::Io(e)),
2825        };
2826
2827        if unmerged_only_paths.contains(path) {
2828            if let (Some(te), Some(meta)) = (tree_entry, wt_meta.as_ref()) {
2829                let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
2830                let wt_oid =
2831                    hash_worktree_file(odb, &file_path, meta, &conv, &file_attrs, path, None)?;
2832                let wt_mode = mode_from_metadata(meta);
2833                if wt_oid != te.oid || wt_mode != te.mode {
2834                    result.push(DiffEntry {
2835                        status: DiffStatus::Modified,
2836                        old_path: Some(path.clone()),
2837                        new_path: Some(path.clone()),
2838                        old_mode: format_mode(te.mode),
2839                        new_mode: format_mode(wt_mode),
2840                        old_oid: te.oid,
2841                        new_oid: wt_oid,
2842                        score: None,
2843                    });
2844                }
2845            }
2846            continue;
2847        }
2848
2849        match (tree_entry, wt_meta) {
2850            (Some(te), Some(ref meta)) => {
2851                let wt_mode = mode_from_metadata(meta);
2852                let Some(ie) = index_entries.get(path.as_bytes()) else {
2853                    continue;
2854                };
2855
2856                let index_matches_tree = ie.oid == te.oid && ie.mode == te.mode;
2857
2858                // Fully clean: index matches `HEAD`, worktree matches index, stat cache fresh.
2859                if index_matches_tree && wt_mode == te.mode && stat_matches(ie, meta) {
2860                    continue;
2861                }
2862
2863                let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
2864                let idx_ent = index_entries.get(path.as_bytes()).copied();
2865
2866                // Staged mode (same blob as `HEAD`, different mode recorded in the index).
2867                if ie.oid == te.oid && ie.mode != te.mode {
2868                    result.push(DiffEntry {
2869                        status: DiffStatus::Modified,
2870                        old_path: Some(path.clone()),
2871                        new_path: Some(path.clone()),
2872                        old_mode: format_mode(te.mode),
2873                        new_mode: format_mode(ie.mode),
2874                        old_oid: te.oid,
2875                        new_oid: te.oid,
2876                        score: None,
2877                    });
2878                    continue;
2879                }
2880
2881                // Index still matches `HEAD`: only unstaged worktree drift (content and/or
2882                // worktree-only exec bit when `update-index` was not run — t4049 harness).
2883                if index_matches_tree {
2884                    let wt_oid = hash_worktree_file(
2885                        odb,
2886                        &file_path,
2887                        meta,
2888                        &conv,
2889                        &file_attrs,
2890                        path,
2891                        idx_ent,
2892                    )?;
2893                    let mut eff_oid = wt_oid;
2894                    if eff_oid != te.oid {
2895                        if let Ok(raw) = fs::read(&file_path) {
2896                            let raw_oid = Odb::hash_object_data(ObjectKind::Blob, &raw);
2897                            if raw_oid == te.oid {
2898                                eff_oid = te.oid;
2899                            }
2900                        }
2901                    }
2902                    if eff_oid != te.oid {
2903                        result.push(DiffEntry {
2904                            status: DiffStatus::Modified,
2905                            old_path: Some(path.clone()),
2906                            new_path: Some(path.clone()),
2907                            old_mode: format_mode(te.mode),
2908                            new_mode: format_mode(wt_mode),
2909                            old_oid: te.oid,
2910                            new_oid: eff_oid,
2911                            score: None,
2912                        });
2913                    } else if wt_mode != te.mode {
2914                        result.push(DiffEntry {
2915                            status: DiffStatus::Modified,
2916                            old_path: Some(path.clone()),
2917                            new_path: Some(path.clone()),
2918                            old_mode: format_mode(te.mode),
2919                            new_mode: format_mode(wt_mode),
2920                            old_oid: te.oid,
2921                            new_oid: te.oid,
2922                            score: None,
2923                        });
2924                    }
2925                    continue;
2926                }
2927
2928                // Staged content (and possibly mode): `git diff <rev>` is tree vs working tree.
2929                let wt_oid =
2930                    hash_worktree_file(odb, &file_path, meta, &conv, &file_attrs, path, idx_ent)?;
2931                let mut eff_oid = wt_oid;
2932                if eff_oid != te.oid {
2933                    if let Ok(raw) = fs::read(&file_path) {
2934                        let raw_oid = Odb::hash_object_data(ObjectKind::Blob, &raw);
2935                        if raw_oid == te.oid {
2936                            eff_oid = te.oid;
2937                        }
2938                    }
2939                }
2940                if eff_oid != te.oid || wt_mode != te.mode {
2941                    result.push(DiffEntry {
2942                        status: DiffStatus::Modified,
2943                        old_path: Some(path.clone()),
2944                        new_path: Some(path.clone()),
2945                        old_mode: format_mode(te.mode),
2946                        new_mode: format_mode(wt_mode),
2947                        old_oid: te.oid,
2948                        new_oid: eff_oid,
2949                        score: None,
2950                    });
2951                }
2952            }
2953            (Some(te), None) => {
2954                // In tree but missing from worktree
2955                result.push(DiffEntry {
2956                    status: DiffStatus::Deleted,
2957                    old_path: Some(path.clone()),
2958                    new_path: None,
2959                    old_mode: format_mode(te.mode),
2960                    new_mode: "000000".to_owned(),
2961                    old_oid: te.oid,
2962                    new_oid: zero_oid(),
2963                    score: None,
2964                });
2965            }
2966            (None, Some(ref meta)) => {
2967                // In index but not in tree, and exists in worktree
2968                let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
2969                let wt_oid = hash_worktree_file(
2970                    odb,
2971                    &file_path,
2972                    meta,
2973                    &conv,
2974                    &file_attrs,
2975                    path,
2976                    index_entries.get(path.as_bytes()).copied(),
2977                )?;
2978                let wt_mode = mode_from_metadata(meta);
2979                result.push(DiffEntry {
2980                    status: DiffStatus::Added,
2981                    old_path: None,
2982                    new_path: Some(path.clone()),
2983                    old_mode: "000000".to_owned(),
2984                    new_mode: format_mode(wt_mode),
2985                    old_oid: zero_oid(),
2986                    new_oid: wt_oid,
2987                    score: None,
2988                });
2989            }
2990            (None, None) => {
2991                // Tracked in index but neither in tree nor worktree — skip
2992            }
2993        }
2994    }
2995
2996    result.sort_by(|a, b| a.path().cmp(b.path()));
2997    Ok(result)
2998}
2999
3000// ── Rename detection ────────────────────────────────────────────────
3001
3002fn read_added_entry_bytes(
3003    odb: &Odb,
3004    entry: &DiffEntry,
3005    work_root: Option<&Path>,
3006) -> Option<Vec<u8>> {
3007    if entry.new_oid != zero_oid() {
3008        return odb.read(&entry.new_oid).ok().map(|obj| obj.data);
3009    }
3010    let path = entry.new_path.as_deref()?;
3011    let root = work_root?;
3012    fs::read(root.join(path)).ok()
3013}
3014
3015fn modified_as_copy_from_sources(
3016    odb: &Odb,
3017    work_root: Option<&Path>,
3018    e: &DiffEntry,
3019    threshold: u32,
3020    sources: &[(String, ObjectId, bool)],
3021    source_contents: &[Option<Vec<u8>>],
3022    source_tree_entries: &[(String, String, ObjectId)],
3023) -> Option<DiffEntry> {
3024    fn regular_file_mode(mode: &str) -> bool {
3025        mode == "100644" || mode == "100755"
3026    }
3027
3028    if e.status != DiffStatus::Modified || !regular_file_mode(&e.new_mode) {
3029        return None;
3030    }
3031    let new_data = read_added_entry_bytes(odb, e, work_root)?;
3032    let new_oid_eff = if e.new_oid != zero_oid() {
3033        e.new_oid
3034    } else {
3035        Odb::hash_object_data(ObjectKind::Blob, &new_data)
3036    };
3037
3038    let mut best: Option<(usize, u32)> = None;
3039    for (si, (src_path, src_oid, is_deleted)) in sources.iter().enumerate() {
3040        if *is_deleted {
3041            continue;
3042        }
3043        if e.new_path.as_deref() == Some(src_path.as_str()) {
3044            continue;
3045        }
3046        let src_mode_str = source_tree_entries
3047            .iter()
3048            .find(|(p, _, _)| p == src_path)
3049            .map(|(_, m, _)| m.as_str())
3050            .unwrap_or("100644");
3051        if !regular_file_mode(src_mode_str) {
3052            continue;
3053        }
3054
3055        let score = if *src_oid == new_oid_eff {
3056            100
3057        } else {
3058            match (&source_contents[si], Some(new_data.as_slice())) {
3059                (Some(old_data), Some(nd)) => compute_similarity(old_data, nd),
3060                _ => 0,
3061            }
3062        };
3063        if score >= threshold {
3064            let replace = match best {
3065                None => true,
3066                Some((_, s)) => score > s,
3067            };
3068            if replace {
3069                best = Some((si, score));
3070            }
3071        }
3072    }
3073
3074    let (si, score) = best?;
3075    let (src_path, src_oid, _) = &sources[si];
3076    let src_mode = source_tree_entries
3077        .iter()
3078        .find(|(p, _, _)| p == src_path)
3079        .map(|(_, m, _)| m.clone())
3080        .unwrap_or_else(|| e.old_mode.clone());
3081
3082    Some(DiffEntry {
3083        status: DiffStatus::Copied,
3084        old_path: Some(src_path.clone()),
3085        new_path: e.new_path.clone(),
3086        old_mode: src_mode,
3087        new_mode: e.new_mode.clone(),
3088        old_oid: *src_oid,
3089        new_oid: e.new_oid,
3090        score: Some(score),
3091    })
3092}
3093
3094/// Detect renames by pairing Deleted and Added entries with similar content.
3095///
3096/// `threshold` is the minimum similarity percentage (0–100) for a pair to
3097/// be considered a rename (Git's default is 50%).  The function reads blob
3098/// content from the ODB to compute a line-level similarity score.
3099///
3100/// Exact-OID matches are always 100% similar regardless of content.
3101///
3102/// When `work_root` is set, added entries whose `new_oid` is the zero placeholder (as in
3103/// uncached `diff-index` when the work tree diverged from the index) load content from disk
3104/// under that root instead of the object database.
3105pub fn detect_renames(
3106    odb: &Odb,
3107    work_root: Option<&Path>,
3108    entries: Vec<DiffEntry>,
3109    threshold: u32,
3110) -> Vec<DiffEntry> {
3111    // Split entries into deleted, added, and others.
3112    let mut deleted: Vec<DiffEntry> = Vec::new();
3113    let mut added: Vec<DiffEntry> = Vec::new();
3114    let mut others: Vec<DiffEntry> = Vec::new();
3115
3116    for entry in entries {
3117        match entry.status {
3118            DiffStatus::Deleted => deleted.push(entry),
3119            DiffStatus::Added => added.push(entry),
3120            _ => others.push(entry),
3121        }
3122    }
3123
3124    if deleted.is_empty() || added.is_empty() {
3125        // Nothing to pair — return original order.
3126        let mut result = others;
3127        result.extend(deleted);
3128        result.extend(added);
3129        result.sort_by(|a, b| a.path().cmp(b.path()));
3130        return result;
3131    }
3132
3133    // Read content for all deleted blobs.
3134    let deleted_contents: Vec<Option<Vec<u8>>> = deleted
3135        .iter()
3136        .map(|d| odb.read(&d.old_oid).ok().map(|obj| obj.data))
3137        .collect();
3138
3139    // Read content for all added blobs.
3140    let added_contents: Vec<Option<Vec<u8>>> = added
3141        .iter()
3142        .map(|a| read_added_entry_bytes(odb, a, work_root))
3143        .collect();
3144
3145    // Build a matrix of similarity scores and find the best pairings.
3146    // We use a greedy approach: pick the highest-scoring pair first.
3147    let mut scores: Vec<(u32, usize, usize)> = Vec::new();
3148
3149    fn is_regularish_mode(mode: &str) -> bool {
3150        mode == "100644" || mode == "100755"
3151    }
3152
3153    fn same_path_same_blob(del: &DiffEntry, add: &DiffEntry) -> bool {
3154        del.old_path == add.new_path && del.old_oid == add.new_oid && del.old_mode == add.new_mode
3155    }
3156
3157    for (di, del) in deleted.iter().enumerate() {
3158        for (ai, add) in added.iter().enumerate() {
3159            // Exact OID match → 100%
3160            if del.old_oid == add.new_oid {
3161                scores.push((100, di, ai));
3162                continue;
3163            }
3164
3165            // Do not use line similarity across file types (e.g. regular ↔ symlink); Git keeps these
3166            // as separate changes (`t4008-diff-break-rewrite` #7).
3167            if !is_regularish_mode(&del.old_mode) || !is_regularish_mode(&add.new_mode) {
3168                continue;
3169            }
3170
3171            let score = match (&deleted_contents[di], &added_contents[ai]) {
3172                (Some(old_data), Some(new_data)) => compute_similarity(old_data, new_data),
3173                _ => 0,
3174            };
3175
3176            if score >= threshold {
3177                scores.push((score, di, ai));
3178            }
3179        }
3180    }
3181
3182    // Sort: prefer real path-changing pairs before same-path no-op pairs, then
3183    // same-basename pairs, then by score descending.
3184    // This matches Git's behavior where basename matches are checked first.
3185    scores.sort_by(|a, b| {
3186        let a_noop = same_path_same_blob(&deleted[a.1], &added[a.2]);
3187        let b_noop = same_path_same_blob(&deleted[b.1], &added[b.2]);
3188        let a_same = same_basename(&deleted[a.1], &added[a.2]);
3189        let b_same = same_basename(&deleted[b.1], &added[b.2]);
3190        a_noop
3191            .cmp(&b_noop)
3192            .then_with(|| b_same.cmp(&a_same))
3193            .then_with(|| b.0.cmp(&a.0))
3194    });
3195
3196    let mut used_deleted = vec![false; deleted.len()];
3197    let mut used_added = vec![false; added.len()];
3198    let mut renames: Vec<DiffEntry> = Vec::new();
3199
3200    for (score, di, ai) in &scores {
3201        if used_deleted[*di] || used_added[*ai] {
3202            continue;
3203        }
3204        used_deleted[*di] = true;
3205        used_added[*ai] = true;
3206
3207        let del = &deleted[*di];
3208        let add = &added[*ai];
3209
3210        // A "rename" whose source and destination are the same path with the
3211        // same blob is not a change at all (this arises with pathological
3212        // duplicate tree entries, t4058). Git pairs and then drops it, leaving
3213        // no diff entry; mirror that by skipping emission.
3214        if same_path_same_blob(del, add) {
3215            continue;
3216        }
3217
3218        renames.push(DiffEntry {
3219            status: DiffStatus::Renamed,
3220            old_path: del.old_path.clone(),
3221            new_path: add.new_path.clone(),
3222            old_mode: del.old_mode.clone(),
3223            new_mode: add.new_mode.clone(),
3224            old_oid: del.old_oid,
3225            new_oid: add.new_oid,
3226            score: Some(*score),
3227        });
3228    }
3229
3230    // Collect unmatched entries.
3231    let mut result = others;
3232    result.extend(renames);
3233    for (i, entry) in deleted.into_iter().enumerate() {
3234        if !used_deleted[i] {
3235            result.push(entry);
3236        }
3237    }
3238    for (i, entry) in added.into_iter().enumerate() {
3239        if !used_added[i] {
3240            result.push(entry);
3241        }
3242    }
3243
3244    result.sort_by(|a, b| a.path().cmp(b.path()));
3245    result
3246}
3247
3248/// Detect copies among diff entries.
3249///
3250/// This first runs rename detection (pairing Deleted+Added), then for any
3251/// remaining Added entries, looks for copy sources.
3252///
3253/// - `find_copies_harder` = false: only Modified entries are copy source candidates.
3254/// - `find_copies_harder` = true: also examine unmodified files from `source_tree_entries`.
3255///
3256/// `source_tree_entries` should be a list of (path, mode, oid) from the source tree;
3257/// used when `find_copies_harder` is true to consider unmodified files as copy sources.
3258pub fn detect_copies(
3259    odb: &Odb,
3260    work_root: Option<&Path>,
3261    entries: Vec<DiffEntry>,
3262    threshold: u32,
3263    find_copies_harder: bool,
3264    source_tree_entries: &[(String, String, ObjectId)],
3265) -> Vec<DiffEntry> {
3266    use std::collections::{HashMap, HashSet};
3267
3268    // Separate entries by status.
3269    let mut deleted: Vec<DiffEntry> = Vec::new();
3270    let mut added: Vec<DiffEntry> = Vec::new();
3271    let mut others: Vec<DiffEntry> = Vec::new();
3272
3273    for entry in entries {
3274        match entry.status {
3275            DiffStatus::Deleted => deleted.push(entry),
3276            DiffStatus::Added => added.push(entry),
3277            _ => others.push(entry),
3278        }
3279    }
3280
3281    // Build source candidates: deleted files, modified files, and optionally tree entries.
3282    // Track which sources are from deleted files (can become renames).
3283    let mut sources: Vec<(String, ObjectId, bool)> = Vec::new(); // (path, oid, is_deleted)
3284    let mut deleted_source_idx: HashMap<String, usize> = HashMap::new();
3285
3286    for entry in &deleted {
3287        if let Some(ref path) = entry.old_path {
3288            deleted_source_idx.insert(path.clone(), sources.len());
3289            sources.push((path.clone(), entry.old_oid, true));
3290        }
3291    }
3292
3293    // Modified and type-changed files are candidates for `-C` (e.g. symlink rewrite leaves the
3294    // old blob available as a copy source for another path; see `t4008-diff-break-rewrite`).
3295    for entry in &others {
3296        if matches!(entry.status, DiffStatus::Modified | DiffStatus::TypeChanged) {
3297            if let Some(ref old_path) = entry.old_path {
3298                if !sources.iter().any(|(p, _, _)| p == old_path) {
3299                    sources.push((old_path.clone(), entry.old_oid, false));
3300                }
3301            }
3302        }
3303    }
3304
3305    // With find_copies_harder, add all source tree entries.
3306    if find_copies_harder {
3307        for (path, _mode, oid) in source_tree_entries {
3308            if !sources.iter().any(|(p, _, _)| p == path) {
3309                sources.push((path.clone(), *oid, false));
3310            }
3311        }
3312    }
3313
3314    if sources.is_empty() {
3315        let mut result = others;
3316        result.extend(deleted);
3317        result.extend(added);
3318        result.sort_by(|a, b| a.path().cmp(b.path()));
3319        return result;
3320    }
3321
3322    // Read content for sources.
3323    let source_contents: Vec<Option<Vec<u8>>> = sources
3324        .iter()
3325        .map(|(_, oid, _)| odb.read(oid).ok().map(|obj| obj.data))
3326        .collect();
3327
3328    let mut result_entries: Vec<DiffEntry> = Vec::new();
3329    let mut renamed_deleted: HashSet<usize> = HashSet::new();
3330    let mut used_added2 = vec![false; added.len()];
3331
3332    if !added.is_empty() {
3333        // Read content for added blobs.
3334        let added_contents: Vec<Option<Vec<u8>>> = added
3335            .iter()
3336            .map(|a| read_added_entry_bytes(odb, a, work_root))
3337            .collect();
3338
3339        // Build score matrix: (score, source_idx, added_idx)
3340        let mut scores: Vec<(u32, usize, usize)> = Vec::new();
3341        for (si, (src_path, src_oid, _)) in sources.iter().enumerate() {
3342            for (ai, add) in added.iter().enumerate() {
3343                // Never pair a path with itself as copy source (matches Git; avoids
3344                // arbitrary tie-breaking when several sources share the same blob).
3345                if add.new_path.as_deref() == Some(src_path.as_str()) {
3346                    continue;
3347                }
3348                let add_oid = if add.new_oid != zero_oid() {
3349                    add.new_oid
3350                } else if let Some(ref data) = added_contents[ai] {
3351                    Odb::hash_object_data(ObjectKind::Blob, data)
3352                } else {
3353                    zero_oid()
3354                };
3355                if *src_oid == add_oid {
3356                    scores.push((100, si, ai));
3357                    continue;
3358                }
3359                let score = match (&source_contents[si], &added_contents[ai]) {
3360                    (Some(old_data), Some(new_data)) => compute_similarity(old_data, new_data),
3361                    _ => 0,
3362                };
3363                if score >= threshold {
3364                    scores.push((score, si, ai));
3365                }
3366            }
3367        }
3368
3369        // Sort by score descending.
3370        scores.sort_by(|a, b| b.0.cmp(&a.0));
3371
3372        // Build source->added mappings, each added file assigned to best source.
3373        let mut used_added = vec![false; added.len()];
3374        let mut source_to_added: HashMap<usize, Vec<(usize, u32)>> = HashMap::new();
3375        for &(score, si, ai) in &scores {
3376            if used_added[ai] {
3377                continue;
3378            }
3379            used_added[ai] = true;
3380            source_to_added.entry(si).or_default().push((ai, score));
3381        }
3382
3383        // For each deleted source, pick one assignment as Rename, rest as Copy.
3384        for (&si, assignments_for_src) in &source_to_added {
3385            let (_, _, is_deleted) = &sources[si];
3386            if *is_deleted && !assignments_for_src.is_empty() {
3387                // Pick the last one (by path) as the rename target.
3388                // Git tends to pick the rename as the last alphabetically.
3389                let rename_ai = assignments_for_src
3390                    .iter()
3391                    .max_by_key(|(ai, _score)| added[*ai].path().to_string())
3392                    .map(|(ai, _)| *ai);
3393
3394                for &(ai, score) in assignments_for_src {
3395                    let (ref src_path, _, _) = sources[si];
3396                    let add = &added[ai];
3397                    let src_mode = source_tree_entries
3398                        .iter()
3399                        .find(|(p, _, _)| p == src_path)
3400                        .map(|(_, m, _)| m.clone())
3401                        .unwrap_or_else(|| add.old_mode.clone());
3402
3403                    let is_rename = Some(ai) == rename_ai;
3404                    result_entries.push(DiffEntry {
3405                        status: if is_rename {
3406                            DiffStatus::Renamed
3407                        } else {
3408                            DiffStatus::Copied
3409                        },
3410                        old_path: Some(src_path.clone()),
3411                        new_path: add.new_path.clone(),
3412                        old_mode: src_mode,
3413                        new_mode: add.new_mode.clone(),
3414                        old_oid: sources[si].1,
3415                        new_oid: add.new_oid,
3416                        score: Some(score),
3417                    });
3418                    used_added2[ai] = true;
3419                }
3420                renamed_deleted.insert(si);
3421            } else {
3422                // Non-deleted source: all assignments are copies.
3423                for &(ai, score) in assignments_for_src {
3424                    let (ref src_path, _, _) = sources[si];
3425                    let add = &added[ai];
3426                    let src_mode = source_tree_entries
3427                        .iter()
3428                        .find(|(p, _, _)| p == src_path)
3429                        .map(|(_, m, _)| m.clone())
3430                        .unwrap_or_else(|| add.old_mode.clone());
3431
3432                    result_entries.push(DiffEntry {
3433                        status: DiffStatus::Copied,
3434                        old_path: Some(src_path.clone()),
3435                        new_path: add.new_path.clone(),
3436                        old_mode: src_mode,
3437                        new_mode: add.new_mode.clone(),
3438                        old_oid: sources[si].1,
3439                        new_oid: add.new_oid,
3440                        score: Some(score),
3441                    });
3442                    used_added2[ai] = true;
3443                }
3444            }
3445        }
3446    }
3447
3448    // Keep deleted entries that weren't consumed by a rename.
3449    for entry in deleted.into_iter() {
3450        if let Some(ref path) = entry.old_path {
3451            if let Some(&si) = deleted_source_idx.get(path) {
3452                if renamed_deleted.contains(&si) {
3453                    // This deletion was consumed by a rename; skip it.
3454                    continue;
3455                }
3456            }
3457        }
3458        result_entries.push(entry);
3459    }
3460
3461    let mut result = others;
3462    result.extend(result_entries);
3463    // Keep unmatched added entries.
3464    for (i, entry) in added.into_iter().enumerate() {
3465        if !used_added2[i] {
3466            result.push(entry);
3467        }
3468    }
3469
3470    let mut final_result = Vec::with_capacity(result.len());
3471    for e in result {
3472        if let Some(c) = modified_as_copy_from_sources(
3473            odb,
3474            work_root,
3475            &e,
3476            threshold,
3477            &sources,
3478            &source_contents,
3479            source_tree_entries,
3480        ) {
3481            final_result.push(c);
3482        } else {
3483            final_result.push(e);
3484        }
3485    }
3486
3487    final_result.sort_by(|a, b| a.path().cmp(b.path()));
3488    final_result
3489}
3490
3491/// Apply Git-style rename and optional copy detection for index↔worktree diffs.
3492///
3493/// When `copies` is true (Git `diff.renames` / `status.renames` set to `copy`/`copies`),
3494/// runs copy detection, which also pairs deleted sources with one rename and any additional
3495/// destinations as copies.
3496///
3497/// # Errors
3498///
3499/// Propagates errors from reading the `head_tree` object from `odb`.
3500pub fn status_apply_rename_copy_detection(
3501    odb: &Odb,
3502    unstaged_raw: Vec<DiffEntry>,
3503    threshold: u32,
3504    copies: bool,
3505    head_tree: Option<&ObjectId>,
3506) -> Result<Vec<DiffEntry>> {
3507    if !copies {
3508        return Ok(detect_renames(odb, None, unstaged_raw, threshold));
3509    }
3510    let source_tree_entries: Vec<(String, String, ObjectId)> = match head_tree {
3511        Some(oid) => flatten_tree(odb, oid, "")?
3512            .into_iter()
3513            .map(|e| (e.path, format_mode(e.mode), e.oid))
3514            .collect(),
3515        None => Vec::new(),
3516    };
3517    Ok(detect_copies(
3518        odb,
3519        None,
3520        unstaged_raw,
3521        threshold,
3522        false,
3523        &source_tree_entries,
3524    ))
3525}
3526
3527/// Format a rename pair using Git's compact path format.
3528///
3529/// Examples:
3530/// - `a/b/c` → `c/b/a` → `a/b/c => c/b/a`
3531/// - `c/b/a` → `c/d/e` → `c/{b/a => d/e}`
3532/// - `c/d/e` → `d/e` → `{c/d => d}/e`
3533/// - `d/e` → `d/f/e` → `d/{ => f}/e`
3534pub fn format_rename_path(old: &str, new: &str) -> String {
3535    let ob = old.as_bytes();
3536    let nb = new.as_bytes();
3537
3538    // Find common prefix length, snapped to '/' boundary.
3539    let pfx = {
3540        let mut last_sep = 0usize;
3541        let min_len = ob.len().min(nb.len());
3542        for i in 0..min_len {
3543            if ob[i] != nb[i] {
3544                break;
3545            }
3546            if ob[i] == b'/' {
3547                last_sep = i + 1;
3548            }
3549        }
3550        last_sep
3551    };
3552
3553    // Find common suffix length, snapped to '/' boundary.
3554    let mut sfx = {
3555        let mut last_sep = 0usize;
3556        let min_len = ob.len().min(nb.len());
3557        for i in 0..min_len {
3558            let oi = ob.len() - 1 - i;
3559            let ni = nb.len() - 1 - i;
3560            if ob[oi] != nb[ni] {
3561                break;
3562            }
3563            if ob[oi] == b'/' {
3564                last_sep = i + 1;
3565            }
3566        }
3567        last_sep
3568    };
3569
3570    // Suffix starts at this position in each string.
3571    let mut sfx_at_old = ob.len() - sfx;
3572    let mut sfx_at_new = nb.len() - sfx;
3573
3574    // If prefix and suffix overlap in both strings (both middles empty),
3575    // reduce the suffix so that at least the longer string has a non-empty middle.
3576    while pfx > sfx_at_old && pfx > sfx_at_new && sfx > 0 {
3577        // Reduce suffix by snapping to the next smaller '/' boundary.
3578        let suffix_bytes = &ob[sfx_at_old..];
3579        let mut new_sfx = 0;
3580        // Find the next '/' after sfx_at_old (i.e., reduce suffix).
3581        for (i, &b) in suffix_bytes.iter().enumerate().skip(1) {
3582            if b == b'/' {
3583                new_sfx = sfx - i;
3584                break;
3585            }
3586        }
3587        if new_sfx == 0 || new_sfx >= sfx {
3588            sfx_at_old = ob.len();
3589            sfx_at_new = nb.len();
3590            break;
3591        }
3592        sfx = new_sfx;
3593        sfx_at_old = ob.len() - sfx;
3594        sfx_at_new = nb.len() - sfx;
3595    }
3596
3597    // When prefix and suffix overlap in the shorter string, they share
3598    // the '/' boundary character. In the output format, the shared '/'
3599    // appears in both positions (e.g. "d/{ => f}/e" for d/e → d/f/e).
3600    // Compute the middle parts. When prefix and suffix overlap in a
3601    // string, the middle for that string is empty. The shared '/' shows
3602    // in both prefix (trailing) and suffix (leading) positions.
3603    let prefix = &old[..pfx];
3604    let suffix = &old[sfx_at_old..];
3605    let old_mid = if pfx <= sfx_at_old {
3606        &old[pfx..sfx_at_old]
3607    } else {
3608        ""
3609    };
3610    let new_mid = if pfx <= sfx_at_new {
3611        &new[pfx..sfx_at_new]
3612    } else {
3613        ""
3614    };
3615
3616    if prefix.is_empty() && suffix.is_empty() {
3617        return format!("{old} => {new}");
3618    }
3619
3620    format!("{prefix}{{{old_mid} => {new_mid}}}{suffix}")
3621}
3622
3623/// Check if two entries share the same filename (basename).
3624fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
3625    let old = del.old_path.as_deref().unwrap_or("");
3626    let new = add.new_path.as_deref().unwrap_or("");
3627    let old_base = old.rsplit('/').next().unwrap_or(old);
3628    let new_base = new.rsplit('/').next().unwrap_or(new);
3629    old_base == new_base && !old_base.is_empty()
3630}
3631
3632/// Compute a similarity percentage (0–100) between two byte slices.
3633///
3634/// Uses Git's approach: count the bytes that are "shared" (appear in
3635/// equal lines), then compute `score = shared_bytes * 2 * 100 / (src_size + dst_size)`.
3636fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
3637    // Normalize CRLF → LF before comparing so that files differing
3638    // only in line endings are detected as renames.
3639    let old_norm = crate::crlf::crlf_to_lf(old);
3640    let new_norm = crate::crlf::crlf_to_lf(new);
3641
3642    let src_size = old_norm.len();
3643    let dst_size = new_norm.len();
3644
3645    if src_size == 0 && dst_size == 0 {
3646        return 100;
3647    }
3648    let total = src_size + dst_size;
3649    if total == 0 {
3650        return 100;
3651    }
3652
3653    // Use line-level diff to find shared content, then count bytes.
3654    use similar::{ChangeTag, TextDiff};
3655    let old_str = String::from_utf8_lossy(&old_norm);
3656    let new_str = String::from_utf8_lossy(&new_norm);
3657    let diff = TextDiff::from_lines(&old_str as &str, &new_str as &str);
3658
3659    let mut shared_bytes = 0usize;
3660    for change in diff.iter_all_changes() {
3661        if change.tag() == ChangeTag::Equal {
3662            // Count bytes in the matching line (including newline).
3663            shared_bytes += change.value().len();
3664        }
3665    }
3666
3667    // Git: score = copied * MAX_SCORE / max(src_size, dst_size)
3668    // We normalize to 0-100.
3669    let max_size = src_size.max(dst_size);
3670
3671    ((shared_bytes * 100) / max_size).min(100) as u32
3672}
3673
3674/// Compute rename/copy similarity percentage (0–100) between two byte slices.
3675///
3676/// This uses the same scoring logic as internal rename detection.
3677#[must_use]
3678pub fn rename_similarity_score(old: &[u8], new: &[u8]) -> u32 {
3679    compute_similarity(old, new)
3680}
3681
3682// ── Output formatting ───────────────────────────────────────────────
3683
3684/// Format a diff entry in Git's raw diff format.
3685///
3686/// Example: `:100644 100644 abc1234... def5678... M\tfile.txt`
3687pub fn format_raw(entry: &DiffEntry) -> String {
3688    let path = match entry.status {
3689        DiffStatus::Renamed | DiffStatus::Copied => {
3690            format!(
3691                "{}\t{}",
3692                entry.old_path.as_deref().unwrap_or(""),
3693                entry.new_path.as_deref().unwrap_or("")
3694            )
3695        }
3696        _ => entry.path().to_owned(),
3697    };
3698
3699    let status_str = match (entry.status, entry.score) {
3700        (DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
3701        (DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
3702        _ => entry.status.letter().to_string(),
3703    };
3704
3705    let (old_hex, new_hex) = raw_oid_hex_pair(&entry.old_oid, &entry.new_oid);
3706    format!(
3707        ":{} {} {} {} {}\t{}",
3708        entry.old_mode, entry.new_mode, old_hex, new_hex, status_str, path
3709    )
3710}
3711
3712/// Render a diff entry's `(old, new)` OIDs as full hex for `--raw` output,
3713/// widening any null OID to the repository's hash width.
3714///
3715/// Diff entries store null OIDs at SHA-1 width regardless of the repository
3716/// algorithm, but `git diff --raw` prints them at the real hash width (64
3717/// zeros in a SHA-256 repo). A diff entry never has both sides null, so the
3718/// width is taken from whichever side carries a real object.
3719fn raw_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
3720    let width = if !old.is_zero() {
3721        old.algo().hex_len()
3722    } else if !new.is_zero() {
3723        new.algo().hex_len()
3724    } else {
3725        old.algo().hex_len()
3726    };
3727    let render = |oid: &ObjectId| {
3728        if oid.is_zero() {
3729            "0".repeat(width)
3730        } else {
3731            oid.to_hex()
3732        }
3733    };
3734    (render(old), render(new))
3735}
3736
3737/// Format a diff entry with abbreviated OIDs.
3738pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
3739    let ellipsis = if std::env::var("GIT_PRINT_SHA1_ELLIPSIS").ok().as_deref() == Some("yes") {
3740        "..."
3741    } else {
3742        ""
3743    };
3744    let old_hex = format!("{}", entry.old_oid);
3745    let new_hex = format!("{}", entry.new_oid);
3746    let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
3747    let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
3748
3749    // Renames/copies carry a similarity score and a `<old>\t<new>` path pair.
3750    let path = match entry.status {
3751        DiffStatus::Renamed | DiffStatus::Copied => format!(
3752            "{}\t{}",
3753            entry.old_path.as_deref().unwrap_or(""),
3754            entry.new_path.as_deref().unwrap_or("")
3755        ),
3756        _ => entry.path().to_owned(),
3757    };
3758    let status_str = match (entry.status, entry.score) {
3759        (DiffStatus::Renamed, Some(s)) => format!("R{s:03}"),
3760        (DiffStatus::Copied, Some(s)) => format!("C{s:03}"),
3761        _ => entry.status.letter().to_string(),
3762    };
3763
3764    format!(
3765        ":{} {} {}{} {}{} {}\t{}",
3766        entry.old_mode,
3767        entry.new_mode,
3768        old_abbrev,
3769        ellipsis,
3770        new_abbrev,
3771        ellipsis,
3772        status_str,
3773        path
3774    )
3775}
3776
3777/// Generate a unified diff patch for two blobs.
3778///
3779/// # Parameters
3780///
3781/// - `old_content` — the old file content (empty for added files).
3782/// - `new_content` — the new file content (empty for deleted files).
3783/// - `old_path` — display path for the old side.
3784/// - `new_path` — display path for the new side.
3785/// - `context_lines` — number of context lines around changes (default: 3).
3786/// - Inter-hunk context defaults to `0` (see [`unified_diff_with_prefix`]).
3787///
3788/// # Returns
3789///
3790/// The unified diff as a string.
3791pub fn unified_diff(
3792    old_content: &str,
3793    new_content: &str,
3794    old_path: &str,
3795    new_path: &str,
3796    context_lines: usize,
3797    indent_heuristic: bool,
3798    quote_path_fully: bool,
3799) -> String {
3800    unified_diff_with_prefix(
3801        old_content,
3802        new_content,
3803        old_path,
3804        new_path,
3805        context_lines,
3806        0,
3807        "a/",
3808        "b/",
3809        indent_heuristic,
3810        quote_path_fully,
3811    )
3812}
3813
3814/// Same as `unified_diff` but with configurable source/destination prefixes.
3815///
3816/// `inter_hunk_context` is Git's `--inter-hunk-context`: adjacent hunks merge when
3817/// the unchanged gap between them is at most `2 * context_lines + inter_hunk_context` lines.
3818#[allow(clippy::too_many_arguments)] // Mirrors Git-style unified diff parameters.
3819pub fn unified_diff_with_prefix(
3820    old_content: &str,
3821    new_content: &str,
3822    old_path: &str,
3823    new_path: &str,
3824    context_lines: usize,
3825    inter_hunk_context: usize,
3826    src_prefix: &str,
3827    dst_prefix: &str,
3828    indent_heuristic: bool,
3829    quote_path_fully: bool,
3830) -> String {
3831    unified_diff_with_prefix_and_funcname(
3832        old_content,
3833        new_content,
3834        old_path,
3835        new_path,
3836        context_lines,
3837        inter_hunk_context,
3838        src_prefix,
3839        dst_prefix,
3840        None,
3841        indent_heuristic,
3842        quote_path_fully,
3843    )
3844}
3845
3846/// Same as [`unified_diff_with_prefix`] with optional custom hunk-header
3847/// function-name matching.
3848#[allow(clippy::too_many_arguments)]
3849pub fn unified_diff_with_prefix_and_funcname(
3850    old_content: &str,
3851    new_content: &str,
3852    old_path: &str,
3853    new_path: &str,
3854    context_lines: usize,
3855    inter_hunk_context: usize,
3856    src_prefix: &str,
3857    dst_prefix: &str,
3858    funcname_matcher: Option<&FuncnameMatcher>,
3859    indent_heuristic: bool,
3860    quote_path_fully: bool,
3861) -> String {
3862    unified_diff_with_prefix_and_funcname_and_algorithm(
3863        old_content,
3864        new_content,
3865        old_path,
3866        new_path,
3867        context_lines,
3868        inter_hunk_context,
3869        src_prefix,
3870        dst_prefix,
3871        funcname_matcher,
3872        similar::Algorithm::Myers,
3873        false,
3874        false,
3875        indent_heuristic,
3876        quote_path_fully,
3877    )
3878}
3879
3880/// Same as [`unified_diff_with_prefix_and_funcname`] but allows callers to
3881/// choose the line diff algorithm used for hunk generation.
3882///
3883/// When `function_context` is true (`git diff -W`), hunks are expanded to
3884/// whole logical functions using the same rules as Git's `XDL_EMIT_FUNCCONTEXT`.
3885#[allow(clippy::too_many_arguments)]
3886pub fn unified_diff_with_prefix_and_funcname_and_algorithm(
3887    old_content: &str,
3888    new_content: &str,
3889    old_path: &str,
3890    new_path: &str,
3891    context_lines: usize,
3892    inter_hunk_context: usize,
3893    src_prefix: &str,
3894    dst_prefix: &str,
3895    funcname_matcher: Option<&FuncnameMatcher>,
3896    algorithm: similar::Algorithm,
3897    function_context: bool,
3898    use_git_histogram: bool,
3899    indent_heuristic: bool,
3900    quote_path_fully: bool,
3901) -> String {
3902    // `--function-context` (`-W`) expansion must apply regardless of the line
3903    // algorithm; the histogram body printer below does not do it, so route `-W`
3904    // through the function-context emitter first (t4015 #136).
3905    if function_context {
3906        return unified_diff_with_function_context(
3907            old_content,
3908            new_content,
3909            old_path,
3910            new_path,
3911            context_lines,
3912            inter_hunk_context,
3913            src_prefix,
3914            dst_prefix,
3915            funcname_matcher,
3916            algorithm,
3917            indent_heuristic,
3918            quote_path_fully,
3919        );
3920    }
3921
3922    if use_git_histogram {
3923        return unified_diff_histogram_with_prefix_and_funcname(
3924            old_content,
3925            new_content,
3926            old_path,
3927            new_path,
3928            context_lines,
3929            inter_hunk_context,
3930            src_prefix,
3931            dst_prefix,
3932            funcname_matcher,
3933            quote_path_fully,
3934        );
3935    }
3936
3937    use crate::quote_path::format_diff_path_with_prefix;
3938    use similar::{udiff::UnifiedDiffHunk, TextDiff};
3939
3940    let diff = TextDiff::configure()
3941        .algorithm(algorithm)
3942        .diff_lines(old_content, new_content);
3943    let compacted_ops = diff_indent_heuristic::diff_lines_ops_compacted(
3944        old_content,
3945        new_content,
3946        algorithm,
3947        indent_heuristic,
3948    );
3949
3950    let mut output = String::new();
3951    if old_path == "/dev/null" {
3952        output.push_str("--- /dev/null\n");
3953    } else if src_prefix.is_empty() {
3954        // Callers (e.g. `diff-tree`, `diff-index`) may pass a fully formatted token
3955        // (already includes `a/` and any C-style quoting).
3956        output.push_str(&format!("--- {old_path}\n"));
3957    } else {
3958        output.push_str("--- ");
3959        output.push_str(&format_diff_path_with_prefix(
3960            src_prefix,
3961            old_path,
3962            quote_path_fully,
3963        ));
3964        output.push('\n');
3965    }
3966    if new_path == "/dev/null" {
3967        output.push_str("+++ /dev/null\n");
3968    } else if dst_prefix.is_empty() {
3969        output.push_str(&format!("+++ {new_path}\n"));
3970    } else {
3971        output.push_str("+++ ");
3972        output.push_str(&format_diff_path_with_prefix(
3973            dst_prefix,
3974            new_path,
3975            quote_path_fully,
3976        ));
3977        output.push('\n');
3978    }
3979
3980    let old_lines: Vec<&str> = old_content.lines().collect();
3981
3982    // Git's xdiff merges adjacent changes while the gap between them in the old file is at most
3983    // `2 * context_lines + inter_hunk_context` (see `xdl_get_hunk` in xemit.c).
3984    // `similar::group_diff_ops` couples the split threshold and the displayed edge context to a
3985    // single radius (split at `> 2n`), which over-merges when the gap limit is odd
3986    // (t4032: `-U0 --inter-hunk-context=1` with 2 common lines must stay 2 hunks).
3987    let max_common_gap = context_lines
3988        .saturating_mul(2)
3989        .saturating_add(inter_hunk_context);
3990    let op_groups = group_diff_ops_gap(compacted_ops, context_lines, max_common_gap);
3991
3992    for ops in op_groups {
3993        if ops.is_empty() {
3994            continue;
3995        }
3996        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
3997        let hunk_str = format!("{hunk}");
3998        // The similar crate outputs @@ -a,b +c,d @@\n but Git adds
3999        // function context after the closing @@. Extract the hunk header
4000        // and add function context.
4001        if let Some(first_newline) = hunk_str.find('\n') {
4002            let header_line = &hunk_str[..first_newline];
4003            let rest = &hunk_str[first_newline..];
4004
4005            // Parse the old start line from the @@ header
4006            if let Some(func_ctx) =
4007                extract_function_context(header_line, &old_lines, funcname_matcher)
4008            {
4009                output.push_str(header_line);
4010                output.push(' ');
4011                output.push_str(&func_ctx);
4012                output.push_str(rest);
4013            } else {
4014                output.push_str(&hunk_str);
4015            }
4016        } else {
4017            output.push_str(&hunk_str);
4018        }
4019    }
4020
4021    output
4022}
4023
4024/// Group diff ops into hunks like Git's `xdl_get_hunk`: two changes merge into one hunk while
4025/// the run of unchanged lines between them is at most `max_common_gap`
4026/// (`2 * context + inter_hunk_context`), and each hunk keeps at most `context` unchanged lines
4027/// at its edges. Unlike `similar::group_diff_ops`, the split threshold is decoupled from the
4028/// edge context so odd gap limits group exactly like Git.
4029fn group_diff_ops_gap(
4030    mut ops: Vec<similar::DiffOp>,
4031    context: usize,
4032    max_common_gap: usize,
4033) -> Vec<Vec<similar::DiffOp>> {
4034    use similar::DiffOp;
4035    if ops.is_empty() {
4036        return vec![];
4037    }
4038
4039    let mut pending_group = Vec::new();
4040    let mut rv = Vec::new();
4041
4042    if let Some(DiffOp::Equal {
4043        old_index,
4044        new_index,
4045        len,
4046    }) = ops.first_mut()
4047    {
4048        let offset = (*len).saturating_sub(context);
4049        *old_index += offset;
4050        *new_index += offset;
4051        *len -= offset;
4052    }
4053
4054    if let Some(DiffOp::Equal { len, .. }) = ops.last_mut() {
4055        *len -= (*len).saturating_sub(context);
4056    }
4057
4058    for op in ops.into_iter() {
4059        if let DiffOp::Equal {
4060            old_index,
4061            new_index,
4062            len,
4063        } = op
4064        {
4065            // End the current group and start a new one whenever the unchanged
4066            // run is too long to fuse the surrounding changes.
4067            if len > max_common_gap {
4068                pending_group.push(DiffOp::Equal {
4069                    old_index,
4070                    new_index,
4071                    len: context,
4072                });
4073                rv.push(pending_group);
4074                let offset = len.saturating_sub(context);
4075                pending_group = vec![DiffOp::Equal {
4076                    old_index: old_index + offset,
4077                    new_index: new_index + offset,
4078                    len: len - offset,
4079                }];
4080                continue;
4081            }
4082        }
4083        pending_group.push(op);
4084    }
4085
4086    match &pending_group[..] {
4087        &[] | &[similar::DiffOp::Equal { .. }] => {}
4088        _ => rv.push(pending_group),
4089    }
4090
4091    rv
4092}
4093
4094/// `git diff -W`: expand each hunk to include full function bodies (see Git `xemit.c`).
4095fn unified_diff_with_function_context(
4096    old_content: &str,
4097    new_content: &str,
4098    old_path: &str,
4099    new_path: &str,
4100    context_lines: usize,
4101    inter_hunk_context: usize,
4102    src_prefix: &str,
4103    dst_prefix: &str,
4104    funcname_matcher: Option<&FuncnameMatcher>,
4105    algorithm: similar::Algorithm,
4106    indent_heuristic: bool,
4107    quote_path_fully: bool,
4108) -> String {
4109    use crate::quote_path::format_diff_path_with_prefix;
4110    use similar::{udiff::UnifiedDiffHunk, TextDiff};
4111
4112    let diff = TextDiff::configure()
4113        .algorithm(algorithm)
4114        .diff_lines(old_content, new_content);
4115
4116    let old_lines: Vec<&str> = old_content.lines().collect();
4117    let new_lines: Vec<&str> = new_content.lines().collect();
4118    let n_old = old_lines.len();
4119    let n_new = new_lines.len();
4120
4121    // Group changes the way Git's xdl_get_hunk does: merge while the unchanged
4122    // gap is at most `2*context + inter_hunk_context`. `similar::group_diff_ops`
4123    // splits only at gap > `2*radius`, which over-merges changes in *different*
4124    // functions (e.g. a leading insertion and a body change) into one hunk and
4125    // then over-expands the function context (t4015 #136). Use the gap-correct
4126    // grouping (same as the non-function-context path).
4127    let max_common_gap = context_lines
4128        .saturating_mul(2)
4129        .saturating_add(inter_hunk_context);
4130    let all_ops = diff.ops().to_vec();
4131    let op_groups = group_diff_ops_gap(all_ops.clone(), context_lines, max_common_gap);
4132
4133    let mut ranges: Vec<(usize, usize, usize, usize)> = Vec::new();
4134
4135    for ops in op_groups {
4136        if ops.is_empty() {
4137            continue;
4138        }
4139        let i1_anchor = func_context_old_anchor(&ops, n_old);
4140        let i1_end = hunk_old_change_end_exclusive(&ops);
4141        let skip_preimage_pull =
4142            append_with_whole_function_added(&ops, n_old, n_new, &new_lines, funcname_matcher);
4143        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
4144        let hunk_str = format!("{hunk}");
4145        let header_line = hunk_str
4146            .lines()
4147            .next()
4148            .unwrap_or("")
4149            .trim_end_matches(['\r', '\n']);
4150        let Some((base_s1, _base_e1, _base_s2, _base_e2)) =
4151            parse_unified_hunk_header_ranges(header_line)
4152        else {
4153            continue;
4154        };
4155
4156        let ctx = context_lines;
4157        let (s1, e1, s2, e2) = if skip_preimage_pull {
4158            let s = n_old.saturating_sub(ctx);
4159            let s2 = map_old_line_to_new(&all_ops, s, n_new).min(n_new);
4160            (s, n_old, s2, n_new)
4161        } else {
4162            let mut s1 = base_s1.saturating_sub(ctx);
4163            let mut s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
4164
4165            let base_pre_s1 = i1_anchor.saturating_sub(ctx);
4166            if base_pre_s1 < s1 {
4167                s1 = base_pre_s1;
4168                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
4169            }
4170
4171            let fs1 = expand_func_pre_start(s1, i1_anchor, n_old, &old_lines, funcname_matcher);
4172            if fs1 < s1 {
4173                s1 = fs1;
4174                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
4175            }
4176
4177            // `i1_end` is the exclusive end of the changed region; its post-image
4178            // context is `ctx` lines (Git's `xche->i1 + xche->chg1 + lctx`). The
4179            // hunk's `base_e1` already includes the group's trailing context, so
4180            // use the change end + ctx directly to avoid double-counting context
4181            // (which over-extended the hunk and merged separate functions — t4015 #136).
4182            let mut e1 = (i1_end + ctx).min(n_old);
4183            let mut e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
4184            let fe1 = expand_func_post_end(e1, i1_end, n_old, &old_lines, funcname_matcher);
4185            if fe1 > e1 {
4186                e1 = fe1;
4187                e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
4188            }
4189            (s1, e1, s2, e2)
4190        };
4191
4192        ranges.push((s1, e1, s2, e2));
4193    }
4194
4195    // Merge ranges whose function-context expansion made them overlap on the old
4196    // side (Git emits a single hunk in that case).
4197    ranges.sort_by_key(|r| (r.0, r.2));
4198    let mut merged: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(ranges.len());
4199    for (s1, e1, s2, e2) in ranges {
4200        if let Some(last) = merged.last_mut() {
4201            if s1 < last.1 {
4202                last.1 = last.1.max(e1);
4203                last.3 = last.3.max(e2);
4204                continue;
4205            }
4206        }
4207        merged.push((s1, e1, s2, e2));
4208    }
4209    let ranges = merged;
4210
4211    let mut output = String::new();
4212    if old_path == "/dev/null" {
4213        output.push_str("--- /dev/null\n");
4214    } else if src_prefix.is_empty() {
4215        output.push_str(&format!("--- {old_path}\n"));
4216    } else {
4217        output.push_str("--- ");
4218        output.push_str(&format_diff_path_with_prefix(
4219            src_prefix,
4220            old_path,
4221            quote_path_fully,
4222        ));
4223        output.push('\n');
4224    }
4225    if new_path == "/dev/null" {
4226        output.push_str("+++ /dev/null\n");
4227    } else if dst_prefix.is_empty() {
4228        output.push_str(&format!("+++ {new_path}\n"));
4229    } else {
4230        output.push_str("+++ ");
4231        output.push_str(&format_diff_path_with_prefix(
4232            dst_prefix,
4233            new_path,
4234            quote_path_fully,
4235        ));
4236        output.push('\n');
4237    }
4238
4239    for (s1, e1, s2, e2) in ranges {
4240        if s1 >= e1 && s2 >= e2 {
4241            continue;
4242        }
4243        let old_seg =
4244            line_slice_for_diff_with_eof_nl(&old_lines, s1, e1, old_content.ends_with('\n'));
4245        let new_seg =
4246            line_slice_for_diff_with_eof_nl(&new_lines, s2, e2, new_content.ends_with('\n'));
4247        let inner_ctx = old_seg.lines().count().max(new_seg.lines().count()).max(1);
4248        let piece = unified_diff_with_prefix_and_funcname_and_algorithm(
4249            &old_seg,
4250            &new_seg,
4251            old_path,
4252            new_path,
4253            inner_ctx,
4254            0,
4255            src_prefix,
4256            dst_prefix,
4257            funcname_matcher,
4258            algorithm,
4259            false,
4260            false,
4261            indent_heuristic,
4262            quote_path_fully,
4263        );
4264        let shifted = shift_unified_hunk_headers_to_full_file(&piece, s1, s2);
4265        let with_func =
4266            enrich_unified_hunk_headers_funcname(&shifted, &old_lines, funcname_matcher);
4267        for line in with_func.lines() {
4268            if line.starts_with("--- ") || line.starts_with("+++ ") {
4269                continue;
4270            }
4271            output.push_str(line);
4272            output.push('\n');
4273        }
4274    }
4275
4276    output
4277}
4278
4279/// `piece` is a unified diff for a slice of the file; hunk headers use 1-based
4280/// coordinates relative to that slice. Shift them by `delta_old` / `delta_new`
4281/// (0-based offsets of the slice in the full file) so the combined patch applies
4282/// to the whole file.
4283fn shift_unified_hunk_headers_to_full_file(
4284    patch: &str,
4285    delta_old: usize,
4286    delta_new: usize,
4287) -> String {
4288    if delta_old == 0 && delta_new == 0 {
4289        return patch.to_owned();
4290    }
4291    let mut out = String::with_capacity(patch.len());
4292    for line in patch.lines() {
4293        if let Some(shifted) = shift_one_unified_hunk_header(line, delta_old, delta_new) {
4294            out.push_str(&shifted);
4295        } else {
4296            out.push_str(line);
4297        }
4298        out.push('\n');
4299    }
4300    out
4301}
4302
4303fn shift_one_unified_hunk_header(line: &str, delta_old: usize, delta_new: usize) -> Option<String> {
4304    let rest = line.strip_prefix("@@ ")?;
4305    let (old_chunk, after_plus) = rest.split_once(" +")?;
4306    let old_spec = old_chunk.strip_prefix('-')?;
4307    let (new_spec, suffix) = after_plus.split_once(" @@")?;
4308    let shifted_old = shift_unified_range_spec(old_spec, delta_old)?;
4309    let shifted_new = shift_unified_range_spec(new_spec, delta_new)?;
4310    Some(format!("@@ -{shifted_old} +{shifted_new} @@{suffix}"))
4311}
4312
4313fn shift_unified_range_spec(spec: &str, delta: usize) -> Option<String> {
4314    let spec = spec.trim();
4315    if let Some((start_s, count_s)) = spec.split_once(',') {
4316        let start: usize = start_s.parse().ok()?;
4317        let count: usize = count_s.parse().ok()?;
4318        Some(format!("{},{}", start.saturating_add(delta), count))
4319    } else {
4320        let start: usize = spec.parse().ok()?;
4321        Some(format!("{}", start.saturating_add(delta)))
4322    }
4323}
4324
4325/// Re-attach `@@ ... @@ <funcname>` using full-file line indices (inner diffs use slices).
4326fn enrich_unified_hunk_headers_funcname(
4327    patch: &str,
4328    full_old_lines: &[&str],
4329    funcname_matcher: Option<&FuncnameMatcher>,
4330) -> String {
4331    let mut out = String::with_capacity(patch.len());
4332    for line in patch.lines() {
4333        if let Some(fixed) = enrich_one_hunk_header_funcname(line, full_old_lines, funcname_matcher)
4334        {
4335            out.push_str(&fixed);
4336        } else {
4337            out.push_str(line);
4338        }
4339        out.push('\n');
4340    }
4341    out
4342}
4343
4344fn enrich_one_hunk_header_funcname(
4345    line: &str,
4346    full_old_lines: &[&str],
4347    funcname_matcher: Option<&FuncnameMatcher>,
4348) -> Option<String> {
4349    let after_at = line.strip_prefix("@@ ")?;
4350    let idx = after_at.find(" @@")?;
4351    let mid = after_at[..idx].trim();
4352    let tail = after_at[idx + 3..].trim_start();
4353    let header_for_parse = format!("@@ {mid} @@");
4354    let func = extract_function_context(&header_for_parse, full_old_lines, funcname_matcher);
4355    Some(if let Some(f) = func {
4356        format!("@@ {mid} @@ {f}")
4357    } else if !tail.is_empty() {
4358        format!("@@ {mid} @@ {tail}")
4359    } else {
4360        format!("@@ {mid} @@")
4361    })
4362}
4363
4364fn line_slice_for_diff_with_eof_nl(
4365    lines: &[&str],
4366    start: usize,
4367    end: usize,
4368    full_file_ends_with_newline: bool,
4369) -> String {
4370    if start >= end {
4371        return String::new();
4372    }
4373    let mut s = lines[start..end].join("\n");
4374    let slice_is_suffix_of_file = end == lines.len();
4375    let need_trailing_nl = if slice_is_suffix_of_file {
4376        full_file_ends_with_newline
4377    } else {
4378        true
4379    };
4380    if need_trailing_nl && !s.ends_with('\n') {
4381        s.push('\n');
4382    }
4383    s
4384}
4385
4386/// Map a 0-based old line index to the corresponding 0-based new line index using the full-file
4387/// diff ops (Git aligns context across deletions/insertions).
4388fn map_old_line_to_new(ops: &[similar::DiffOp], old_line: usize, n_new: usize) -> usize {
4389    use similar::DiffOp;
4390    let mut n = 0usize;
4391    for op in ops {
4392        match *op {
4393            DiffOp::Equal {
4394                old_index,
4395                new_index,
4396                len,
4397            } => {
4398                if old_index + len <= old_line {
4399                    n = new_index + len;
4400                    continue;
4401                }
4402                if old_index < old_line {
4403                    let take = old_line - old_index;
4404                    return (new_index + take).min(n_new);
4405                }
4406                return new_index.min(n_new);
4407            }
4408            DiffOp::Delete {
4409                old_index,
4410                old_len,
4411                new_index,
4412            } => {
4413                if old_index + old_len <= old_line {
4414                    n = new_index;
4415                    continue;
4416                }
4417                if old_index < old_line {
4418                    return new_index.min(n_new);
4419                }
4420            }
4421            DiffOp::Insert {
4422                old_index,
4423                new_index,
4424                new_len,
4425            } => {
4426                if old_index < old_line {
4427                    n = new_index + new_len;
4428                    continue;
4429                }
4430                if old_index == old_line {
4431                    // `old_line` is an exclusive end or insertion point aligned with this insert
4432                    // (e.g. EOF append maps to after the inserted block).
4433                    return (new_index + new_len).min(n_new);
4434                }
4435                return new_index.min(n_new);
4436            }
4437            DiffOp::Replace {
4438                old_index,
4439                old_len,
4440                new_index,
4441                new_len,
4442            } => {
4443                if old_index + old_len <= old_line {
4444                    n = new_index + new_len;
4445                    continue;
4446                }
4447                if old_index < old_line {
4448                    let into_old = old_line - old_index;
4449                    let mapped = new_index + into_old.min(new_len);
4450                    return mapped.min(n_new);
4451                }
4452                return new_index.min(n_new);
4453            }
4454        }
4455    }
4456    n.min(n_new)
4457}
4458
4459/// Parse `@@ -old +new @@` into 0-based half-open ranges in each file.
4460fn parse_unified_hunk_header_ranges(header: &str) -> Option<(usize, usize, usize, usize)> {
4461    let rest = header.strip_prefix("@@ ")?;
4462    let (old_tok, rest2) = rest.split_once(" +")?;
4463    let old_tok = old_tok.strip_prefix('-')?;
4464    let new_tok = rest2.split_once(" @@").map(|(a, _)| a)?;
4465
4466    fn parse_side(spec: &str) -> Option<(usize, usize)> {
4467        let spec = spec.trim();
4468        let (start_one_based, count) = if let Some((a, b)) = spec.split_once(',') {
4469            (a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)
4470        } else {
4471            let s = spec.parse::<usize>().ok()?;
4472            (s, 1usize)
4473        };
4474        let s0 = start_one_based.saturating_sub(1);
4475        let e0 = s0.saturating_add(count);
4476        Some((s0, e0))
4477    }
4478
4479    let (os, oe) = parse_side(old_tok)?;
4480    let (ns, ne) = parse_side(new_tok)?;
4481    Some((os, oe, ns, ne))
4482}
4483
4484/// Git `xemit.c`: when a hunk only inserts at EOF (first inserted line is `new_index == n_old`)
4485/// and the added text already contains a funcname line, do not pull extra context from the preimage.
4486fn append_with_whole_function_added(
4487    ops: &[similar::DiffOp],
4488    n_old: usize,
4489    n_new: usize,
4490    new_lines: &[&str],
4491    matcher: Option<&FuncnameMatcher>,
4492) -> bool {
4493    use similar::DiffOp;
4494    if n_old == 0 {
4495        return false;
4496    }
4497    let mut only_ins_or_eq = true;
4498    let mut min_new_ins = usize::MAX;
4499    for op in ops {
4500        match *op {
4501            DiffOp::Equal { .. } => {}
4502            DiffOp::Insert {
4503                new_index, new_len, ..
4504            } => {
4505                min_new_ins = min_new_ins.min(new_index);
4506                if new_len == 0 {
4507                    only_ins_or_eq = false;
4508                }
4509            }
4510            DiffOp::Delete { .. } | DiffOp::Replace { .. } => {
4511                only_ins_or_eq = false;
4512            }
4513        }
4514    }
4515    let mut insert_at_eof = false;
4516    for op in ops {
4517        if let DiffOp::Insert { old_index, .. } = *op {
4518            if old_index == n_old {
4519                insert_at_eof = true;
4520                break;
4521            }
4522        }
4523    }
4524    let append_at_eof = min_new_ins == n_old || insert_at_eof;
4525    if !only_ins_or_eq || !append_at_eof || min_new_ins == usize::MAX {
4526        return false;
4527    }
4528    // Git only skips preimage pull when the inserted block is clearly a new logical
4529    // function (see `xemit.c` walking `xdf2` for `is_func_rec`). A loose "any line
4530    // looks like a function" check would match `return` / `printf` and break `-W`
4531    // hunks that still need preimage context (t4051 `extended`).
4532    let mut j = min_new_ins;
4533    while j < n_new {
4534        let line = new_lines[j];
4535        if line.trim().is_empty() {
4536            j += 1;
4537            continue;
4538        }
4539        if let Some(m) = matcher {
4540            if m.match_line(line).is_some() {
4541                return true;
4542            }
4543        } else if inserted_block_starts_with_c_like_function_definition(line) {
4544            return true;
4545        }
4546        j += 1;
4547    }
4548    false
4549}
4550
4551fn inserted_block_starts_with_c_like_function_definition(line: &str) -> bool {
4552    let t = line.trim_start();
4553    let Some(open_paren) = t.find('(') else {
4554        return false;
4555    };
4556    let head = &t[..open_paren];
4557    let tokens: Vec<&str> = head.split_whitespace().collect();
4558    if tokens.len() < 2 {
4559        // `printf(...)`, `return (`, etc. — not `return_type name(`.
4560        return false;
4561    }
4562    let nameish = tokens.last().copied().unwrap_or("");
4563    let name = nameish.trim_end_matches(['*', '&']);
4564    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
4565        return false;
4566    }
4567    let type_or_modifier = |tok: &str| {
4568        matches!(
4569            tok,
4570            "static"
4571                | "extern"
4572                | "inline"
4573                | "void"
4574                | "int"
4575                | "char"
4576                | "short"
4577                | "long"
4578                | "float"
4579                | "double"
4580                | "unsigned"
4581                | "signed"
4582                | "struct"
4583                | "enum"
4584                | "union"
4585                | "const"
4586                | "volatile"
4587                | "typedef"
4588        )
4589    };
4590    tokens[..tokens.len() - 1]
4591        .iter()
4592        .any(|tok| type_or_modifier(tok))
4593}
4594
4595fn hunk_old_change_end_exclusive(ops: &[similar::DiffOp]) -> usize {
4596    use similar::DiffOp;
4597    let mut max_o = 0usize;
4598    for op in ops {
4599        match *op {
4600            DiffOp::Delete {
4601                old_index, old_len, ..
4602            } => {
4603                max_o = max_o.max(old_index + old_len);
4604            }
4605            DiffOp::Replace {
4606                old_index, old_len, ..
4607            } => {
4608                max_o = max_o.max(old_index + old_len);
4609            }
4610            DiffOp::Insert { old_index, .. } => {
4611                // Pure insertions do not consume old lines; Git's post-context anchor is the
4612                // insertion point (`old_index`), not 0 (t4051 `extended`).
4613                max_o = max_o.max(old_index);
4614            }
4615            DiffOp::Equal { .. } => {}
4616        }
4617    }
4618    max_o
4619}
4620
4621fn func_context_old_anchor(ops: &[similar::DiffOp], n_old: usize) -> usize {
4622    use similar::DiffOp;
4623    let mut has_delete_or_replace = false;
4624    let mut min_del = usize::MAX;
4625    let mut min_ins_old = usize::MAX;
4626
4627    for op in ops {
4628        match *op {
4629            DiffOp::Delete {
4630                old_index, old_len, ..
4631            } => {
4632                has_delete_or_replace = true;
4633                min_del = min_del.min(old_index);
4634                min_del = min_del.min(old_index + old_len.saturating_sub(1));
4635            }
4636            DiffOp::Replace {
4637                old_index, old_len, ..
4638            } => {
4639                has_delete_or_replace = true;
4640                min_del = min_del.min(old_index);
4641                min_del = min_del.min(old_index + old_len.saturating_sub(1));
4642            }
4643            DiffOp::Insert { old_index, .. } => {
4644                min_ins_old = min_ins_old.min(old_index);
4645            }
4646            DiffOp::Equal { .. } => {}
4647        }
4648    }
4649
4650    let mut i1 = if has_delete_or_replace {
4651        min_del
4652    } else if min_ins_old != usize::MAX {
4653        min_ins_old
4654    } else {
4655        0
4656    };
4657
4658    let pure_insert = ops
4659        .iter()
4660        .all(|op| matches!(op, DiffOp::Insert { .. } | DiffOp::Equal { .. }))
4661        && ops.iter().any(|op| matches!(op, DiffOp::Insert { .. }));
4662
4663    if pure_insert && i1 >= n_old && n_old > 0 {
4664        i1 = n_old - 1;
4665    }
4666
4667    i1.min(n_old.saturating_sub(1))
4668}
4669
4670fn expand_func_pre_start(
4671    s1: usize,
4672    i1: usize,
4673    n_old: usize,
4674    old_lines: &[&str],
4675    matcher: Option<&FuncnameMatcher>,
4676) -> usize {
4677    if n_old == 0 {
4678        return s1;
4679    }
4680    let i1 = i1.min(n_old.saturating_sub(1));
4681    let mut fs1 = get_func_line_backward(old_lines, i1, matcher).unwrap_or(i1);
4682    while fs1 > 0
4683        && !is_line_empty_for_func_context(old_lines[fs1 - 1])
4684        && !is_func_line(old_lines[fs1 - 1], matcher)
4685    {
4686        fs1 -= 1;
4687    }
4688    s1.min(fs1)
4689}
4690
4691fn expand_func_post_end(
4692    e1: usize,
4693    i1_end: usize,
4694    n_old: usize,
4695    old_lines: &[&str],
4696    matcher: Option<&FuncnameMatcher>,
4697) -> usize {
4698    let from = i1_end.min(n_old);
4699    let fe1 = get_func_line_forward(old_lines, from, matcher).unwrap_or(n_old);
4700    let mut fe1_adj = fe1;
4701    while fe1_adj > 0 && is_line_empty_for_func_context(old_lines[fe1_adj - 1]) {
4702        fe1_adj -= 1;
4703    }
4704    e1.max(fe1_adj).min(n_old)
4705}
4706
4707fn is_line_empty_for_func_context(line: &str) -> bool {
4708    line.chars().all(|c| c.is_whitespace())
4709}
4710
4711fn is_func_line(line: &str, matcher: Option<&FuncnameMatcher>) -> bool {
4712    if let Some(m) = matcher {
4713        return m.match_line(line).is_some();
4714    }
4715    let t = line.trim_end_matches(['\n', '\r']);
4716    if t.is_empty() {
4717        return false;
4718    }
4719    let b = t.as_bytes()[0];
4720    b.is_ascii_alphabetic() || b == b'_' || b == b'$'
4721}
4722
4723fn get_func_line_backward(
4724    old_lines: &[&str],
4725    start: usize,
4726    matcher: Option<&FuncnameMatcher>,
4727) -> Option<usize> {
4728    let mut l = start.min(old_lines.len().saturating_sub(1));
4729    if old_lines.is_empty() {
4730        return None;
4731    }
4732    loop {
4733        if is_func_line(old_lines[l], matcher) {
4734            return Some(l);
4735        }
4736        if l == 0 {
4737            break;
4738        }
4739        l -= 1;
4740    }
4741    None
4742}
4743
4744fn get_func_line_forward(
4745    old_lines: &[&str],
4746    start: usize,
4747    matcher: Option<&FuncnameMatcher>,
4748) -> Option<usize> {
4749    let mut l = start;
4750    while l < old_lines.len() {
4751        if is_func_line(old_lines[l], matcher) {
4752            return Some(l);
4753        }
4754        l += 1;
4755    }
4756    None
4757}
4758
4759/// Compute a unified diff with anchored lines.
4760///
4761/// Anchored lines that appear exactly once in both old and new content are
4762/// forced to match, splitting the diff into segments around those anchor points.
4763/// This produces diffs where the anchored text stays as context and surrounding
4764/// lines are shown as additions/removals.
4765///
4766/// Segment diffs use `algorithm`. When `use_git_histogram` is true, histogram uses imara-diff
4767/// (Git-compatible); otherwise `algorithm` is passed to `similar`.
4768pub fn anchored_unified_diff(
4769    old_content: &str,
4770    new_content: &str,
4771    old_path: &str,
4772    new_path: &str,
4773    context_lines: usize,
4774    anchors: &[String],
4775    algorithm: similar::Algorithm,
4776    use_git_histogram: bool,
4777    indent_heuristic: bool,
4778    quote_path_fully: bool,
4779) -> String {
4780    use crate::quote_path::format_diff_path_with_prefix;
4781    use similar::TextDiff;
4782
4783    let old_lines: Vec<&str> = old_content.lines().collect();
4784    let new_lines: Vec<&str> = new_content.lines().collect();
4785
4786    // Find anchored lines that appear exactly once in both old and new
4787    let mut anchor_pairs: Vec<(usize, usize)> = Vec::new(); // (old_idx, new_idx)
4788
4789    for anchor in anchors {
4790        let anchor_str = anchor.as_str();
4791
4792        // Count occurrences in old
4793        let old_positions: Vec<usize> = old_lines
4794            .iter()
4795            .enumerate()
4796            .filter(|(_, l)| l.trim_end() == anchor_str)
4797            .map(|(i, _)| i)
4798            .collect();
4799
4800        // Count occurrences in new
4801        let new_positions: Vec<usize> = new_lines
4802            .iter()
4803            .enumerate()
4804            .filter(|(_, l)| l.trim_end() == anchor_str)
4805            .map(|(i, _)| i)
4806            .collect();
4807
4808        // Only anchor if unique in both
4809        if old_positions.len() == 1 && new_positions.len() == 1 {
4810            anchor_pairs.push((old_positions[0], new_positions[0]));
4811        }
4812    }
4813
4814    // If no valid anchors, fall back to normal diff
4815    if anchor_pairs.is_empty() {
4816        return unified_diff_with_prefix_and_funcname_and_algorithm(
4817            old_content,
4818            new_content,
4819            old_path,
4820            new_path,
4821            context_lines,
4822            0,
4823            "a/",
4824            "b/",
4825            None,
4826            algorithm,
4827            false,
4828            use_git_histogram,
4829            indent_heuristic,
4830            quote_path_fully,
4831        );
4832    }
4833
4834    // Sort anchor pairs by their position in the old file
4835    anchor_pairs.sort_by_key(|&(old_idx, _)| old_idx);
4836
4837    // Filter to only keep pairs where new positions are also increasing
4838    // (longest increasing subsequence of new positions)
4839    let mut filtered: Vec<(usize, usize)> = Vec::new();
4840    for &pair in &anchor_pairs {
4841        if filtered.is_empty() || filtered.last().is_some_and(|last| pair.1 > last.1) {
4842            filtered.push(pair);
4843        }
4844    }
4845    let anchor_pairs = filtered;
4846
4847    // Build a modified version of old/new where we diff segments between anchors.
4848    // We'll construct the diff by processing segments:
4849    // - Before first anchor
4850    // - Between consecutive anchors
4851    // - After last anchor
4852    // Each anchor line itself is a fixed context match.
4853
4854    // Collect all diff operations
4855    struct LineDiffOp {
4856        tag: char, // ' ', '+', '-'
4857        line: String,
4858    }
4859
4860    let append_segment_diff =
4861        |ops: &mut Vec<LineDiffOp>, old_seg_input: &str, new_seg_input: &str| {
4862            use similar::ChangeTag;
4863            let old_ls: Vec<&str> = old_seg_input.lines().collect();
4864            let new_ls: Vec<&str> = new_seg_input.lines().collect();
4865            if old_ls.is_empty() && new_ls.is_empty() {
4866                return;
4867            }
4868            let seg_diff = TextDiff::configure()
4869                .algorithm(algorithm)
4870                .diff_slices(&old_ls, &new_ls);
4871            let raw = seg_diff.ops().to_vec();
4872            let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
4873                &raw,
4874                &old_ls,
4875                &new_ls,
4876                indent_heuristic,
4877            );
4878            for op in &compacted {
4879                for ch in op.iter_changes(&old_ls, &new_ls) {
4880                    let t = match ch.tag() {
4881                        ChangeTag::Equal => ' ',
4882                        ChangeTag::Delete => '-',
4883                        ChangeTag::Insert => '+',
4884                    };
4885                    ops.push(LineDiffOp {
4886                        tag: t,
4887                        line: ch.value().to_string(),
4888                    });
4889                }
4890            }
4891        };
4892
4893    let mut ops: Vec<LineDiffOp> = Vec::new();
4894    let mut old_pos = 0usize;
4895    let mut new_pos = 0usize;
4896
4897    for &(old_anchor, new_anchor) in &anchor_pairs {
4898        // Diff the segment before this anchor
4899        let old_segment: Vec<&str> = old_lines[old_pos..old_anchor].to_vec();
4900        let new_segment: Vec<&str> = new_lines[new_pos..new_anchor].to_vec();
4901
4902        let old_seg_text = old_segment.join("\n");
4903        let new_seg_text = new_segment.join("\n");
4904
4905        if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
4906            let old_seg_input = if old_seg_text.is_empty() {
4907                String::new()
4908            } else {
4909                format!("{}\n", old_seg_text)
4910            };
4911            let new_seg_input = if new_seg_text.is_empty() {
4912                String::new()
4913            } else {
4914                format!("{}\n", new_seg_text)
4915            };
4916            append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
4917        }
4918
4919        // The anchor line itself is always context
4920        ops.push(LineDiffOp {
4921            tag: ' ',
4922            line: old_lines[old_anchor].to_string(),
4923        });
4924
4925        old_pos = old_anchor + 1;
4926        new_pos = new_anchor + 1;
4927    }
4928
4929    // Diff the remaining segment after the last anchor
4930    let old_segment: Vec<&str> = old_lines[old_pos..].to_vec();
4931    let new_segment: Vec<&str> = new_lines[new_pos..].to_vec();
4932    let old_seg_text = old_segment.join("\n");
4933    let new_seg_text = new_segment.join("\n");
4934
4935    if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
4936        let old_seg_input = if old_seg_text.is_empty() {
4937            String::new()
4938        } else {
4939            format!("{}\n", old_seg_text)
4940        };
4941        let new_seg_input = if new_seg_text.is_empty() {
4942            String::new()
4943        } else {
4944            format!("{}\n", new_seg_text)
4945        };
4946        append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
4947    }
4948
4949    // Now format as unified diff with hunks
4950    let mut output = String::new();
4951    if old_path == "/dev/null" {
4952        output.push_str("--- /dev/null\n");
4953    } else {
4954        output.push_str("--- ");
4955        output.push_str(&format_diff_path_with_prefix(
4956            "a/",
4957            old_path,
4958            quote_path_fully,
4959        ));
4960        output.push('\n');
4961    }
4962    if new_path == "/dev/null" {
4963        output.push_str("+++ /dev/null\n");
4964    } else {
4965        output.push_str("+++ ");
4966        output.push_str(&format_diff_path_with_prefix(
4967            "b/",
4968            new_path,
4969            quote_path_fully,
4970        ));
4971        output.push('\n');
4972    }
4973
4974    // Group ops into hunks with context
4975    let total_ops = ops.len();
4976    if total_ops == 0 {
4977        return output;
4978    }
4979
4980    // Find ranges of changes
4981    let mut hunks: Vec<(usize, usize)> = Vec::new(); // (start, end) indices into ops
4982    let mut i = 0;
4983    while i < total_ops {
4984        if ops[i].tag != ' ' {
4985            let start = i.saturating_sub(context_lines);
4986            let mut end = i;
4987            // Extend to include consecutive changes and their context
4988            while end < total_ops {
4989                if ops[end].tag != ' ' {
4990                    end += 1;
4991                    continue;
4992                }
4993                // Check if there's another change within context_lines
4994                let mut next_change = end;
4995                while next_change < total_ops && ops[next_change].tag == ' ' {
4996                    next_change += 1;
4997                }
4998                if next_change < total_ops && next_change - end <= context_lines * 2 {
4999                    end = next_change + 1;
5000                } else {
5001                    end = (end + context_lines).min(total_ops);
5002                    break;
5003                }
5004            }
5005            // Merge with previous hunk if overlapping
5006            if let Some(last) = hunks.last_mut() {
5007                if start <= last.1 {
5008                    last.1 = end;
5009                } else {
5010                    hunks.push((start, end));
5011                }
5012            } else {
5013                hunks.push((start, end));
5014            }
5015            i = end;
5016        } else {
5017            i += 1;
5018        }
5019    }
5020
5021    // Output each hunk
5022    for (start, end) in hunks {
5023        // Count old/new lines in this hunk
5024        let mut old_start = 1usize;
5025        let mut new_start = 1usize;
5026        // Calculate line numbers by counting ops before this hunk
5027        for op in &ops[..start] {
5028            match op.tag {
5029                ' ' => {
5030                    old_start += 1;
5031                    new_start += 1;
5032                }
5033                '-' => {
5034                    old_start += 1;
5035                }
5036                '+' => {
5037                    new_start += 1;
5038                }
5039                _ => {}
5040            }
5041        }
5042        let mut old_count = 0usize;
5043        let mut new_count = 0usize;
5044        for op in &ops[start..end] {
5045            match op.tag {
5046                ' ' => {
5047                    old_count += 1;
5048                    new_count += 1;
5049                }
5050                '-' => {
5051                    old_count += 1;
5052                }
5053                '+' => {
5054                    new_count += 1;
5055                }
5056                _ => {}
5057            }
5058        }
5059
5060        output.push_str(&format!(
5061            "@@ -{},{} +{},{} @@\n",
5062            old_start, old_count, new_start, new_count
5063        ));
5064        for op in &ops[start..end] {
5065            output.push(op.tag);
5066            output.push_str(&op.line);
5067            output.push('\n');
5068        }
5069    }
5070
5071    output
5072}
5073
5074/// Extract function context for a hunk header.
5075///
5076/// Given a hunk header like `@@ -8,7 +8,7 @@`, find the last line
5077/// before line 8 in the old content that looks like a function header
5078/// (starts with a non-whitespace character, like Git's default).
5079fn extract_function_context(
5080    header: &str,
5081    old_lines: &[&str],
5082    funcname_matcher: Option<&FuncnameMatcher>,
5083) -> Option<String> {
5084    // Parse the old start line number from "@@ -<start>,<count> ..."
5085    let at_pos = header.find("-")?;
5086    let rest = &header[at_pos + 1..];
5087    let comma_or_space = rest.find([',', ' '])?;
5088    let start_str = &rest[..comma_or_space];
5089    let start_line: usize = start_str.parse().ok()?;
5090
5091    // Parse the old line count; "@@ -<start>,<count> ..." (no comma means count 1).
5092    // Only look for the comma inside the old-range token itself — searching the
5093    // whole remainder would pick up the comma from the new side (e.g. "+0,0").
5094    let old_token_end = rest.find([' ', '\t']).unwrap_or(rest.len());
5095    let old_token = &rest[..old_token_end];
5096    let old_count: usize = if let Some(comma) = old_token.find(',') {
5097        old_token[comma + 1..].parse().unwrap_or(1)
5098    } else {
5099        1
5100    };
5101
5102    if start_line == 0 {
5103        return None;
5104    }
5105
5106    // Look backwards for a line that matches the funcname pattern. start_line is
5107    // 1-indexed. For a normal hunk the first changed pre-image line is
5108    // old_lines[start_line-1], so we search lines strictly before it
5109    // (old_lines[0..start_line-1]). For a pure insertion (old count 0) the
5110    // content is inserted *after* old line start_line, so Git's function search
5111    // begins at that line itself: search old_lines[0..start_line].
5112    let search_end = if old_count == 0 {
5113        start_line.min(old_lines.len())
5114    } else {
5115        if start_line <= 1 {
5116            return None;
5117        }
5118        (start_line - 1).min(old_lines.len())
5119    };
5120    let truncate = |text: &str| {
5121        if text.len() > 80 {
5122            let mut end = 80;
5123            while end > 0 && !text.is_char_boundary(end) {
5124                end -= 1;
5125            }
5126            text[..end].to_owned()
5127        } else {
5128            text.to_owned()
5129        }
5130    };
5131
5132    for i in (0..search_end).rev() {
5133        let line = old_lines[i];
5134        if line.is_empty() {
5135            continue;
5136        }
5137        if let Some(matcher) = funcname_matcher {
5138            if let Some(matched) = matcher.match_line(line) {
5139                return Some(truncate(&matched));
5140            }
5141            continue;
5142        }
5143
5144        let first = line.as_bytes()[0];
5145        if first.is_ascii_alphabetic() || first == b'_' || first == b'$' {
5146            return Some(truncate(line.trim_end_matches(char::is_whitespace)));
5147        }
5148    }
5149    None
5150}
5151
5152/// Generate diff stat output (file name + insertions/deletions).
5153///
5154/// Returns a single line like: ` file.txt | 5 ++---`
5155pub fn format_stat_line(
5156    path: &str,
5157    insertions: usize,
5158    deletions: usize,
5159    max_path_len: usize,
5160) -> String {
5161    format_stat_line_width(path, insertions, deletions, max_path_len, 0)
5162}
5163
5164pub fn format_stat_line_width(
5165    path: &str,
5166    insertions: usize,
5167    deletions: usize,
5168    max_path_len: usize,
5169    count_width: usize,
5170) -> String {
5171    let total = insertions + deletions;
5172    let plus = "+".repeat(insertions.min(50));
5173    let minus = "-".repeat(deletions.min(50));
5174    let cw = if count_width > 0 {
5175        count_width
5176    } else {
5177        format!("{}", total).len()
5178    };
5179    let bar = format!("{}{}", plus, minus);
5180    if bar.is_empty() {
5181        format!(
5182            " {:<width$} | {:>cw$}",
5183            path,
5184            total,
5185            width = max_path_len,
5186            cw = cw
5187        )
5188    } else {
5189        format!(
5190            " {:<width$} | {:>cw$} {}",
5191            path,
5192            total,
5193            bar,
5194            width = max_path_len,
5195            cw = cw
5196        )
5197    }
5198}
5199
5200/// Normalise one line like Git's `-b` / `--ignore-space-change`.
5201#[must_use]
5202pub fn normalize_ignore_space_change_line(line: &str) -> String {
5203    let mut result = String::with_capacity(line.len());
5204    let mut in_space = false;
5205    for c in line.chars() {
5206        if c.is_whitespace() {
5207            if !in_space {
5208                result.push(' ');
5209                in_space = true;
5210            }
5211        } else {
5212            result.push(c);
5213            in_space = false;
5214        }
5215    }
5216    while result.ends_with(' ') {
5217        result.pop();
5218    }
5219    result
5220}
5221
5222/// Normalise text like Git's `-b` / `--ignore-space-change`: on each line, collapse runs of
5223/// whitespace to a single ASCII space and trim trailing spaces.
5224///
5225/// Line breaks are preserved by splitting on [`str::lines`] and rejoining with `\n` (same approach
5226/// as the porcelain `diff` whitespace handling in `grit`).
5227#[must_use]
5228pub fn normalize_ignore_space_change(content: &str) -> String {
5229    content
5230        .lines()
5231        .map(normalize_ignore_space_change_line)
5232        .collect::<Vec<_>>()
5233        .join("\n")
5234}
5235
5236/// Count insertions and deletions between two strings.
5237///
5238/// Returns `(insertions, deletions)`.
5239pub fn count_changes(old_content: &str, new_content: &str) -> (usize, usize) {
5240    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, false)
5241}
5242
5243/// Count insertions and deletions using the given line-diff algorithm.
5244///
5245/// Git's `--stat` / `--numstat` follow the configured diff algorithm; this mirrors that by
5246/// running [`similar::TextDiff`] with an explicit [`similar::Algorithm`].
5247#[must_use]
5248pub fn count_changes_with_algorithm(
5249    old_content: &str,
5250    new_content: &str,
5251    algorithm: similar::Algorithm,
5252    use_git_histogram: bool,
5253) -> (usize, usize) {
5254    if use_git_histogram {
5255        use imara_diff::{Algorithm, Diff, InternedInput};
5256        let input = InternedInput::new(old_content, new_content);
5257        let mut d = Diff::compute(Algorithm::Histogram, &input);
5258        d.postprocess_lines(&input);
5259        return (d.count_additions() as usize, d.count_removals() as usize);
5260    }
5261
5262    use similar::{ChangeTag, TextDiff};
5263
5264    let diff = TextDiff::configure()
5265        .algorithm(algorithm)
5266        .diff_lines(old_content, new_content);
5267    let mut ins = 0;
5268    let mut del = 0;
5269
5270    for change in diff.iter_all_changes() {
5271        match change.tag() {
5272            ChangeTag::Insert => ins += 1,
5273            ChangeTag::Delete => del += 1,
5274            ChangeTag::Equal => {}
5275        }
5276    }
5277
5278    (ins, del)
5279}
5280
5281/// Line count for diffstat/`--numstat`, matching Git's `count_lines()` in `diff.c`.
5282///
5283/// Counts newline-terminated lines; a final line without trailing newline still counts as one line.
5284/// An empty buffer yields `0`.
5285#[must_use]
5286pub fn count_git_lines(data: &[u8]) -> usize {
5287    if data.is_empty() {
5288        return 0;
5289    }
5290    let mut count = 0usize;
5291    let mut nl_just_seen = false;
5292    for &ch in data {
5293        if ch == b'\n' {
5294            count += 1;
5295            nl_just_seen = true;
5296        } else {
5297            nl_just_seen = false;
5298        }
5299    }
5300    if !nl_just_seen {
5301        count += 1;
5302    }
5303    count
5304}
5305
5306/// Internal maximum diff score used by Git rename/break heuristics (`MAX_SCORE` in `diffcore.h`).
5307pub const GIT_DIFF_MAX_SCORE: u64 = 60_000;
5308const DIFF_MAX_SCORE: u64 = GIT_DIFF_MAX_SCORE;
5309const DIFF_MINIMUM_BREAK_SIZE: usize = 400;
5310const DIFF_DEFAULT_BREAK_SCORE: u64 = 30_000;
5311/// Default break threshold (`DEFAULT_BREAK_SCORE` in `diffcore.h`), internal 0–[`GIT_DIFF_MAX_SCORE`] scale.
5312pub const GIT_DIFF_DEFAULT_BREAK_SCORE: u64 = DIFF_DEFAULT_BREAK_SCORE;
5313/// Default merge threshold after a break (`DEFAULT_MERGE_SCORE` in `diffcore.h`): pairs broken for
5314/// rename/copy but not consumed are merged back when deletion-weight is below this (60% by default).
5315pub const GIT_DIFF_DEFAULT_MERGE_SCORE_AFTER_BREAK: u64 = 36_000;
5316const DIFF_HASHBASE: u32 = 107_927;
5317
5318#[derive(Clone, Copy, Default)]
5319struct SpanSlot {
5320    hashval: u32,
5321    cnt: u32,
5322}
5323
5324struct SpanHashTop {
5325    alloc_log2: u8,
5326    free_slots: i32,
5327    data: Vec<SpanSlot>,
5328}
5329
5330impl SpanHashTop {
5331    fn new(initial_log2: u8) -> Self {
5332        let cap = 1usize << initial_log2;
5333        Self {
5334            alloc_log2: initial_log2,
5335            free_slots: initial_free(initial_log2),
5336            data: vec![SpanSlot::default(); cap],
5337        }
5338    }
5339
5340    fn len(&self) -> usize {
5341        1usize << self.alloc_log2
5342    }
5343
5344    fn add_span(&mut self, hashval: u32, cnt: u32) {
5345        loop {
5346            let lim = self.len();
5347            let mut bucket = (hashval as usize) & (lim - 1);
5348            loop {
5349                let h = &mut self.data[bucket];
5350                if h.cnt == 0 {
5351                    h.hashval = hashval;
5352                    h.cnt = cnt;
5353                    self.free_slots -= 1;
5354                    if self.free_slots < 0 {
5355                        self.rehash();
5356                        break;
5357                    }
5358                    return;
5359                }
5360                if h.hashval == hashval {
5361                    h.cnt = h.cnt.saturating_add(cnt);
5362                    return;
5363                }
5364                bucket += 1;
5365                if bucket >= lim {
5366                    bucket = 0;
5367                }
5368            }
5369        }
5370    }
5371
5372    fn rehash(&mut self) {
5373        let old = std::mem::take(&mut self.data);
5374        let old_log = self.alloc_log2;
5375        self.alloc_log2 = old_log.saturating_add(1);
5376        let new_len = 1usize << self.alloc_log2;
5377        self.free_slots = initial_free(self.alloc_log2);
5378        self.data = vec![SpanSlot::default(); new_len];
5379        let old_sz = 1usize << old_log;
5380        for o in old.iter().take(old_sz) {
5381            let o = *o;
5382            if o.cnt == 0 {
5383                continue;
5384            }
5385            self.add_span_after_rehash(o.hashval, o.cnt);
5386        }
5387    }
5388
5389    fn add_span_after_rehash(&mut self, hashval: u32, cnt: u32) {
5390        loop {
5391            let lim = self.len();
5392            let mut bucket = (hashval as usize) & (lim - 1);
5393            loop {
5394                let h = &mut self.data[bucket];
5395                if h.cnt == 0 {
5396                    h.hashval = hashval;
5397                    h.cnt = cnt;
5398                    self.free_slots -= 1;
5399                    if self.free_slots < 0 {
5400                        self.rehash();
5401                        break;
5402                    }
5403                    return;
5404                }
5405                if h.hashval == hashval {
5406                    h.cnt = h.cnt.saturating_add(cnt);
5407                    return;
5408                }
5409                bucket += 1;
5410                if bucket >= lim {
5411                    bucket = 0;
5412                }
5413            }
5414        }
5415    }
5416
5417    fn sort_by_hashval(&mut self) {
5418        let sz = self.len();
5419        self.data[..sz].sort_by(|a, b| {
5420            if a.cnt == 0 {
5421                return std::cmp::Ordering::Greater;
5422            }
5423            if b.cnt == 0 {
5424                return std::cmp::Ordering::Less;
5425            }
5426            a.hashval.cmp(&b.hashval)
5427        });
5428    }
5429}
5430
5431fn initial_free(sz_log2: u8) -> i32 {
5432    let sz = sz_log2 as i32;
5433    ((1i32 << sz_log2) * (sz - 3) / sz).max(0)
5434}
5435
5436fn hash_blob_spans(buf: &[u8], is_text: bool) -> SpanHashTop {
5437    let mut hash = SpanHashTop::new(9);
5438    let mut n = 0u32;
5439    let mut accum1: u32 = 0;
5440    let mut accum2: u32 = 0;
5441    let mut i = 0usize;
5442    while i < buf.len() {
5443        let c = buf[i] as u32;
5444        let old_1 = accum1;
5445        i += 1;
5446
5447        if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
5448            continue;
5449        }
5450
5451        accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
5452        accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
5453        accum1 = accum1.wrapping_add(c);
5454        n += 1;
5455        if n < 64 && c != b'\n' as u32 {
5456            continue;
5457        }
5458        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
5459        hash.add_span(hashval, n);
5460        n = 0;
5461        accum1 = 0;
5462        accum2 = 0;
5463    }
5464    if n > 0 {
5465        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
5466        hash.add_span(hashval, n);
5467    }
5468    hash.sort_by_hashval();
5469    hash
5470}
5471
5472/// Approximate copied vs added material between two blobs (Git `diffcore_count_changes`).
5473///
5474/// Returns `(copied_bytes_from_src, literal_added_bytes_in_dst)` matching Git's
5475/// `diffcore_count_changes` semantics (used for `--dirstat=changes` damage).
5476#[must_use]
5477pub fn diffcore_count_changes(old: &[u8], new: &[u8]) -> (u64, u64) {
5478    let src_is_text = !crate::merge_file::is_binary(old);
5479    let dst_is_text = !crate::merge_file::is_binary(new);
5480    let src_count = hash_blob_spans(old, src_is_text);
5481    let dst_count = hash_blob_spans(new, dst_is_text);
5482    let mut sc: u64 = 0;
5483    let mut la: u64 = 0;
5484    let mut si = 0usize;
5485    let mut di = 0usize;
5486    let src_len = src_count.len();
5487    let dst_len = dst_count.len();
5488    loop {
5489        if si >= src_len || src_count.data[si].cnt == 0 {
5490            break;
5491        }
5492        let s_hash = src_count.data[si].hashval;
5493        let s_cnt = u64::from(src_count.data[si].cnt);
5494        while di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval < s_hash {
5495            la += u64::from(dst_count.data[di].cnt);
5496            di += 1;
5497        }
5498        let mut dst_cnt = 0u64;
5499        if di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval == s_hash {
5500            dst_cnt = u64::from(dst_count.data[di].cnt);
5501            di += 1;
5502        }
5503        if s_cnt < dst_cnt {
5504            la += dst_cnt - s_cnt;
5505            sc += s_cnt;
5506        } else {
5507            sc += dst_cnt;
5508        }
5509        si += 1;
5510    }
5511    while di < dst_len && dst_count.data[di].cnt != 0 {
5512        la += u64::from(dst_count.data[di].cnt);
5513        di += 1;
5514    }
5515    (sc, la)
5516}
5517
5518/// Whether this modified blob pair should use Git's "complete rewrite" diffstat path when
5519/// `--break-rewrites` is in effect (`should_break` in `diffcore-break.c`).
5520#[must_use]
5521pub fn should_break_rewrite_for_stat(old: &[u8], new: &[u8]) -> bool {
5522    should_break_rewrite_inner(old, new, DIFF_DEFAULT_BREAK_SCORE)
5523}
5524
5525/// Whether an in-place blob edit should be split into delete+create for rename/copy (`should_break`
5526/// in `diffcore-break.c`). `break_score` is on the internal 0–[`GIT_DIFF_MAX_SCORE`] scale (default
5527/// [`DIFF_DEFAULT_BREAK_SCORE`]).
5528#[must_use]
5529pub fn should_break_rewrite_pair(old: &[u8], new: &[u8], break_score: u64) -> bool {
5530    should_break_rewrite_inner(old, new, break_score)
5531}
5532
5533/// Parse a single Git `parse_rename_score` token (`50`, `50%`, decimal forms) into internal
5534/// 0–[`GIT_DIFF_MAX_SCORE`] units.
5535pub fn parse_diff_rename_score_token(arg: &str) -> Option<u64> {
5536    let mut num: u64 = 0;
5537    let mut scale: u64 = 1;
5538    let mut dot = false;
5539    let mut saw_digit = false;
5540    for ch in arg.chars() {
5541        if !dot && ch == '.' {
5542            scale = 1;
5543            dot = true;
5544            continue;
5545        }
5546        if ch == '%' {
5547            scale = if dot { scale.saturating_mul(100) } else { 100 };
5548            break;
5549        }
5550        if ch.is_ascii_digit() {
5551            saw_digit = true;
5552            if scale < 100_000 {
5553                scale = scale.saturating_mul(10);
5554                num = num.saturating_mul(10) + u64::from(ch as u8 - b'0');
5555            }
5556        } else {
5557            break;
5558        }
5559    }
5560    if !saw_digit {
5561        return None;
5562    }
5563    Some(if num >= scale {
5564        GIT_DIFF_MAX_SCORE
5565    } else {
5566        GIT_DIFF_MAX_SCORE * num / scale
5567    })
5568}
5569
5570/// Git `merge_score` from `diffcore-break.c` when a pair is considered broken: how much of the
5571/// source blob was removed (0–[`DIFF_MAX_SCORE`] scale). Used for `dissimilarity index` metadata.
5572#[must_use]
5573pub fn rewrite_merge_score(old: &[u8], new: &[u8]) -> Option<u64> {
5574    if old.is_empty() {
5575        return None;
5576    }
5577    let max_size = old.len().max(new.len());
5578    if max_size < DIFF_MINIMUM_BREAK_SIZE {
5579        return None;
5580    }
5581    let (src_copied, _) = diffcore_count_changes(old, new);
5582    let src_copied = src_copied.min(old.len() as u64);
5583    let src_removed = (old.len() as u64).saturating_sub(src_copied);
5584    Some(src_removed * DIFF_MAX_SCORE / old.len() as u64)
5585}
5586
5587/// Percentage shown in `dissimilarity index N%` for a rewrite (`similarity_index` in Git's diff.c).
5588#[must_use]
5589pub fn rewrite_dissimilarity_index_percent(old: &[u8], new: &[u8]) -> Option<u32> {
5590    let score = rewrite_merge_score(old, new)?;
5591    Some((score * 100 / DIFF_MAX_SCORE).min(100) as u32)
5592}
5593
5594fn should_break_rewrite_inner(src: &[u8], dst: &[u8], break_score: u64) -> bool {
5595    if src.is_empty() {
5596        return false;
5597    }
5598    let max_size = src.len().max(dst.len());
5599    if max_size < DIFF_MINIMUM_BREAK_SIZE {
5600        return false;
5601    }
5602    let (src_copied, literal_added) = diffcore_count_changes(src, dst);
5603    let src_copied = src_copied.min(src.len() as u64);
5604    let mut literal_added = literal_added;
5605    let dst_len = dst.len() as u64;
5606    if src_copied < dst_len && literal_added + src_copied > dst_len {
5607        literal_added = dst_len.saturating_sub(src_copied);
5608    }
5609    let src_removed = (src.len() as u64).saturating_sub(src_copied);
5610    let merge_score = src_removed * DIFF_MAX_SCORE / src.len() as u64;
5611    if merge_score > break_score {
5612        return true;
5613    }
5614    let delta_size = src_removed.saturating_add(literal_added);
5615    if delta_size * DIFF_MAX_SCORE / (max_size as u64) < break_score {
5616        return false;
5617    }
5618    let s = src.len() as u64;
5619    if (s * break_score < src_removed * DIFF_MAX_SCORE)
5620        && (literal_added * 20 < src_removed)
5621        && (literal_added * 20 < src_copied)
5622    {
5623        return false;
5624    }
5625    true
5626}
5627
5628// ── Helpers ─────────────────────────────────────────────────────────
5629
5630/// Flatten a tree object recursively into a sorted list of (path, mode, oid).
5631struct FlatEntry {
5632    path: String,
5633    mode: u32,
5634    oid: ObjectId,
5635}
5636
5637fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
5638    let entries = read_tree(odb, tree_oid)?;
5639    let mut result = Vec::new();
5640
5641    for entry in entries {
5642        let name_str = String::from_utf8_lossy(&entry.name);
5643        let path = format_path(prefix, &name_str);
5644        if is_tree_mode(entry.mode) {
5645            let nested = flatten_tree(odb, &entry.oid, &path)?;
5646            result.extend(nested);
5647        } else {
5648            result.push(FlatEntry {
5649                path,
5650                mode: entry.mode,
5651                oid: entry.oid,
5652            });
5653        }
5654    }
5655
5656    Ok(result)
5657}
5658
5659/// Paths present in `HEAD`'s tree with mode and blob/commit OID (for status porcelain v2).
5660pub fn head_path_states(
5661    odb: &Odb,
5662    head_tree: Option<&ObjectId>,
5663) -> Result<std::collections::BTreeMap<String, (u32, ObjectId)>> {
5664    let mut m = std::collections::BTreeMap::new();
5665    let Some(t) = head_tree else {
5666        return Ok(m);
5667    };
5668    for fe in flatten_tree(odb, t, "")? {
5669        m.insert(fe.path, (fe.mode, fe.oid));
5670    }
5671    Ok(m)
5672}
5673
5674/// Whether a mode represents a tree (directory).
5675fn is_tree_mode(mode: u32) -> bool {
5676    mode == 0o040000
5677}
5678
5679/// Build a path with an optional prefix.
5680fn format_path(prefix: &str, name: &str) -> String {
5681    if prefix.is_empty() {
5682        name.to_owned()
5683    } else {
5684        format!("{prefix}/{name}")
5685    }
5686}
5687
5688/// Format a numeric mode as a zero-padded octal string.
5689pub fn format_mode(mode: u32) -> String {
5690    format!("{mode:06o}")
5691}
5692
5693/// Read the HEAD commit OID from a submodule checkout directory.
5694///
5695/// Returns `None` if the path is missing, not a submodule checkout, or has no resolvable HEAD.
5696#[must_use]
5697pub fn read_submodule_head_for_checkout(sub_dir: &Path) -> Option<ObjectId> {
5698    read_submodule_head(sub_dir)
5699}
5700
5701/// First line of a commit's message for `git diff --submodule=log` output.
5702///
5703/// Honors `encoding` in the commit object (Latin-1 vs UTF-8) using the same
5704/// rules as Git's submodule summary.
5705#[must_use]
5706pub fn submodule_commit_subject_line(c: &CommitData) -> String {
5707    let enc = c.encoding.as_deref().unwrap_or("UTF-8");
5708    let is_latin1 = enc.eq_ignore_ascii_case("ISO8859-1")
5709        || enc.eq_ignore_ascii_case("ISO-8859-1")
5710        || enc.eq_ignore_ascii_case("LATIN1")
5711        || enc.eq_ignore_ascii_case("ISO-8859-15");
5712    if let Some(raw) = c.raw_message.as_deref() {
5713        let line = raw.split(|b| *b == b'\n').next().unwrap_or(raw);
5714        if is_latin1 {
5715            return line
5716                .iter()
5717                .map(|&b| b as char)
5718                .collect::<String>()
5719                .trim()
5720                .to_owned();
5721        }
5722        return String::from_utf8_lossy(line).trim().to_string();
5723    }
5724    c.message.lines().next().unwrap_or("").trim().to_owned()
5725}
5726
5727/// True when `sub_dir` is an empty directory (or missing), i.e. the placeholder left by
5728/// `git apply --index` before `git submodule update`.
5729fn submodule_worktree_is_unpopulated_placeholder(sub_dir: &Path) -> bool {
5730    match fs::read_dir(sub_dir) {
5731        Ok(mut it) => it.next().is_none(),
5732        Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
5733        Err(_) => false,
5734    }
5735}
5736
5737fn read_submodule_head(sub_dir: &Path) -> Option<ObjectId> {
5738    read_submodule_head_oid(sub_dir)
5739}
5740
5741/// Resolve the embedded git directory for a submodule work tree (`sub_dir/.git`).
5742#[must_use]
5743pub fn submodule_embedded_git_dir(sub_dir: &Path) -> Option<PathBuf> {
5744    let gitfile = sub_dir.join(".git");
5745    if gitfile.is_file() {
5746        let content = fs::read_to_string(&gitfile).ok()?;
5747        let gitdir = content
5748            .lines()
5749            .find_map(|l| l.strip_prefix("gitdir: "))?
5750            .trim();
5751        Some(if Path::new(gitdir).is_absolute() {
5752            PathBuf::from(gitdir)
5753        } else {
5754            sub_dir.join(gitdir)
5755        })
5756    } else if gitfile.is_dir() {
5757        Some(gitfile)
5758    } else {
5759        None
5760    }
5761}
5762
5763/// Walk upward from `sub_dir` to find the nearest containing Git work tree.
5764fn find_superproject_git(sub_dir: &Path) -> Option<(PathBuf, PathBuf)> {
5765    let mut cur = sub_dir.parent()?;
5766    loop {
5767        let git_path = cur.join(".git");
5768        if git_path.exists() {
5769            let gd = if git_path.is_file() {
5770                let content = fs::read_to_string(&git_path).ok()?;
5771                let line = content
5772                    .lines()
5773                    .find_map(|l| l.strip_prefix("gitdir: "))?
5774                    .trim();
5775                if Path::new(line).is_absolute() {
5776                    PathBuf::from(line)
5777                } else {
5778                    cur.join(line)
5779                }
5780            } else {
5781                git_path
5782            };
5783            return Some((cur.to_path_buf(), gd));
5784        }
5785        cur = cur.parent()?;
5786    }
5787}
5788
5789/// Read the HEAD commit OID from a submodule working tree directory.
5790///
5791/// Handles both embedded `.git` directories and `gitdir:` gitfiles pointing at
5792/// `.git/modules/...` (or other locations). Returns `None` if the path is not
5793/// a checkout or has no resolvable HEAD.
5794pub fn read_submodule_head_oid(sub_dir: &Path) -> Option<ObjectId> {
5795    // Submodule `.git` may be a gitfile pointing at `.git/modules/<name>` in another superproject
5796    // after `cp -R`. Prefer the current superproject's module dir when present.
5797    let mut git_dir = submodule_embedded_git_dir(sub_dir)?;
5798    if let Some((super_wt, super_git_dir)) = find_superproject_git(sub_dir) {
5799        let rel = sub_dir.strip_prefix(&super_wt).ok()?;
5800        let rel_str = rel.to_string_lossy().replace('\\', "/");
5801        let local_mod = super_git_dir
5802            .join("modules")
5803            .join(rel_str.trim_start_matches('/'));
5804        if local_mod.join("HEAD").exists() {
5805            let sg = super_git_dir.canonicalize().unwrap_or(super_git_dir);
5806            let cur = git_dir.canonicalize().unwrap_or_else(|_| git_dir.clone());
5807            if !cur.starts_with(&sg) {
5808                git_dir = local_mod;
5809            }
5810        }
5811    }
5812    let head_content = fs::read_to_string(git_dir.join("HEAD")).ok()?;
5813    let head_trimmed = head_content.trim();
5814    if head_trimmed.starts_with("ref: ") {
5815        // Use the full ref resolver so packed-refs and worktrees match Git. If `HEAD` is a stale
5816        // symref (e.g. still `refs/heads/master` while only `main` exists), fall back like
5817        // `resolve_gitlink_ref` / `git add` on embedded repos (`t6437-submodule-merge`).
5818        match crate::refs::resolve_ref(&git_dir, "HEAD") {
5819            Ok(oid) => Some(oid),
5820            Err(_) => {
5821                let mut found = None;
5822                for branch in ["main", "master"] {
5823                    let p = git_dir.join("refs/heads").join(branch);
5824                    if let Ok(s) = fs::read_to_string(&p) {
5825                        if let Ok(o) = ObjectId::from_hex(s.trim()) {
5826                            found = Some(o);
5827                            break;
5828                        }
5829                    }
5830                }
5831                found
5832            }
5833        }
5834    } else {
5835        ObjectId::from_hex(head_trimmed).ok()
5836    }
5837}
5838
5839/// True when a populated submodule checkout is *broken*: its `HEAD` resolves to a commit OID, but
5840/// that commit object cannot be read from the submodule's own object database.
5841///
5842/// This mirrors Git's [`is_submodule_modified`], which shells out to `git status --porcelain=2`
5843/// inside the submodule; when the submodule's object store is corrupt (e.g. `rm -r .git/objects`),
5844/// that inner status fails and Git aborts the surrounding `status`/`diff`/`fetch`. We detect the
5845/// same condition in-process so the superproject operation can return a fatal error rather than
5846/// silently treating the submodule as clean (t5526 "fetching submodule into a broken repository").
5847///
5848/// Returns `false` when the submodule is not checked out, has no embedded git dir, or has an
5849/// unresolvable HEAD (those are handled separately by the unpopulated/placeholder logic).
5850#[must_use]
5851pub fn submodule_head_object_broken(sub_dir: &Path) -> bool {
5852    let Some(sub_git_dir) = submodule_embedded_git_dir(sub_dir) else {
5853        return false;
5854    };
5855    let Some(head_oid) = read_submodule_head_oid(sub_dir) else {
5856        return false;
5857    };
5858    let odb = Odb::new(&sub_git_dir.join("objects"));
5859    match odb.read(&head_oid) {
5860        // HEAD object present but not a commit would be a different kind of corruption; only the
5861        // missing-object case is the broken-repo scenario Git guards against here.
5862        Ok(obj) => parse_commit(&obj.data).is_err(),
5863        Err(_) => true,
5864    }
5865}
5866
5867/// True when a checked-out submodule at `rel_path` has modified or untracked content relative to
5868/// the gitlink `recorded_oid` stored in the superproject (used for `git diff <tree>` parity).
5869fn submodule_has_dirty_worktree_for_super_diff(
5870    super_worktree: &Path,
5871    rel_path: &str,
5872    recorded_oid: &ObjectId,
5873) -> bool {
5874    let flags = submodule_porcelain_flags(super_worktree, rel_path, *recorded_oid);
5875    flags.modified || flags.untracked
5876}
5877
5878/// Submodule dirty bits aligned with Git's `DIRTY_SUBMODULE_*` / porcelain v2 `S???` token.
5879#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5880pub struct SubmodulePorcelainFlags {
5881    /// Submodule checkout HEAD differs from the gitlink OID recorded in the parent index.
5882    pub new_commits: bool,
5883    /// The submodule has its own staged or unstaged changes (`DIRTY_SUBMODULE_MODIFIED`).
5884    pub modified: bool,
5885    /// The submodule work tree contains paths not in its index (`DIRTY_SUBMODULE_UNTRACKED`).
5886    pub untracked: bool,
5887}
5888
5889/// Inspect a checked-out submodule at `rel_path` (relative to `super_worktree`) and return
5890/// flags used for `git status --porcelain=v2` submodule tokens.
5891///
5892/// `recorded_oid` is the gitlink OID stored in the **parent** index (stage 0). When the
5893/// submodule is not checked out or cannot be opened, returns [`Default::default()`].
5894pub fn submodule_porcelain_flags(
5895    super_worktree: &Path,
5896    rel_path: &str,
5897    recorded_oid: ObjectId,
5898) -> SubmodulePorcelainFlags {
5899    let sub_dir = super_worktree.join(rel_path);
5900    let Some(sub_git_dir) = submodule_embedded_git_dir(&sub_dir) else {
5901        return SubmodulePorcelainFlags::default();
5902    };
5903    let Some(sub_head) = read_submodule_head_oid(&sub_dir) else {
5904        return SubmodulePorcelainFlags::default();
5905    };
5906
5907    let new_commits = sub_head != recorded_oid;
5908
5909    let index_path = sub_git_dir.join("index");
5910    let sub_index = match crate::index::Index::load(&index_path) {
5911        Ok(ix) => ix,
5912        Err(_) => {
5913            return SubmodulePorcelainFlags {
5914                new_commits,
5915                ..Default::default()
5916            }
5917        }
5918    };
5919
5920    let tracked: std::collections::BTreeSet<String> = sub_index
5921        .entries
5922        .iter()
5923        .filter(|e| e.stage() == 0)
5924        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
5925        .collect();
5926    let untracked = submodule_dir_has_untracked_inner(&sub_dir, &sub_dir, &tracked, &sub_index);
5927
5928    let objects_dir = sub_git_dir.join("objects");
5929    let odb = Odb::new(&objects_dir);
5930
5931    let sub_head_tree = (|| -> Option<ObjectId> {
5932        let h = fs::read_to_string(sub_git_dir.join("HEAD")).ok()?;
5933        let h_str = h.trim();
5934        let commit_oid = if let Some(r) = h_str.strip_prefix("ref: ") {
5935            let oid_hex = fs::read_to_string(sub_git_dir.join(r)).ok()?;
5936            ObjectId::from_hex(oid_hex.trim()).ok()?
5937        } else {
5938            ObjectId::from_hex(h_str).ok()?
5939        };
5940        let obj = odb.read(&commit_oid).ok()?;
5941        let commit = parse_commit(&obj.data).ok()?;
5942        Some(commit.tree)
5943    })();
5944
5945    let staged_dirty = sub_head_tree
5946        .as_ref()
5947        .map(|t| diff_index_to_tree(&odb, &sub_index, Some(t), false).map(|v| !v.is_empty()))
5948        .unwrap_or(Ok(false));
5949    let staged_dirty = staged_dirty.unwrap_or(false);
5950
5951    let unstaged_dirty = diff_index_to_worktree(&odb, &sub_index, &sub_dir, false, true)
5952        .map(|v| !v.is_empty())
5953        .unwrap_or(false);
5954
5955    let mut modified = staged_dirty || unstaged_dirty;
5956
5957    // Nested submodule has its own index: OR `modified` from immediate gitlink children so a
5958    // dirty nested checkout (e.g. staged `file` under `sub1/sub2`) marks the parent gitlink as
5959    // modified in the superproject (t7506). Do **not** OR `untracked` — untracked-only inside a
5960    // nested submodule must stay `S..U` on the parent, not `S.U` / `S.M.`.
5961    for e in &sub_index.entries {
5962        if e.stage() != 0 || e.mode != 0o160000 {
5963            continue;
5964        }
5965        let child = String::from_utf8_lossy(&e.path).into_owned();
5966        let full_rel = if rel_path.is_empty() {
5967            child
5968        } else {
5969            format!("{rel_path}/{child}")
5970        };
5971        let nested = submodule_porcelain_flags(super_worktree, &full_rel, e.oid);
5972        modified |= nested.modified;
5973    }
5974
5975    SubmodulePorcelainFlags {
5976        new_commits,
5977        modified,
5978        untracked,
5979    }
5980}
5981
5982fn submodule_dir_has_untracked_inner(
5983    dir: &Path,
5984    root: &Path,
5985    tracked: &std::collections::BTreeSet<String>,
5986    owning_index: &Index,
5987) -> bool {
5988    let entries = match fs::read_dir(dir) {
5989        Ok(e) => e,
5990        Err(_) => return false,
5991    };
5992    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
5993    sorted.sort_by_key(|e| e.file_name());
5994
5995    for entry in sorted {
5996        let name = entry.file_name().to_string_lossy().to_string();
5997        if name == ".git" {
5998            continue;
5999        }
6000        let path = entry.path();
6001        let rel = path
6002            .strip_prefix(root)
6003            .map(|p| p.to_string_lossy().to_string())
6004            .unwrap_or_else(|_| name.clone());
6005
6006        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
6007        if is_dir {
6008            let is_gitlink = owning_index
6009                .get(rel.as_bytes(), 0)
6010                .is_some_and(|e| e.mode == 0o160000);
6011            if is_gitlink {
6012                let Some(nested_git) = submodule_embedded_git_dir(&path) else {
6013                    continue;
6014                };
6015                let nested_index_path = nested_git.join("index");
6016                let Ok(nested_ix) = crate::index::Index::load(&nested_index_path) else {
6017                    continue;
6018                };
6019                let nested_tracked: std::collections::BTreeSet<String> = nested_ix
6020                    .entries
6021                    .iter()
6022                    .filter(|e| e.stage() == 0)
6023                    .map(|e| String::from_utf8_lossy(&e.path).into_owned())
6024                    .collect();
6025                if submodule_dir_has_untracked_inner(&path, &path, &nested_tracked, &nested_ix) {
6026                    return true;
6027                }
6028            } else if submodule_dir_has_untracked_inner(&path, root, tracked, owning_index) {
6029                return true;
6030            }
6031        } else if !tracked.contains(&rel) {
6032            return true;
6033        }
6034    }
6035    false
6036}
6037
6038/// Reorder diff entries by a `-O<orderfile>` (`diff.orderFile`): entries are sorted by the index of
6039/// the first orderfile glob pattern that matches their path; unmatched entries sort last (stable).
6040pub fn apply_orderfile_entries(
6041    entries: Vec<DiffEntry>,
6042    order_path: &str,
6043    cwd: &Path,
6044) -> Result<Vec<DiffEntry>> {
6045    apply_orderfile(entries, order_path, cwd)
6046}
6047
6048fn apply_orderfile(
6049    mut entries: Vec<DiffEntry>,
6050    order_path: &str,
6051    cwd: &Path,
6052) -> Result<Vec<DiffEntry>> {
6053    let patterns = read_orderfile_patterns(order_path, cwd)?;
6054    let sort_key = |entry: &DiffEntry| -> usize {
6055        let path = entry
6056            .new_path
6057            .as_ref()
6058            .or(entry.old_path.as_ref())
6059            .cloned()
6060            .unwrap_or_default();
6061        for (i, pat) in patterns.iter().enumerate() {
6062            if orderfile_pattern_matches(pat, &path) {
6063                return i;
6064            }
6065        }
6066        patterns.len()
6067    };
6068    entries.sort_by_key(|e| sort_key(e));
6069    Ok(entries)
6070}
6071
6072/// Read non-empty, non-comment glob patterns (one per line) from a `-O<orderfile>` file.
6073pub fn read_orderfile_patterns(order_path: &str, cwd: &Path) -> Result<Vec<String>> {
6074    let path = Path::new(order_path);
6075    let resolved = if path.is_absolute() {
6076        path.to_path_buf()
6077    } else {
6078        cwd.join(path)
6079    };
6080    let _meta = std::fs::metadata(&resolved)
6081        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
6082    let mut f = std::fs::File::open(&resolved)
6083        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
6084    let mut content = String::new();
6085    std::io::Read::read_to_string(&mut f, &mut content)
6086        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
6087    Ok(content
6088        .lines()
6089        .map(|l| l.trim().to_string())
6090        .filter(|l| !l.is_empty() && !l.starts_with('#'))
6091        .collect())
6092}
6093
6094/// Reorder diff entries for `git diff` `--rotate-to` / `--skip-to` (changed paths only).
6095pub fn apply_rotate_skip_entries(
6096    mut entries: Vec<DiffEntry>,
6097    rotate_to: Option<&str>,
6098    skip_to: Option<&str>,
6099) -> Result<Vec<DiffEntry>> {
6100    let Some(needle) = rotate_to.or(skip_to) else {
6101        return Ok(entries);
6102    };
6103    let needle = needle.trim();
6104    if needle.is_empty() {
6105        return Ok(entries);
6106    }
6107    let idx = entries
6108        .iter()
6109        .position(|e| e.path() == needle)
6110        .ok_or_else(|| Error::Message(format!("fatal: No such path '{needle}' in the diff")))?;
6111    if rotate_to.is_some() {
6112        entries.rotate_left(idx);
6113    }
6114    if let Some(skip) = skip_to.filter(|s| !s.trim().is_empty()) {
6115        let pos = entries
6116            .iter()
6117            .position(|e| e.path() == skip)
6118            .ok_or_else(|| Error::Message(format!("fatal: No such path '{skip}' in the diff")))?;
6119        entries.drain(..pos);
6120    }
6121    Ok(entries)
6122}
6123
6124/// `git log` rotate/skip: reorder using the **commit tree** path order (all blobs), then keep only
6125/// paths present in `entries` — matches Git's `diff --rotate-to` with history walks.
6126pub fn apply_rotate_skip_log_entries(
6127    odb: &Odb,
6128    commit_tree: &ObjectId,
6129    entries: Vec<DiffEntry>,
6130    rotate_to: Option<&str>,
6131    skip_to: Option<&str>,
6132) -> Result<Vec<DiffEntry>> {
6133    // Without --rotate-to/--skip-to this is a no-op (the ordered-paths helper
6134    // returns the entries untouched), so skip the full-tree walk that
6135    // `all_blob_paths_in_tree_order` performs — per displayed commit it read
6136    // every subtree of the commit's tree just to discard the result.
6137    fn trimmed(s: Option<&str>) -> Option<&str> {
6138        s.map(str::trim).filter(|t| !t.is_empty())
6139    }
6140    if trimmed(rotate_to).is_none() && trimmed(skip_to).is_none() {
6141        return Ok(entries);
6142    }
6143    let tree_paths = crate::merge_diff::all_blob_paths_in_tree_order(odb, commit_tree);
6144    apply_rotate_skip_ordered_paths(&tree_paths, entries, rotate_to, skip_to)
6145}
6146
6147fn apply_rotate_skip_ordered_paths(
6148    tree_paths: &[String],
6149    entries: Vec<DiffEntry>,
6150    rotate_to: Option<&str>,
6151    skip_to: Option<&str>,
6152) -> Result<Vec<DiffEntry>> {
6153    let rotate = rotate_to.and_then(|s| {
6154        let t = s.trim();
6155        (!t.is_empty()).then_some(t)
6156    });
6157    let skip = skip_to.and_then(|s| {
6158        let t = s.trim();
6159        (!t.is_empty()).then_some(t)
6160    });
6161    if rotate.is_none() && skip.is_none() {
6162        return Ok(entries);
6163    }
6164
6165    use std::collections::HashMap;
6166    let mut by_path: HashMap<String, DiffEntry> = HashMap::new();
6167    for e in entries {
6168        by_path.insert(e.path().to_string(), e);
6169    }
6170
6171    // `git log --skip-to`: only list changed paths from the skip point onward (unmodified paths
6172    // in the tree-order suffix are omitted). `--rotate-to` still lists every changed file in order.
6173    if rotate.is_none() {
6174        let Some(skip_path) = skip else {
6175            return Ok(by_path.into_values().collect());
6176        };
6177        let idx = tree_paths
6178            .iter()
6179            .position(|p| p == skip_path)
6180            .ok_or_else(|| {
6181                Error::Message(format!("fatal: No such path '{skip_path}' in the diff"))
6182            })?;
6183        let mut out = Vec::new();
6184        for p in tree_paths.iter().skip(idx) {
6185            if let Some(e) = by_path.remove(p) {
6186                out.push(e);
6187            }
6188        }
6189        return Ok(out);
6190    }
6191
6192    let Some(needle) = rotate else {
6193        return Ok(by_path.into_values().collect());
6194    };
6195    let idx = tree_paths
6196        .iter()
6197        .position(|p| p == needle)
6198        .ok_or_else(|| Error::Message(format!("fatal: No such path '{needle}' in the diff")))?;
6199    let mut order: Vec<String> = tree_paths.to_vec();
6200    order.rotate_left(idx);
6201    if let Some(skip_path) = skip {
6202        let pos = order.iter().position(|p| p == skip_path).ok_or_else(|| {
6203            Error::Message(format!("fatal: No such path '{skip_path}' in the diff"))
6204        })?;
6205        order.drain(..pos);
6206    }
6207    let mut out = Vec::new();
6208    for p in order {
6209        if let Some(e) = by_path.remove(&p) {
6210            out.push(e);
6211        }
6212    }
6213    Ok(out)
6214}
6215
6216/// Check if an orderfile pattern matches a path (matches the basename or the full path).
6217/// Supports basic glob patterns: `*` matches any sequence, `?` matches one char.
6218pub fn orderfile_pattern_matches(pattern: &str, path: &str) -> bool {
6219    let name = path.rsplit('/').next().unwrap_or(path);
6220    orderfile_glob_match(pattern, name) || orderfile_glob_match(pattern, path)
6221}
6222
6223/// Basic glob matching (supports `*` and `?`).
6224fn orderfile_glob_match(pattern: &str, text: &str) -> bool {
6225    let mut pi = 0;
6226    let mut ti = 0;
6227    let pb = pattern.as_bytes();
6228    let tb = text.as_bytes();
6229    let mut star_pi = usize::MAX;
6230    let mut star_ti = 0;
6231
6232    while ti < tb.len() {
6233        if pi < pb.len() && (pb[pi] == b'?' || pb[pi] == tb[ti]) {
6234            pi += 1;
6235            ti += 1;
6236        } else if pi < pb.len() && pb[pi] == b'*' {
6237            star_pi = pi;
6238            star_ti = ti;
6239            pi += 1;
6240        } else if star_pi != usize::MAX {
6241            pi = star_pi + 1;
6242            star_ti += 1;
6243            ti = star_ti;
6244        } else {
6245            return false;
6246        }
6247    }
6248    while pi < pb.len() && pb[pi] == b'*' {
6249        pi += 1;
6250    }
6251    pi == pb.len()
6252}