Skip to main content

grit_lib/diff/
core.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, grit's own histogram engine for output compatible with
20//! upstream Git.
21//! Output formats: unified patch, raw (`:old-mode new-mode ...`), stat,
22//! numstat.
23
24use crate::git_path::CanonicalizeExt;
25use std::borrow::Cow;
26use std::fs;
27#[cfg(unix)]
28use std::os::unix::fs::MetadataExt;
29use std::path::{Path, PathBuf};
30
31use crate::config::ConfigSet;
32use crate::diff_indent_heuristic;
33use crate::diff_rename_parallel::{
34    diff_rename_parallel_options, inexact_matrix_exceeds_limit, parallel_load_optional_blobs,
35    parallel_map_indices, parse_rename_limit,
36};
37use crate::error::{Error, Result, SubmoduleError};
38use crate::index::{CacheTreeNode, Index, IndexEntry, MODE_SYMLINK, MODE_TREE};
39use crate::objects::{
40    parse_commit, CommitData, FileMode, ObjectId, ObjectKind, TreeEntry, TreeEntryRef, TreeRefIter,
41};
42use crate::odb::{Odb, SessionTree};
43use crate::parallel::ThreadPolicy;
44use crate::repo::Repository;
45use crate::sparse_checkout::SparseCheckout;
46use crate::userdiff::FuncnameMatcher;
47
48use super::rename_lines::RenameLineTable;
49use super::unified_emit::{
50    emit_unified_body_from_edits, line_ending_only_edits, EditSpan, LineSide, UnifiedEmitOptions,
51};
52
53/// Comparison keys for every line of `side`.
54///
55/// Without whitespace normalization a key is the raw line including its `\n` (the same bytes as
56/// `line_diff_token`), borrowed from the side's records. Under `-w`/`-b`/`--ignore-space-at-eol`
57/// the keys are normalized line bodies, kept in `normalized` so the returned slices can borrow
58/// them.
59fn line_compare_keys<'a>(
60    side: &'a LineSide<'a>,
61    ws: super::WhitespaceMode,
62    normalized: &'a mut Vec<Cow<'a, str>>,
63) -> Cow<'a, [&'a str]> {
64    if !ws.normalizes_line_compare() {
65        return Cow::Borrowed(side.records());
66    }
67    // Git -w/-b compare normalized bodies only; EOF newline is whitespace.
68    normalized.extend(
69        side.lines()
70            .map(|line| ws.normalize_line_for_compare(line.body)),
71    );
72    Cow::Owned(normalized.iter().map(Cow::as_ref).collect())
73}
74
75#[path = "../structured_diff.rs"]
76pub mod structured;
77
78pub(crate) fn imara_unified_hunk_slices(body: &str) -> Vec<&str> {
79    let mut starts: Vec<usize> = Vec::new();
80    if body.starts_with("@@") {
81        starts.push(0);
82    }
83    for (idx, _) in body.match_indices("\n@@ ") {
84        starts.push(idx + 1);
85    }
86    starts.push(body.len());
87    starts.windows(2).map(|w| &body[w[0]..w[1]]).collect()
88}
89
90/// Histogram line alignment (Git `xhistogram.c`), then `xdl_change_compact` and indent heuristic.
91pub(crate) fn histogram_compacted_line_ops(
92    old_content: &str,
93    new_content: &str,
94    indent_heuristic: bool,
95    ws: super::WhitespaceMode,
96) -> Vec<similar::DiffOp> {
97    let before = LineSide::from_text(old_content);
98    let after = LineSide::from_text(new_content);
99    let old_lines: Vec<&str> = before.lines().map(|l| l.body).collect();
100    let new_lines: Vec<&str> = after.lines().map(|l| l.body).collect();
101    let old_len = old_lines.len();
102    let new_len = new_lines.len();
103    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
104    let old_compare = line_compare_keys(&before, ws, &mut old_normalized);
105    let new_compare = line_compare_keys(&after, ws, &mut new_normalized);
106
107    let (changed1, changed2) = git_xdiff::histogram_changed_flags(&old_compare, &new_compare);
108
109    if !changed1.iter().any(|c| *c) && !changed2.iter().any(|c| *c) {
110        return Vec::new();
111    }
112    let raw = changed_flags_to_ops(&changed1, &changed2, old_len, new_len);
113    diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
114        &raw,
115        diff_indent_heuristic::CompactSide {
116            lines: &old_lines,
117            keys: &old_compare,
118            compare_ids: None,
119        },
120        diff_indent_heuristic::CompactSide {
121            lines: &new_lines,
122            keys: &new_compare,
123            compare_ids: None,
124        },
125        indent_heuristic,
126        ws,
127        true,
128    )
129}
130
131pub(crate) fn histogram_unified_body_raw(
132    old_content: &str,
133    new_content: &str,
134    context_lines: usize,
135    inter_hunk_context: usize,
136    indent_heuristic: bool,
137    ws: super::WhitespaceMode,
138) -> String {
139    let before_side = LineSide::from_text(old_content);
140    let after_side = LineSide::from_text(new_content);
141    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
142    let mut edits = diff_ops_to_edit_spans(&ops);
143    if edits.is_empty() {
144        if old_content != new_content && !ws.normalizes_line_compare() {
145            edits = line_ending_only_edits(&before_side, &after_side);
146        }
147        if edits.is_empty() {
148            return String::new();
149        }
150    }
151    emit_unified_body_from_edits(
152        &before_side,
153        &after_side,
154        &edits,
155        UnifiedEmitOptions {
156            context_lines,
157            inter_hunk_context,
158            function_context: false,
159        },
160        None,
161    )
162}
163
164/// Build the unified-diff body (no `---`/`+++` header) using Git's histogram
165/// algorithm, but applying `--ignore-blank-lines` / `-I` semantics through a
166/// Git-compatible implementation of the xdiff change-record machinery
167/// (`xdl_mark_ignorable_lines` + `xdl_get_hunk` + `xdl_emit_diff`).
168///
169/// `is_ignorable_change` is called with the slices of removed and added lines
170/// for one change record (each item is the line text without its trailing
171/// newline); it returns `true` when the whole change is ignorable (Git's
172/// `xch->ignore`). Ignorable changes that are far from substantive changes are
173/// dropped, and hunk boundaries are computed with Git's `max_ignorable`
174/// distance rules so the line numbers and surviving context match Git exactly.
175///
176/// Returns the body only; an empty string means no substantive change survives.
177/// Optional `--function-context` (`-W`) hunk expansion is applied *after*
178/// ignorable change-record pruning so the boundaries/funcname header match Git.
179#[allow(clippy::too_many_arguments)]
180fn diff_ops_to_ignorable_edit_spans<F>(
181    ops: &[similar::DiffOp],
182    old_lines: &[&str],
183    new_lines: &[&str],
184    is_ignorable_change: F,
185) -> Vec<EditSpan>
186where
187    F: Fn(&[&str], &[&str]) -> bool,
188{
189    use similar::DiffOp;
190    let mut out = Vec::new();
191    for op in ops {
192        match *op {
193            DiffOp::Equal { .. } => {}
194            DiffOp::Delete {
195                old_index,
196                old_len,
197                new_index,
198            } => {
199                let removed = &old_lines[old_index..old_index + old_len];
200                out.push(EditSpan {
201                    before_start: old_index as i64,
202                    before_len: old_len as i64,
203                    after_start: new_index as i64,
204                    after_len: 0,
205                    skip_in_output: is_ignorable_change(removed, &[]),
206                });
207            }
208            DiffOp::Insert {
209                old_index,
210                new_index,
211                new_len,
212            } => {
213                let added = &new_lines[new_index..new_index + new_len];
214                out.push(EditSpan {
215                    before_start: old_index as i64,
216                    before_len: 0,
217                    after_start: new_index as i64,
218                    after_len: new_len as i64,
219                    skip_in_output: is_ignorable_change(&[], added),
220                });
221            }
222            DiffOp::Replace {
223                old_index,
224                old_len,
225                new_index,
226                new_len,
227            } => {
228                let removed = &old_lines[old_index..old_index + old_len];
229                let added = &new_lines[new_index..new_index + new_len];
230                out.push(EditSpan {
231                    before_start: old_index as i64,
232                    before_len: old_len as i64,
233                    after_start: new_index as i64,
234                    after_len: new_len as i64,
235                    skip_in_output: is_ignorable_change(removed, added),
236                });
237            }
238        }
239    }
240    out
241}
242
243#[allow(clippy::too_many_arguments)]
244pub(crate) fn histogram_unified_body_ignore_fc<F>(
245    old_content: &str,
246    new_content: &str,
247    context_lines: usize,
248    inter_hunk_context: usize,
249    function_context: bool,
250    funcname_matcher: Option<&FuncnameMatcher>,
251    indent_heuristic: bool,
252    ws: super::WhitespaceMode,
253    is_ignorable_change: F,
254) -> String
255where
256    F: Fn(&[&str], &[&str]) -> bool,
257{
258    let before = LineSide::from_text(old_content);
259    let after = LineSide::from_text(new_content);
260    let old_lines: Vec<&str> = before.lines().map(|l| l.body).collect();
261    let new_lines: Vec<&str> = after.lines().map(|l| l.body).collect();
262    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
263    if ops.is_empty() {
264        return String::new();
265    }
266    let edits = diff_ops_to_ignorable_edit_spans(&ops, &old_lines, &new_lines, is_ignorable_change);
267    emit_unified_body_from_edits(
268        &before,
269        &after,
270        &edits,
271        UnifiedEmitOptions {
272            context_lines,
273            inter_hunk_context,
274            function_context,
275        },
276        funcname_matcher,
277    )
278}
279
280/// Line bodies of `side`, as the change compaction reads them.
281fn line_bodies<'a>(side: &LineSide<'a>) -> Vec<&'a str> {
282    side.lines().map(|l| l.body).collect()
283}
284
285/// Count insertions and deletions from compacted line diff ops (patch/stat alignment).
286#[must_use]
287fn count_diff_ops(ops: &[similar::DiffOp]) -> (usize, usize) {
288    use similar::DiffOp;
289    let mut insertions = 0usize;
290    let mut deletions = 0usize;
291    for op in ops {
292        match *op {
293            DiffOp::Equal { .. } => {}
294            DiffOp::Delete { old_len, .. } => deletions += old_len,
295            DiffOp::Insert { new_len, .. } => insertions += new_len,
296            DiffOp::Replace {
297                old_len, new_len, ..
298            } => {
299                deletions += old_len;
300                insertions += new_len;
301            }
302        }
303    }
304    (insertions, deletions)
305}
306
307/// Line alignment ops shared by unified patches, `--numstat`, and `-G`.
308pub(crate) fn compacted_line_ops_for_options(
309    old_content: &str,
310    new_content: &str,
311    options: &crate::diff::options::DiffOptions,
312) -> Vec<similar::DiffOp> {
313    use crate::diff::options::DiffAlgorithm;
314
315    let ws = options.whitespace;
316    match options.algorithm {
317        DiffAlgorithm::Histogram => {
318            histogram_compacted_line_ops(old_content, new_content, options.indent_heuristic, ws)
319        }
320        DiffAlgorithm::Patience => {
321            let before = LineSide::from_text(old_content);
322            let after = LineSide::from_text(new_content);
323            if before.is_empty() && after.is_empty() {
324                return Vec::new();
325            }
326            let old_refs: Vec<&str> = before.lines().map(|l| l.body).collect();
327            let new_refs: Vec<&str> = after.lines().map(|l| l.body).collect();
328            let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
329            let old_compare = line_compare_keys(&before, ws, &mut old_normalized);
330            let new_compare = line_compare_keys(&after, ws, &mut new_normalized);
331            let (changed1, changed2) =
332                git_xdiff::patience_changed_flags(&old_compare, &new_compare);
333            let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
334            diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
335                &raw,
336                diff_indent_heuristic::CompactSide {
337                    lines: &old_refs,
338                    keys: &old_compare,
339                    compare_ids: None,
340                },
341                diff_indent_heuristic::CompactSide {
342                    lines: &new_refs,
343                    keys: &new_compare,
344                    compare_ids: None,
345                },
346                options.indent_heuristic,
347                ws,
348                false,
349            )
350        }
351        DiffAlgorithm::Myers | DiffAlgorithm::Minimal => {
352            let before = LineSide::from_text(old_content);
353            let after = LineSide::from_text(new_content);
354            if before.is_empty() && after.is_empty() {
355                return Vec::new();
356            }
357            let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
358            let old_compare = line_compare_keys(&before, ws, &mut old_normalized);
359            let new_compare = line_compare_keys(&after, ws, &mut new_normalized);
360            git_xdiff_compacted_line_ops(
361                &old_compare,
362                &new_compare,
363                &before,
364                &after,
365                ws,
366                options.indent_heuristic,
367                options.algorithm == DiffAlgorithm::Minimal,
368            )
369        }
370    }
371}
372
373/// Count changed lines using the same alignment as structured patches and `--numstat`.
374#[must_use]
375pub(crate) fn count_line_changes_with_options(
376    old_content: &str,
377    new_content: &str,
378    options: &crate::diff::options::DiffOptions,
379) -> (usize, usize) {
380    let ws = options.whitespace;
381    let ops = compacted_line_ops_for_options(old_content, new_content, options);
382    let (mut insertions, mut deletions) = count_diff_ops(&ops);
383    if insertions == 0
384        && deletions == 0
385        && old_content != new_content
386        && !ws.normalizes_line_compare()
387    {
388        let before = LineSide::from_text(old_content);
389        let after = LineSide::from_text(new_content);
390        for edit in line_ending_only_edits(&before, &after) {
391            insertions += edit.after_len as usize;
392            deletions += edit.before_len as usize;
393        }
394    }
395    (insertions, deletions)
396}
397
398/// Myers changed flags (via [`git_xdiff::changed_flags_with_shared`]) plus interned compare keys.
399type XdiffTrimmedIds = ((Vec<bool>, Vec<bool>), Vec<u32>, Vec<u32>);
400
401fn xdiff_changed_flags_trimmed_ids(
402    old_compare: &[&str],
403    new_compare: &[&str],
404    need_min: bool,
405) -> XdiffTrimmedIds {
406    use crate::line_interner::LineInterner;
407
408    let nrec1 = old_compare.len();
409    let nrec2 = new_compare.len();
410    if nrec1 == 0 && nrec2 == 0 {
411        return ((Vec::new(), Vec::new()), Vec::new(), Vec::new());
412    }
413    let mut interner = LineInterner::with_capacity(nrec1 + nrec2);
414    let ids1_full = interner.intern_all(old_compare);
415    let ids2_full = interner.intern_all(new_compare);
416    let nclasses = interner.len();
417    // Only the ids are needed from here on; freeing the table before the search keeps it out of
418    // the diff's peak memory.
419    drop(interner);
420
421    let lim = nrec1.min(nrec2);
422    let mut head = 0usize;
423    while head < lim && ids1_full[head] == ids2_full[head] {
424        head += 1;
425    }
426    let mut tail = 0usize;
427    let rem = lim - head;
428    while tail < rem && ids1_full[nrec1 - 1 - tail] == ids2_full[nrec2 - 1 - tail] {
429        tail += 1;
430    }
431    let shared_records = head + tail;
432    let (full1, full2) = if shared_records == 0 {
433        git_xdiff::changed_flags(&ids1_full, &ids2_full, need_min)
434    } else {
435        let cut_ids1 = &ids1_full[head..nrec1 - tail];
436        let cut_ids2 = &ids2_full[head..nrec2 - tail];
437        let mut shared_counts = vec![0u32; nclasses];
438        for &id in &ids1_full[..head] {
439            shared_counts[id as usize] += 1;
440        }
441        for &id in &ids1_full[nrec1 - tail..] {
442            shared_counts[id as usize] += 1;
443        }
444        let (mid1, mid2) = git_xdiff::changed_flags_with_shared(
445            cut_ids1,
446            cut_ids2,
447            &shared_counts,
448            shared_records,
449            need_min,
450        );
451        let mut full1 = vec![false; nrec1];
452        let mut full2 = vec![false; nrec2];
453        if !mid1.is_empty() {
454            full1[head..nrec1 - tail].copy_from_slice(&mid1);
455        }
456        if !mid2.is_empty() {
457            full2[head..nrec2 - tail].copy_from_slice(&mid2);
458        }
459        (full1, full2)
460    };
461    ((full1, full2), ids1_full, ids2_full)
462}
463
464fn xdiff_changed_flags_trimmed_preinterned(
465    ids1_full: &[u32],
466    ids2_full: &[u32],
467    need_min: bool,
468) -> (Vec<bool>, Vec<bool>) {
469    let nrec1 = ids1_full.len();
470    let nrec2 = ids2_full.len();
471    if nrec1 == 0 && nrec2 == 0 {
472        return (Vec::new(), Vec::new());
473    }
474
475    let lim = nrec1.min(nrec2);
476    let mut head = 0usize;
477    while head < lim && ids1_full[head] == ids2_full[head] {
478        head += 1;
479    }
480    let mut tail = 0usize;
481    let rem = lim - head;
482    while tail < rem && ids1_full[nrec1 - 1 - tail] == ids2_full[nrec2 - 1 - tail] {
483        tail += 1;
484    }
485    let shared_records = head + tail;
486    if shared_records == 0 {
487        return git_xdiff::changed_flags(ids1_full, ids2_full, need_min);
488    }
489    let cut_ids1 = &ids1_full[head..nrec1 - tail];
490    let cut_ids2 = &ids2_full[head..nrec2 - tail];
491    let nclasses = ids1_full
492        .iter()
493        .chain(ids2_full)
494        .max()
495        .map_or(0, |&max| max as usize + 1);
496    let mut shared_counts = vec![0u32; nclasses];
497    for &id in &ids1_full[..head] {
498        shared_counts[id as usize] += 1;
499    }
500    for &id in &ids1_full[nrec1 - tail..] {
501        shared_counts[id as usize] += 1;
502    }
503    let (mid1, mid2) = git_xdiff::changed_flags_with_shared(
504        cut_ids1,
505        cut_ids2,
506        &shared_counts,
507        shared_records,
508        need_min,
509    );
510    let mut full1 = vec![false; nrec1];
511    let mut full2 = vec![false; nrec2];
512    if !mid1.is_empty() {
513        full1[head..nrec1 - tail].copy_from_slice(&mid1);
514    }
515    if !mid2.is_empty() {
516        full2[head..nrec2 - tail].copy_from_slice(&mid2);
517    }
518    (full1, full2)
519}
520
521fn xdiff_changed_flags_trimmed_byte_ids(
522    old_compare: &[Vec<u8>],
523    new_compare: &[Vec<u8>],
524    need_min: bool,
525) -> XdiffTrimmedIds {
526    use crate::line_interner::ByteLineInterner;
527
528    let nrec1 = old_compare.len();
529    let nrec2 = new_compare.len();
530    if nrec1 == 0 && nrec2 == 0 {
531        return ((Vec::new(), Vec::new()), Vec::new(), Vec::new());
532    }
533    let mut interner = ByteLineInterner::with_capacity(nrec1 + nrec2);
534    let ids1_full = interner.intern_all_vecs(old_compare);
535    let ids2_full = interner.intern_all_vecs(new_compare);
536    let (full1, full2) = xdiff_changed_flags_trimmed_preinterned(&ids1_full, &ids2_full, need_min);
537    ((full1, full2), ids1_full, ids2_full)
538}
539
540/// Intern compare keys for base and both merge sides in one pass (shared token ids).
541pub(crate) fn intern_merge_compare_three_way(
542    base: &[Vec<u8>],
543    ours: &[Vec<u8>],
544    theirs: &[Vec<u8>],
545) -> (Vec<u32>, Vec<u32>, Vec<u32>) {
546    use crate::line_interner::ByteLineInterner;
547
548    let mut interner = ByteLineInterner::with_capacity(base.len() + ours.len() + theirs.len());
549    (
550        interner.intern_all_vecs(base),
551        interner.intern_all_vecs(ours),
552        interner.intern_all_vecs(theirs),
553    )
554}
555
556pub(crate) fn merge_myops_from_preinterned_ids(
557    old_ids: &[u32],
558    new_ids: &[u32],
559    need_min: bool,
560) -> Vec<similar::DiffOp> {
561    const INDENT_HEURISTIC: bool = false;
562    let (changed1, changed2) = xdiff_changed_flags_trimmed_preinterned(old_ids, new_ids, need_min);
563    let old_lines = merge_dummy_line_refs(old_ids.len());
564    let new_lines = merge_dummy_line_refs(new_ids.len());
565    diff_indent_heuristic::apply_change_compact_to_masks(
566        changed1,
567        changed2,
568        diff_indent_heuristic::CompactSide {
569            lines: &old_lines,
570            keys: &old_lines,
571            compare_ids: Some(old_ids),
572        },
573        diff_indent_heuristic::CompactSide {
574            lines: &new_lines,
575            keys: &new_lines,
576            compare_ids: Some(new_ids),
577        },
578        INDENT_HEURISTIC,
579    )
580}
581
582fn merge_dummy_line_refs(len: usize) -> Vec<&'static str> {
583    vec![""; len]
584}
585
586enum MergeFileCompactAlgorithm {
587    Myers { need_min: bool },
588    Histogram,
589    Patience,
590}
591
592fn parse_merge_file_compact_algorithm(name: Option<&str>) -> MergeFileCompactAlgorithm {
593    match name.map(str::to_lowercase).as_deref() {
594        None | Some("myers") => MergeFileCompactAlgorithm::Myers { need_min: false },
595        Some("minimal") => MergeFileCompactAlgorithm::Myers { need_min: true },
596        Some("histogram") => MergeFileCompactAlgorithm::Histogram,
597        Some("patience") => MergeFileCompactAlgorithm::Patience,
598        Some(_) => MergeFileCompactAlgorithm::Myers { need_min: false },
599    }
600}
601
602/// Compacted line alignment for three-way merge (base↔side diffs and conflict refinement).
603///
604/// Compare keys must already follow merge `xdl_recmatch` rules. Neither merge-file nor merge-ort
605/// enables the indent heuristic; change compaction still slides groups against the other side.
606pub(crate) fn merge_file_compacted_line_ops(
607    old_compare: &[Vec<u8>],
608    new_compare: &[Vec<u8>],
609    diff_algorithm: Option<&str>,
610) -> Vec<similar::DiffOp> {
611    use crate::diff::git_histogram::histogram_changed_flags_bytes;
612    use crate::line_interner::ByteLineInterner;
613
614    const INDENT_HEURISTIC: bool = false;
615    let algo = parse_merge_file_compact_algorithm(diff_algorithm);
616    match algo {
617        MergeFileCompactAlgorithm::Myers { need_min } => {
618            let ((_, _), old_key_ids, new_key_ids) =
619                xdiff_changed_flags_trimmed_byte_ids(old_compare, new_compare, need_min);
620            merge_myops_from_preinterned_ids(&old_key_ids, &new_key_ids, need_min)
621        }
622        MergeFileCompactAlgorithm::Histogram | MergeFileCompactAlgorithm::Patience => {
623            let old_refs: Vec<&[u8]> = old_compare.iter().map(|line| line.as_slice()).collect();
624            let new_refs: Vec<&[u8]> = new_compare.iter().map(|line| line.as_slice()).collect();
625            let mut interner =
626                ByteLineInterner::with_capacity(old_compare.len() + new_compare.len());
627            let old_key_ids = interner.intern_all_vecs(old_compare);
628            let new_key_ids = interner.intern_all_vecs(new_compare);
629            let histogram = matches!(algo, MergeFileCompactAlgorithm::Histogram);
630            let (changed1, changed2) = if histogram {
631                histogram_changed_flags_bytes(&old_refs, &new_refs, |c1, c2, o0, o1, n0, n1| {
632                    git_xdiff::merge_myers_region_bytes(
633                        &old_refs, &new_refs, c1, c2, o0, o1, n0, n1,
634                    );
635                })
636            } else {
637                git_xdiff::patience_changed_flags_bytes(&old_refs, &new_refs)
638            };
639            let raw =
640                changed_flags_to_ops(&changed1, &changed2, old_compare.len(), new_compare.len());
641            let old_lines = merge_dummy_line_refs(old_compare.len());
642            let new_lines = merge_dummy_line_refs(new_compare.len());
643            diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
644                &raw,
645                diff_indent_heuristic::CompactSide {
646                    lines: &old_lines,
647                    keys: &old_lines,
648                    compare_ids: Some(&old_key_ids),
649                },
650                diff_indent_heuristic::CompactSide {
651                    lines: &new_lines,
652                    keys: &new_lines,
653                    compare_ids: Some(&new_key_ids),
654                },
655                INDENT_HEURISTIC,
656                super::WhitespaceMode::default(),
657                histogram,
658            )
659        }
660    }
661}
662
663/// Myers (or minimal) line alignment with Git's change compaction and indent heuristic.
664///
665/// `old_compare` / `new_compare` must be [`line_compare_keys`] of `before` / `after` under `ws`.
666pub(crate) fn git_xdiff_compacted_line_ops(
667    old_compare: &[&str],
668    new_compare: &[&str],
669    before: &LineSide<'_>,
670    after: &LineSide<'_>,
671    ws: super::WhitespaceMode,
672    indent_heuristic: bool,
673    need_min: bool,
674) -> Vec<similar::DiffOp> {
675    let ((changed1, changed2), old_key_ids, new_key_ids) =
676        xdiff_changed_flags_trimmed_ids(old_compare, new_compare, need_min);
677    // Compaction reads line bodies only to measure indentation. A raw key is the body plus its
678    // newline, which that measure skips as trailing whitespace, so only normalized keys need
679    // the bodies collected separately.
680    let bodies;
681    let (old_lines, new_lines): (&[&str], &[&str]) = if ws.normalizes_line_compare() {
682        bodies = (line_bodies(before), line_bodies(after));
683        (&bodies.0, &bodies.1)
684    } else {
685        (old_compare, new_compare)
686    };
687    diff_indent_heuristic::apply_change_compact_to_masks(
688        changed1,
689        changed2,
690        diff_indent_heuristic::CompactSide {
691            lines: old_lines,
692            keys: old_compare,
693            compare_ids: Some(&old_key_ids),
694        },
695        diff_indent_heuristic::CompactSide {
696            lines: new_lines,
697            keys: new_compare,
698            compare_ids: Some(&new_key_ids),
699        },
700        indent_heuristic,
701    )
702}
703
704fn diff_ops_to_edit_spans(ops: &[similar::DiffOp]) -> Vec<EditSpan> {
705    use similar::DiffOp;
706    let mut out = Vec::new();
707    for op in ops {
708        match *op {
709            DiffOp::Equal { .. } => {}
710            DiffOp::Delete {
711                old_index,
712                old_len,
713                new_index,
714            } => out.push(EditSpan {
715                before_start: old_index as i64,
716                before_len: old_len as i64,
717                after_start: new_index as i64,
718                after_len: 0,
719                skip_in_output: false,
720            }),
721            DiffOp::Insert {
722                old_index,
723                new_index,
724                new_len,
725            } => out.push(EditSpan {
726                before_start: old_index as i64,
727                before_len: 0,
728                after_start: new_index as i64,
729                after_len: new_len as i64,
730                skip_in_output: false,
731            }),
732            DiffOp::Replace {
733                old_index,
734                old_len,
735                new_index,
736                new_len,
737            } => out.push(EditSpan {
738                before_start: old_index as i64,
739                before_len: old_len as i64,
740                after_start: new_index as i64,
741                after_len: new_len as i64,
742                skip_in_output: false,
743            }),
744        }
745    }
746    out
747}
748
749/// Unified hunk body using Git's xdiff engine (no `---` / `+++`).
750fn git_xdiff_unified_body_raw(
751    old_content: &str,
752    new_content: &str,
753    context_lines: usize,
754    inter_hunk_context: usize,
755    indent_heuristic: bool,
756    need_min: bool,
757    ws: super::WhitespaceMode,
758) -> String {
759    git_xdiff_unified_body_raw_with_ignorable(
760        old_content,
761        new_content,
762        context_lines,
763        inter_hunk_context,
764        indent_heuristic,
765        need_min,
766        ws,
767        None::<fn(&[&str], &[&str]) -> bool>,
768    )
769}
770
771#[allow(clippy::too_many_arguments)]
772fn git_xdiff_unified_body_raw_with_ignorable<F>(
773    old_content: &str,
774    new_content: &str,
775    context_lines: usize,
776    inter_hunk_context: usize,
777    indent_heuristic: bool,
778    need_min: bool,
779    ws: super::WhitespaceMode,
780    is_ignorable_change: Option<F>,
781) -> String
782where
783    F: Fn(&[&str], &[&str]) -> bool,
784{
785    let before = LineSide::from_text(old_content);
786    let after = LineSide::from_text(new_content);
787    if before.is_empty() && after.is_empty() {
788        return String::new();
789    }
790    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
791    let old_compare = line_compare_keys(&before, ws, &mut old_normalized);
792    let new_compare = line_compare_keys(&after, ws, &mut new_normalized);
793    let ops = git_xdiff_compacted_line_ops(
794        &old_compare,
795        &new_compare,
796        &before,
797        &after,
798        ws,
799        indent_heuristic,
800        need_min,
801    );
802    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
803        diff_ops_to_ignorable_edit_spans(
804            &ops,
805            &line_bodies(&before),
806            &line_bodies(&after),
807            is_ignorable,
808        )
809    } else {
810        diff_ops_to_edit_spans(&ops)
811    };
812    if edits.is_empty() {
813        if old_content != new_content && !ws.normalizes_line_compare() {
814            edits = line_ending_only_edits(&before, &after);
815        }
816        if edits.is_empty() {
817            return String::new();
818        }
819    }
820    emit_unified_body_from_edits(
821        &before,
822        &after,
823        &edits,
824        UnifiedEmitOptions {
825            context_lines,
826            inter_hunk_context,
827            function_context: false,
828        },
829        None,
830    )
831}
832
833#[allow(clippy::too_many_arguments)]
834fn git_patience_unified_body_raw_with_ignorable<F>(
835    old_content: &str,
836    new_content: &str,
837    context_lines: usize,
838    inter_hunk_context: usize,
839    indent_heuristic: bool,
840    ws: super::WhitespaceMode,
841    is_ignorable_change: Option<F>,
842) -> String
843where
844    F: Fn(&[&str], &[&str]) -> bool,
845{
846    let before = LineSide::from_text(old_content);
847    let after = LineSide::from_text(new_content);
848    if before.is_empty() && after.is_empty() {
849        return String::new();
850    }
851    let old_refs: Vec<&str> = before.lines().map(|l| l.body).collect();
852    let new_refs: Vec<&str> = after.lines().map(|l| l.body).collect();
853    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
854    let old_compare = line_compare_keys(&before, ws, &mut old_normalized);
855    let new_compare = line_compare_keys(&after, ws, &mut new_normalized);
856    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
857    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
858    let ops = diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
859        &raw,
860        diff_indent_heuristic::CompactSide {
861            lines: &old_refs,
862            keys: &old_compare,
863            compare_ids: None,
864        },
865        diff_indent_heuristic::CompactSide {
866            lines: &new_refs,
867            keys: &new_compare,
868            compare_ids: None,
869        },
870        indent_heuristic,
871        ws,
872        false,
873    );
874    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
875        diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
876    } else {
877        diff_ops_to_edit_spans(&ops)
878    };
879    if edits.is_empty() {
880        if old_content != new_content && !ws.normalizes_line_compare() {
881            edits = line_ending_only_edits(&before, &after);
882        }
883        if edits.is_empty() {
884            return String::new();
885        }
886    }
887    emit_unified_body_from_edits(
888        &before,
889        &after,
890        &edits,
891        UnifiedEmitOptions {
892            context_lines,
893            inter_hunk_context,
894            function_context: false,
895        },
896        None,
897    )
898}
899
900fn blank_line_ignorable(ws: super::WhitespaceMode) -> impl Fn(&[&str], &[&str]) -> bool + Copy {
901    move |removed: &[&str], added: &[&str]| {
902        ws.ignore_blank_lines
903            && removed.iter().all(|l| crate::ws::ws_blank_line(l))
904            && added.iter().all(|l| crate::ws::ws_blank_line(l))
905    }
906}
907
908/// Unified hunk bodies (`@@` …) for [`DiffOptions`], shared by structured and log-patch output.
909pub(crate) fn unified_hunk_body_for_diff_options(
910    old_content: &str,
911    new_content: &str,
912    options: &crate::diff::options::DiffOptions,
913) -> String {
914    use crate::diff::options::DiffAlgorithm;
915
916    let ws = options.whitespace;
917    if ws.is_active() {
918        let blank_only = blank_line_ignorable(ws);
919        if ws.normalizes_line_compare() {
920            let ignorable = ws.ignore_blank_lines.then_some(blank_only);
921            return match options.algorithm {
922                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
923                    old_content,
924                    new_content,
925                    options.context_lines,
926                    options.inter_hunk_context,
927                    options.indent_heuristic,
928                    false,
929                    ws,
930                    ignorable,
931                ),
932                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
933                    old_content,
934                    new_content,
935                    options.context_lines,
936                    options.inter_hunk_context,
937                    options.indent_heuristic,
938                    true,
939                    ws,
940                    ignorable,
941                ),
942                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
943                    old_content,
944                    new_content,
945                    options.context_lines,
946                    options.inter_hunk_context,
947                    options.indent_heuristic,
948                    ws,
949                    ignorable,
950                ),
951                DiffAlgorithm::Histogram => {
952                    if ws.ignore_blank_lines {
953                        histogram_unified_body_ignore_fc(
954                            old_content,
955                            new_content,
956                            options.context_lines,
957                            options.inter_hunk_context,
958                            false,
959                            None,
960                            options.indent_heuristic,
961                            ws,
962                            blank_only,
963                        )
964                    } else {
965                        histogram_unified_body_raw(
966                            old_content,
967                            new_content,
968                            options.context_lines,
969                            options.inter_hunk_context,
970                            options.indent_heuristic,
971                            ws,
972                        )
973                    }
974                }
975            };
976        }
977        if ws.ignore_blank_lines {
978            return match options.algorithm {
979                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
980                    old_content,
981                    new_content,
982                    options.context_lines,
983                    options.inter_hunk_context,
984                    options.indent_heuristic,
985                    false,
986                    ws,
987                    Some(blank_only),
988                ),
989                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
990                    old_content,
991                    new_content,
992                    options.context_lines,
993                    options.inter_hunk_context,
994                    options.indent_heuristic,
995                    true,
996                    ws,
997                    Some(blank_only),
998                ),
999                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
1000                    old_content,
1001                    new_content,
1002                    options.context_lines,
1003                    options.inter_hunk_context,
1004                    options.indent_heuristic,
1005                    ws,
1006                    Some(blank_only),
1007                ),
1008                DiffAlgorithm::Histogram => histogram_unified_body_ignore_fc(
1009                    old_content,
1010                    new_content,
1011                    options.context_lines,
1012                    options.inter_hunk_context,
1013                    false,
1014                    None,
1015                    options.indent_heuristic,
1016                    ws,
1017                    blank_only,
1018                ),
1019            };
1020        }
1021    }
1022    match options.algorithm {
1023        DiffAlgorithm::Myers => git_xdiff_unified_body_raw(
1024            old_content,
1025            new_content,
1026            options.context_lines,
1027            options.inter_hunk_context,
1028            options.indent_heuristic,
1029            false,
1030            ws,
1031        ),
1032        DiffAlgorithm::Minimal => git_xdiff_unified_body_raw(
1033            old_content,
1034            new_content,
1035            options.context_lines,
1036            options.inter_hunk_context,
1037            options.indent_heuristic,
1038            true,
1039            ws,
1040        ),
1041        DiffAlgorithm::Patience => git_patience_unified_body_raw(
1042            old_content,
1043            new_content,
1044            options.context_lines,
1045            options.inter_hunk_context,
1046            options.indent_heuristic,
1047        ),
1048        DiffAlgorithm::Histogram => histogram_unified_body_raw(
1049            old_content,
1050            new_content,
1051            options.context_lines,
1052            options.inter_hunk_context,
1053            options.indent_heuristic,
1054            ws,
1055        ),
1056    }
1057}
1058
1059/// Unified hunk body using Git's patience diff (no `---` / `+++`).
1060fn git_patience_unified_body_raw(
1061    old_content: &str,
1062    new_content: &str,
1063    context_lines: usize,
1064    inter_hunk_context: usize,
1065    indent_heuristic: bool,
1066) -> String {
1067    let before = LineSide::from_text(old_content);
1068    let after = LineSide::from_text(new_content);
1069    if before.is_empty() && after.is_empty() {
1070        return String::new();
1071    }
1072    let old_refs: Vec<&str> = before.lines().map(|l| l.body).collect();
1073    let new_refs: Vec<&str> = after.lines().map(|l| l.body).collect();
1074    let ws = super::WhitespaceMode::default();
1075    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
1076    let old_compare = line_compare_keys(&before, ws, &mut old_normalized);
1077    let new_compare = line_compare_keys(&after, ws, &mut new_normalized);
1078    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
1079    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
1080    let ops = diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
1081        &raw,
1082        diff_indent_heuristic::CompactSide {
1083            lines: &old_refs,
1084            keys: &old_compare,
1085            compare_ids: None,
1086        },
1087        diff_indent_heuristic::CompactSide {
1088            lines: &new_refs,
1089            keys: &new_compare,
1090            compare_ids: None,
1091        },
1092        indent_heuristic,
1093        ws,
1094        false,
1095    );
1096    let mut edits = diff_ops_to_edit_spans(&ops);
1097    if edits.is_empty() {
1098        if old_content != new_content {
1099            edits = line_ending_only_edits(&before, &after);
1100        }
1101        if edits.is_empty() {
1102            return String::new();
1103        }
1104    }
1105    emit_unified_body_from_edits(
1106        &before,
1107        &after,
1108        &edits,
1109        UnifiedEmitOptions {
1110            context_lines,
1111            inter_hunk_context,
1112            function_context: false,
1113        },
1114        None,
1115    )
1116}
1117
1118/// Parse `@@ -old +new @@` into 1-based starts and line counts (Git display form).
1119pub(crate) fn unified_hunk_header_counts(
1120    header_line: &str,
1121) -> Option<(usize, usize, usize, usize)> {
1122    let rest = header_line.strip_prefix("@@ ")?;
1123    let (old_part, rest2) = rest.split_once(" +")?;
1124    let old_tok = old_part.strip_prefix('-')?.split(' ').next()?;
1125    let new_tok = rest2.split(" @@").next()?.split(' ').next()?;
1126
1127    fn parse_side(spec: &str) -> (usize, usize) {
1128        if let Some((start, count)) = spec.split_once(',') {
1129            (start.parse().unwrap_or(0), count.parse().unwrap_or(0))
1130        } else {
1131            (spec.parse().unwrap_or(0), 1)
1132        }
1133    }
1134
1135    let (os, ol) = parse_side(old_tok);
1136    let (ns, nl) = parse_side(new_tok);
1137    Some((os, ol, ns, nl))
1138}
1139
1140/// Write `---` / `+++` and unified hunks using [`unified_hunk_body_for_diff_options`].
1141#[allow(clippy::too_many_arguments)]
1142pub(crate) fn write_unified_diff_from_diff_options<W: std::io::Write>(
1143    out: &mut W,
1144    old_content: &str,
1145    new_content: &str,
1146    old_path: &str,
1147    new_path: &str,
1148    src_prefix: &str,
1149    dst_prefix: &str,
1150    funcname_matcher: Option<&FuncnameMatcher>,
1151    options: &crate::diff::options::DiffOptions,
1152    quote_path_fully: bool,
1153) -> Result<()> {
1154    use crate::quote_path::format_diff_path_with_prefix;
1155
1156    if old_path == "/dev/null" {
1157        out.write_all(b"--- /dev/null\n")?;
1158    } else if src_prefix.is_empty() {
1159        writeln!(out, "--- {old_path}")?;
1160    } else {
1161        out.write_all(b"--- ")?;
1162        out.write_all(
1163            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
1164        )?;
1165        out.write_all(b"\n")?;
1166    }
1167    if new_path == "/dev/null" {
1168        out.write_all(b"+++ /dev/null\n")?;
1169    } else if dst_prefix.is_empty() {
1170        writeln!(out, "+++ {new_path}")?;
1171    } else {
1172        out.write_all(b"+++ ")?;
1173        out.write_all(
1174            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
1175        )?;
1176        out.write_all(b"\n")?;
1177    }
1178
1179    let body = unified_hunk_body_for_diff_options(old_content, new_content, options);
1180    if body.is_empty() {
1181        return Ok(());
1182    }
1183    let old_lines: Vec<&str> = old_content.lines().collect();
1184    for hunk_str in imara_unified_hunk_slices(&body) {
1185        if hunk_str.is_empty() {
1186            continue;
1187        }
1188        if let Some(first_newline) = hunk_str.find('\n') {
1189            let header_line = &hunk_str[..first_newline];
1190            let rest = &hunk_str[first_newline..];
1191            if let Some(func_ctx) =
1192                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1193            {
1194                out.write_all(header_line.as_bytes())?;
1195                out.write_all(b" ")?;
1196                out.write_all(func_ctx.as_bytes())?;
1197                out.write_all(rest.as_bytes())?;
1198            } else {
1199                out.write_all(hunk_str.as_bytes())?;
1200            }
1201        } else {
1202            out.write_all(hunk_str.as_bytes())?;
1203        }
1204    }
1205    Ok(())
1206}
1207
1208#[expect(clippy::too_many_arguments)]
1209fn unified_diff_git_myers_with_prefix_and_funcname(
1210    old_content: &str,
1211    new_content: &str,
1212    old_path: &str,
1213    new_path: &str,
1214    context_lines: usize,
1215    inter_hunk_context: usize,
1216    src_prefix: &str,
1217    dst_prefix: &str,
1218    funcname_matcher: Option<&FuncnameMatcher>,
1219    indent_heuristic: bool,
1220    quote_path_fully: bool,
1221) -> String {
1222    use crate::quote_path::format_diff_path_with_prefix;
1223
1224    let body = git_xdiff_unified_body_raw(
1225        old_content,
1226        new_content,
1227        context_lines,
1228        inter_hunk_context,
1229        indent_heuristic,
1230        false,
1231        super::WhitespaceMode::default(),
1232    );
1233
1234    let mut output = String::new();
1235    if old_path == "/dev/null" {
1236        output.push_str("--- /dev/null\n");
1237    } else if src_prefix.is_empty() {
1238        output.push_str(&format!("--- {old_path}\n"));
1239    } else {
1240        output.push_str("--- ");
1241        output.push_str(&format_diff_path_with_prefix(
1242            src_prefix,
1243            old_path,
1244            quote_path_fully,
1245        ));
1246        output.push('\n');
1247    }
1248    if new_path == "/dev/null" {
1249        output.push_str("+++ /dev/null\n");
1250    } else if dst_prefix.is_empty() {
1251        output.push_str(&format!("+++ {new_path}\n"));
1252    } else {
1253        output.push_str("+++ ");
1254        output.push_str(&format_diff_path_with_prefix(
1255            dst_prefix,
1256            new_path,
1257            quote_path_fully,
1258        ));
1259        output.push('\n');
1260    }
1261
1262    let old_lines: Vec<&str> = old_content.lines().collect();
1263    for hunk_str in imara_unified_hunk_slices(&body) {
1264        if hunk_str.is_empty() {
1265            continue;
1266        }
1267        if let Some(first_newline) = hunk_str.find('\n') {
1268            let header_line = &hunk_str[..first_newline];
1269            let rest = &hunk_str[first_newline..];
1270            if let Some(func_ctx) =
1271                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1272            {
1273                output.push_str(header_line);
1274                output.push(' ');
1275                output.push_str(&func_ctx);
1276                output.push_str(rest);
1277            } else {
1278                output.push_str(hunk_str);
1279            }
1280        } else {
1281            output.push_str(hunk_str);
1282        }
1283    }
1284
1285    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1286}
1287
1288#[expect(clippy::too_many_arguments)]
1289fn unified_diff_git_minimal_with_prefix_and_funcname(
1290    old_content: &str,
1291    new_content: &str,
1292    old_path: &str,
1293    new_path: &str,
1294    context_lines: usize,
1295    inter_hunk_context: usize,
1296    src_prefix: &str,
1297    dst_prefix: &str,
1298    funcname_matcher: Option<&FuncnameMatcher>,
1299    indent_heuristic: bool,
1300    quote_path_fully: bool,
1301) -> String {
1302    use crate::quote_path::format_diff_path_with_prefix;
1303
1304    let body = git_xdiff_unified_body_raw(
1305        old_content,
1306        new_content,
1307        context_lines,
1308        inter_hunk_context,
1309        indent_heuristic,
1310        true,
1311        super::WhitespaceMode::default(),
1312    );
1313
1314    let mut output = String::new();
1315    if old_path == "/dev/null" {
1316        output.push_str("--- /dev/null\n");
1317    } else if src_prefix.is_empty() {
1318        output.push_str(&format!("--- {old_path}\n"));
1319    } else {
1320        output.push_str("--- ");
1321        output.push_str(&format_diff_path_with_prefix(
1322            src_prefix,
1323            old_path,
1324            quote_path_fully,
1325        ));
1326        output.push('\n');
1327    }
1328    if new_path == "/dev/null" {
1329        output.push_str("+++ /dev/null\n");
1330    } else if dst_prefix.is_empty() {
1331        output.push_str(&format!("+++ {new_path}\n"));
1332    } else {
1333        output.push_str("+++ ");
1334        output.push_str(&format_diff_path_with_prefix(
1335            dst_prefix,
1336            new_path,
1337            quote_path_fully,
1338        ));
1339        output.push('\n');
1340    }
1341
1342    let old_lines: Vec<&str> = old_content.lines().collect();
1343    for hunk_str in imara_unified_hunk_slices(&body) {
1344        if hunk_str.is_empty() {
1345            continue;
1346        }
1347        if let Some(first_newline) = hunk_str.find('\n') {
1348            let header_line = &hunk_str[..first_newline];
1349            let rest = &hunk_str[first_newline..];
1350            if let Some(func_ctx) =
1351                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1352            {
1353                output.push_str(header_line);
1354                output.push(' ');
1355                output.push_str(&func_ctx);
1356                output.push_str(rest);
1357            } else {
1358                output.push_str(hunk_str);
1359            }
1360        } else {
1361            output.push_str(hunk_str);
1362        }
1363    }
1364
1365    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1366}
1367
1368#[expect(clippy::too_many_arguments)]
1369fn unified_diff_git_patience_with_prefix_and_funcname(
1370    old_content: &str,
1371    new_content: &str,
1372    old_path: &str,
1373    new_path: &str,
1374    context_lines: usize,
1375    inter_hunk_context: usize,
1376    src_prefix: &str,
1377    dst_prefix: &str,
1378    funcname_matcher: Option<&FuncnameMatcher>,
1379    indent_heuristic: bool,
1380    quote_path_fully: bool,
1381) -> String {
1382    use crate::quote_path::format_diff_path_with_prefix;
1383
1384    let body = git_patience_unified_body_raw(
1385        old_content,
1386        new_content,
1387        context_lines,
1388        inter_hunk_context,
1389        indent_heuristic,
1390    );
1391
1392    let mut output = String::new();
1393    if old_path == "/dev/null" {
1394        output.push_str("--- /dev/null\n");
1395    } else if src_prefix.is_empty() {
1396        output.push_str(&format!("--- {old_path}\n"));
1397    } else {
1398        output.push_str("--- ");
1399        output.push_str(&format_diff_path_with_prefix(
1400            src_prefix,
1401            old_path,
1402            quote_path_fully,
1403        ));
1404        output.push('\n');
1405    }
1406    if new_path == "/dev/null" {
1407        output.push_str("+++ /dev/null\n");
1408    } else if dst_prefix.is_empty() {
1409        output.push_str(&format!("+++ {new_path}\n"));
1410    } else {
1411        output.push_str("+++ ");
1412        output.push_str(&format_diff_path_with_prefix(
1413            dst_prefix,
1414            new_path,
1415            quote_path_fully,
1416        ));
1417        output.push('\n');
1418    }
1419
1420    let old_lines: Vec<&str> = old_content.lines().collect();
1421    for hunk_str in imara_unified_hunk_slices(&body) {
1422        if hunk_str.is_empty() {
1423            continue;
1424        }
1425        if let Some(first_newline) = hunk_str.find('\n') {
1426            let header_line = &hunk_str[..first_newline];
1427            let rest = &hunk_str[first_newline..];
1428            if let Some(func_ctx) =
1429                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1430            {
1431                output.push_str(header_line);
1432                output.push(' ');
1433                output.push_str(&func_ctx);
1434                output.push_str(rest);
1435            } else {
1436                output.push_str(hunk_str);
1437            }
1438        } else {
1439            output.push_str(hunk_str);
1440        }
1441    }
1442
1443    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1444}
1445
1446/// Unified diff hunks for Git's histogram algorithm (no `---` / `+++` lines).
1447///
1448/// Used by `--no-index` when whitespace normalization is off so the patch matches upstream Git.
1449#[must_use]
1450pub fn unified_diff_histogram_hunks_only(
1451    old_content: &str,
1452    new_content: &str,
1453    context_lines: usize,
1454    inter_hunk_context: usize,
1455    indent_heuristic: bool,
1456) -> String {
1457    histogram_unified_body_raw(
1458        old_content,
1459        new_content,
1460        context_lines,
1461        inter_hunk_context,
1462        indent_heuristic,
1463        super::WhitespaceMode::default(),
1464    )
1465}
1466
1467#[expect(clippy::too_many_arguments)]
1468fn unified_diff_histogram_with_prefix_and_funcname_indent(
1469    old_content: &str,
1470    new_content: &str,
1471    old_path: &str,
1472    new_path: &str,
1473    context_lines: usize,
1474    inter_hunk_context: usize,
1475    src_prefix: &str,
1476    dst_prefix: &str,
1477    funcname_matcher: Option<&FuncnameMatcher>,
1478    indent_heuristic: bool,
1479    quote_path_fully: bool,
1480) -> String {
1481    use crate::quote_path::format_diff_path_with_prefix;
1482
1483    let body = histogram_unified_body_raw(
1484        old_content,
1485        new_content,
1486        context_lines,
1487        inter_hunk_context,
1488        indent_heuristic,
1489        super::WhitespaceMode::default(),
1490    );
1491
1492    let mut output = String::new();
1493    if old_path == "/dev/null" {
1494        output.push_str("--- /dev/null\n");
1495    } else if src_prefix.is_empty() {
1496        output.push_str(&format!("--- {old_path}\n"));
1497    } else {
1498        output.push_str("--- ");
1499        output.push_str(&format_diff_path_with_prefix(
1500            src_prefix,
1501            old_path,
1502            quote_path_fully,
1503        ));
1504        output.push('\n');
1505    }
1506    if new_path == "/dev/null" {
1507        output.push_str("+++ /dev/null\n");
1508    } else if dst_prefix.is_empty() {
1509        output.push_str(&format!("+++ {new_path}\n"));
1510    } else {
1511        output.push_str("+++ ");
1512        output.push_str(&format_diff_path_with_prefix(
1513            dst_prefix,
1514            new_path,
1515            quote_path_fully,
1516        ));
1517        output.push('\n');
1518    }
1519
1520    let old_lines: Vec<&str> = old_content.lines().collect();
1521    for hunk_str in imara_unified_hunk_slices(&body) {
1522        if hunk_str.is_empty() {
1523            continue;
1524        }
1525        if let Some(first_newline) = hunk_str.find('\n') {
1526            let header_line = &hunk_str[..first_newline];
1527            let rest = &hunk_str[first_newline..];
1528            if let Some(func_ctx) =
1529                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1530            {
1531                output.push_str(header_line);
1532                output.push(' ');
1533                output.push_str(&func_ctx);
1534                output.push_str(rest);
1535            } else {
1536                output.push_str(hunk_str);
1537            }
1538        } else {
1539            output.push_str(hunk_str);
1540        }
1541    }
1542
1543    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1544}
1545
1546/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm,
1547/// applying `--ignore-blank-lines` / `-I` change-record suppression
1548/// (`histogram_unified_body_ignore_fc`) and then attaching function-name hunk
1549/// headers like `unified_diff_histogram_with_prefix_and_funcname_indent`.
1550///
1551/// `is_ignorable_change` is given the removed/added line slices (without
1552/// trailing newlines) of one change record and returns whether the whole change
1553/// is ignorable. Returns an empty string when no substantive change survives so
1554/// the caller can hide the file entry.
1555#[allow(clippy::too_many_arguments)]
1556pub fn unified_diff_histogram_ignore_with_prefix_and_funcname<F>(
1557    old_content: &str,
1558    new_content: &str,
1559    old_path: &str,
1560    new_path: &str,
1561    context_lines: usize,
1562    inter_hunk_context: usize,
1563    src_prefix: &str,
1564    dst_prefix: &str,
1565    funcname_matcher: Option<&FuncnameMatcher>,
1566    quote_path_fully: bool,
1567    function_context: bool,
1568    indent_heuristic: bool,
1569    is_ignorable_change: F,
1570) -> String
1571where
1572    F: Fn(&[&str], &[&str]) -> bool,
1573{
1574    use crate::quote_path::format_diff_path_with_prefix;
1575
1576    let body = histogram_unified_body_ignore_fc(
1577        old_content,
1578        new_content,
1579        context_lines,
1580        inter_hunk_context,
1581        function_context,
1582        funcname_matcher,
1583        indent_heuristic,
1584        super::WhitespaceMode::default(),
1585        is_ignorable_change,
1586    );
1587    if body.is_empty() {
1588        return String::new();
1589    }
1590
1591    let mut output = String::new();
1592    if old_path == "/dev/null" {
1593        output.push_str("--- /dev/null\n");
1594    } else if src_prefix.is_empty() {
1595        output.push_str(&format!("--- {old_path}\n"));
1596    } else {
1597        output.push_str("--- ");
1598        output.push_str(&format_diff_path_with_prefix(
1599            src_prefix,
1600            old_path,
1601            quote_path_fully,
1602        ));
1603        output.push('\n');
1604    }
1605    if new_path == "/dev/null" {
1606        output.push_str("+++ /dev/null\n");
1607    } else if dst_prefix.is_empty() {
1608        output.push_str(&format!("+++ {new_path}\n"));
1609    } else {
1610        output.push_str("+++ ");
1611        output.push_str(&format_diff_path_with_prefix(
1612            dst_prefix,
1613            new_path,
1614            quote_path_fully,
1615        ));
1616        output.push('\n');
1617    }
1618
1619    let old_lines: Vec<&str> = old_content.lines().collect();
1620    for hunk_str in imara_unified_hunk_slices(&body) {
1621        if hunk_str.is_empty() {
1622            continue;
1623        }
1624        if let Some(first_newline) = hunk_str.find('\n') {
1625            let header_line = &hunk_str[..first_newline];
1626            let rest = &hunk_str[first_newline..];
1627            if let Some(func_ctx) =
1628                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1629            {
1630                output.push_str(header_line);
1631                output.push(' ');
1632                output.push_str(&func_ctx);
1633                output.push_str(rest);
1634            } else {
1635                output.push_str(hunk_str);
1636            }
1637        } else {
1638            output.push_str(hunk_str);
1639        }
1640    }
1641
1642    output
1643}
1644
1645/// `diff.indentHeuristic` from config (Git defaults to true when unset).
1646#[must_use]
1647pub(crate) fn indent_heuristic_from_config(config: &ConfigSet) -> bool {
1648    config.bool_or("diff.indentHeuristic", true)
1649}
1650
1651/// Resolve indent heuristic: `--no-indent-heuristic` and `--indent-heuristic` override config.
1652#[must_use]
1653pub fn resolve_indent_heuristic(
1654    config: &ConfigSet,
1655    cli_indent_heuristic: bool,
1656    cli_no_indent_heuristic: bool,
1657) -> bool {
1658    if cli_no_indent_heuristic {
1659        false
1660    } else if cli_indent_heuristic {
1661        true
1662    } else {
1663        indent_heuristic_from_config(config)
1664    }
1665}
1666
1667// ---------------------------------------------------------------------------
1668// Git-compatible implementation of the xdiff Myers engine,
1669// restricted to what the word-diff path needs (no rename/ignore-whitespace
1670// flags). Git's word diff runs the *default* xdiff Myers over the per-word
1671// token streams; matching Git's exact change-record selection requires
1672// reproducing its preprocessing (record classification + `xdl_cleanup_records`
1673// + `xdl_trim_ends`) and its divide-and-conquer split, because the histogram
1674// engine (and `similar`) pick different — but equally minimal — alignments for inputs
1675// with many repeated tokens (e.g. the `{`/`}`/`,` in the `bibtex` driver).
1676mod git_xdiff {
1677    use std::cell::RefCell;
1678
1679    const XDL_MAX_COST_MIN: i64 = 256;
1680    const XDL_HEUR_MIN_COST: i64 = 256;
1681    const XDL_SNAKE_CNT: i64 = 20;
1682    const XDL_K_HEUR: i64 = 4;
1683    const XDL_LINE_MAX: i64 = i64::MAX;
1684    const XDL_KPDIS_RUN: i64 = 4;
1685    const XDL_MAX_EQLIMIT: i64 = 1024;
1686    const XDL_SIMSCAN_WINDOW: i64 = 100;
1687
1688    // record-classification actions (xprepare.c)
1689    const DISCARD: u8 = 0;
1690    const KEEP: u8 = 1;
1691    const INVESTIGATE: u8 = 2;
1692
1693    /// Classical integer square root approximation using shifts (`xdl_bogosqrt`).
1694    fn bogosqrt(mut n: i64) -> i64 {
1695        let mut i: i64 = 1;
1696        while n > 0 {
1697            i <<= 1;
1698            n >>= 2;
1699        }
1700        i
1701    }
1702
1703    struct XdAlgoEnv {
1704        mxcost: i64,
1705        snake_cnt: i64,
1706        heur_min: i64,
1707    }
1708
1709    struct XdpSplit {
1710        i1: i64,
1711        i2: i64,
1712        min_lo: bool,
1713        min_hi: bool,
1714    }
1715
1716    /// A single side of the diff: token ids plus the `changed` flags and the
1717    /// `reference_index` mapping from effective record -> original record.
1718    struct XdFile {
1719        changed: Vec<u8>,
1720        reference_index: Vec<usize>,
1721        /// Interned token id per effective record (parallel to `reference_index`).
1722        effective_ids: Vec<u32>,
1723        dstart: i64,
1724        dend: i64,
1725        nreff: i64,
1726    }
1727
1728    #[inline]
1729    fn effective_id(xdf: &XdFile, idx: i64) -> u32 {
1730        xdf.effective_ids[idx as usize]
1731    }
1732
1733    struct XdiffScratch {
1734        count1: Vec<u32>,
1735        count2: Vec<u32>,
1736        action1: Vec<u8>,
1737        action2: Vec<u8>,
1738        kvd: Vec<i64>,
1739    }
1740
1741    impl XdiffScratch {
1742        fn prepare_counts(&mut self, nclasses: usize) {
1743            if self.count1.len() < nclasses {
1744                self.count1.resize(nclasses, 0);
1745                self.count2.resize(nclasses, 0);
1746            }
1747            self.count1[..nclasses].fill(0);
1748            self.count2[..nclasses].fill(0);
1749        }
1750
1751        fn prepare_actions(&mut self, nrec1: usize, nrec2: usize) {
1752            let a1 = nrec1 + 1;
1753            let a2 = nrec2 + 1;
1754            if self.action1.len() < a1 {
1755                self.action1.resize(a1, 0);
1756            } else {
1757                self.action1[..a1].fill(0);
1758            }
1759            if self.action2.len() < a2 {
1760                self.action2.resize(a2, 0);
1761            } else {
1762                self.action2[..a2].fill(0);
1763            }
1764        }
1765
1766        fn prepare_kvd(&mut self, kvd_len: usize) {
1767            // The search reads only diagonals it has written, so the contents never need
1768            // clearing. A fresh zeroed allocation leaves the pages a small diff never reaches
1769            // untouched, where growing in place would write every one of them.
1770            if self.kvd.len() < kvd_len {
1771                self.kvd = vec![0; kvd_len];
1772            }
1773        }
1774    }
1775
1776    thread_local! { // hygiene: per-thread xdiff Myers scratch (class counts, K-vectors) reused across files
1777        static XDIFF_SCRATCH: RefCell<XdiffScratch> = const {
1778            RefCell::new(XdiffScratch {
1779                count1: Vec::new(),
1780                count2: Vec::new(),
1781                action1: Vec::new(),
1782                action2: Vec::new(),
1783                kvd: Vec::new(),
1784            })
1785        };
1786    }
1787
1788    fn bool_changed(changed: Vec<u8>) -> Vec<bool> {
1789        changed.into_iter().map(|b| b != 0).collect()
1790    }
1791
1792    /// Faithful port of `xdl_split` (the middle-snake finder). `kvdf`/`kvdb` are
1793    /// the forward/backward k-vectors, offset so index 0 maps to diagonal 0.
1794    #[allow(clippy::too_many_arguments)]
1795    fn xdl_split(
1796        xdf1: &XdFile,
1797        off1: i64,
1798        lim1: i64,
1799        xdf2: &XdFile,
1800        off2: i64,
1801        lim2: i64,
1802        kvdf: &mut [i64],
1803        kvdb: &mut [i64],
1804        koff: i64,
1805        need_min: bool,
1806        spl: &mut XdpSplit,
1807        xenv: &XdAlgoEnv,
1808    ) -> i64 {
1809        let dmin = off1 - lim2;
1810        let dmax = lim1 - off2;
1811        let fmid = off1 - off2;
1812        let bmid = lim1 - lim2;
1813        let odd = ((fmid - bmid) & 1) != 0;
1814        let mut fmin = fmid;
1815        let mut fmax = fmid;
1816        let mut bmin = bmid;
1817        let mut bmax = bmid;
1818
1819        let kf = |k: i64| -> usize { (k + koff) as usize };
1820
1821        kvdf[kf(fmid)] = off1;
1822        kvdb[kf(bmid)] = lim1;
1823
1824        let mut ec: i64 = 1;
1825        loop {
1826            let mut got_snake = false;
1827
1828            if fmin > dmin {
1829                fmin -= 1;
1830                kvdf[kf(fmin - 1)] = -1;
1831            } else {
1832                fmin += 1;
1833            }
1834            if fmax < dmax {
1835                fmax += 1;
1836                kvdf[kf(fmax + 1)] = -1;
1837            } else {
1838                fmax -= 1;
1839            }
1840
1841            let mut d = fmax;
1842            while d >= fmin {
1843                let mut i1 = if kvdf[kf(d - 1)] >= kvdf[kf(d + 1)] {
1844                    kvdf[kf(d - 1)] + 1
1845                } else {
1846                    kvdf[kf(d + 1)]
1847                };
1848                let prev1 = i1;
1849                let mut i2 = i1 - d;
1850                while i1 < lim1 && i2 < lim2 && effective_id(xdf1, i1) == effective_id(xdf2, i2) {
1851                    i1 += 1;
1852                    i2 += 1;
1853                }
1854                if i1 - prev1 > xenv.snake_cnt {
1855                    got_snake = true;
1856                }
1857                kvdf[kf(d)] = i1;
1858                if odd && bmin <= d && d <= bmax && kvdb[kf(d)] <= i1 {
1859                    spl.i1 = i1;
1860                    spl.i2 = i2;
1861                    spl.min_lo = true;
1862                    spl.min_hi = true;
1863                    return ec;
1864                }
1865                d -= 2;
1866            }
1867
1868            if bmin > dmin {
1869                bmin -= 1;
1870                kvdb[kf(bmin - 1)] = XDL_LINE_MAX;
1871            } else {
1872                bmin += 1;
1873            }
1874            if bmax < dmax {
1875                bmax += 1;
1876                kvdb[kf(bmax + 1)] = XDL_LINE_MAX;
1877            } else {
1878                bmax -= 1;
1879            }
1880
1881            let mut d = bmax;
1882            while d >= bmin {
1883                let mut i1 = if kvdb[kf(d - 1)] < kvdb[kf(d + 1)] {
1884                    kvdb[kf(d - 1)]
1885                } else {
1886                    kvdb[kf(d + 1)] - 1
1887                };
1888                let prev1 = i1;
1889                let mut i2 = i1 - d;
1890                while i1 > off1
1891                    && i2 > off2
1892                    && effective_id(xdf1, i1 - 1) == effective_id(xdf2, i2 - 1)
1893                {
1894                    i1 -= 1;
1895                    i2 -= 1;
1896                }
1897                if prev1 - i1 > xenv.snake_cnt {
1898                    got_snake = true;
1899                }
1900                kvdb[kf(d)] = i1;
1901                if !odd && fmin <= d && d <= fmax && i1 <= kvdf[kf(d)] {
1902                    spl.i1 = i1;
1903                    spl.i2 = i2;
1904                    spl.min_lo = true;
1905                    spl.min_hi = true;
1906                    return ec;
1907                }
1908                d -= 2;
1909            }
1910
1911            if need_min {
1912                ec += 1;
1913                continue;
1914            }
1915
1916            if got_snake && ec > xenv.heur_min {
1917                let mut best = 0i64;
1918                let mut d = fmax;
1919                while d >= fmin {
1920                    let dd = if d > fmid { d - fmid } else { fmid - d };
1921                    let i1 = kvdf[kf(d)];
1922                    let i2 = i1 - d;
1923                    let v = (i1 - off1) + (i2 - off2) - dd;
1924                    if v > XDL_K_HEUR * ec
1925                        && v > best
1926                        && off1 + xenv.snake_cnt <= i1
1927                        && i1 < lim1
1928                        && off2 + xenv.snake_cnt <= i2
1929                        && i2 < lim2
1930                    {
1931                        let mut k = 1i64;
1932                        while effective_id(xdf1, i1 - k) == effective_id(xdf2, i2 - k) {
1933                            if k == xenv.snake_cnt {
1934                                best = v;
1935                                spl.i1 = i1;
1936                                spl.i2 = i2;
1937                                break;
1938                            }
1939                            k += 1;
1940                        }
1941                    }
1942                    d -= 2;
1943                }
1944                if best > 0 {
1945                    spl.min_lo = true;
1946                    spl.min_hi = false;
1947                    return ec;
1948                }
1949
1950                let mut best = 0i64;
1951                let mut d = bmax;
1952                while d >= bmin {
1953                    let dd = if d > bmid { d - bmid } else { bmid - d };
1954                    let i1 = kvdb[kf(d)];
1955                    let i2 = i1 - d;
1956                    let v = (lim1 - i1) + (lim2 - i2) - dd;
1957                    if v > XDL_K_HEUR * ec
1958                        && v > best
1959                        && off1 < i1
1960                        && i1 <= lim1 - xenv.snake_cnt
1961                        && off2 < i2
1962                        && i2 <= lim2 - xenv.snake_cnt
1963                    {
1964                        let mut k = 0i64;
1965                        while effective_id(xdf1, i1 + k) == effective_id(xdf2, i2 + k) {
1966                            if k == xenv.snake_cnt - 1 {
1967                                best = v;
1968                                spl.i1 = i1;
1969                                spl.i2 = i2;
1970                                break;
1971                            }
1972                            k += 1;
1973                        }
1974                    }
1975                    d -= 2;
1976                }
1977                if best > 0 {
1978                    spl.min_lo = false;
1979                    spl.min_hi = true;
1980                    return ec;
1981                }
1982            }
1983
1984            if ec >= xenv.mxcost {
1985                let mut fbest = -1i64;
1986                let mut fbest1 = -1i64;
1987                let mut d = fmax;
1988                while d >= fmin {
1989                    let mut i1 = kvdf[kf(d)].min(lim1);
1990                    let mut i2 = i1 - d;
1991                    if lim2 < i2 {
1992                        i1 = lim2 + d;
1993                        i2 = lim2;
1994                    }
1995                    if fbest < i1 + i2 {
1996                        fbest = i1 + i2;
1997                        fbest1 = i1;
1998                    }
1999                    d -= 2;
2000                }
2001
2002                let mut bbest = XDL_LINE_MAX;
2003                let mut bbest1 = XDL_LINE_MAX;
2004                let mut d = bmax;
2005                while d >= bmin {
2006                    let mut i1 = off1.max(kvdb[kf(d)]);
2007                    let mut i2 = i1 - d;
2008                    if i2 < off2 {
2009                        i1 = off2 + d;
2010                        i2 = off2;
2011                    }
2012                    if i1 + i2 < bbest {
2013                        bbest = i1 + i2;
2014                        bbest1 = i1;
2015                    }
2016                    d -= 2;
2017                }
2018
2019                if (lim1 + lim2) - bbest < fbest - (off1 + off2) {
2020                    spl.i1 = fbest1;
2021                    spl.i2 = fbest - fbest1;
2022                    spl.min_lo = true;
2023                    spl.min_hi = false;
2024                } else {
2025                    spl.i1 = bbest1;
2026                    spl.i2 = bbest - bbest1;
2027                    spl.min_lo = false;
2028                    spl.min_hi = true;
2029                }
2030                return ec;
2031            }
2032
2033            ec += 1;
2034        }
2035    }
2036
2037    /// Faithful port of `xdl_recs_cmp` (divide & conquer). Marks `changed` flags.
2038    #[allow(clippy::too_many_arguments)]
2039    fn xdl_recs_cmp(
2040        xdf1: &mut XdFile,
2041        mut off1: i64,
2042        mut lim1: i64,
2043        xdf2: &mut XdFile,
2044        mut off2: i64,
2045        mut lim2: i64,
2046        kvdf: &mut [i64],
2047        kvdb: &mut [i64],
2048        koff: i64,
2049        need_min: bool,
2050        xenv: &XdAlgoEnv,
2051    ) {
2052        while off1 < lim1 && off2 < lim2 && effective_id(xdf1, off1) == effective_id(xdf2, off2) {
2053            off1 += 1;
2054            off2 += 1;
2055        }
2056        while off1 < lim1
2057            && off2 < lim2
2058            && effective_id(xdf1, lim1 - 1) == effective_id(xdf2, lim2 - 1)
2059        {
2060            lim1 -= 1;
2061            lim2 -= 1;
2062        }
2063
2064        if off1 == lim1 {
2065            while off2 < lim2 {
2066                let r = xdf2.reference_index[off2 as usize];
2067                xdf2.changed[r] = 1;
2068                off2 += 1;
2069            }
2070        } else if off2 == lim2 {
2071            while off1 < lim1 {
2072                let r = xdf1.reference_index[off1 as usize];
2073                xdf1.changed[r] = 1;
2074                off1 += 1;
2075            }
2076        } else {
2077            let mut spl = XdpSplit {
2078                i1: 0,
2079                i2: 0,
2080                min_lo: false,
2081                min_hi: false,
2082            };
2083            xdl_split(
2084                xdf1, off1, lim1, xdf2, off2, lim2, kvdf, kvdb, koff, need_min, &mut spl, xenv,
2085            );
2086            xdl_recs_cmp(
2087                xdf1, off1, spl.i1, xdf2, off2, spl.i2, kvdf, kvdb, koff, spl.min_lo, xenv,
2088            );
2089            xdl_recs_cmp(
2090                xdf1, spl.i1, lim1, xdf2, spl.i2, lim2, kvdf, kvdb, koff, spl.min_hi, xenv,
2091            );
2092        }
2093    }
2094
2095    /// `xdl_clean_mmatch`: decide whether a multimatch record should be discarded.
2096    fn clean_mmatch(action: &[u8], i: i64, mut s: i64, mut e: i64) -> bool {
2097        if i - s > XDL_SIMSCAN_WINDOW {
2098            s = i - XDL_SIMSCAN_WINDOW;
2099        }
2100        if e - i > XDL_SIMSCAN_WINDOW {
2101            e = i + XDL_SIMSCAN_WINDOW;
2102        }
2103
2104        let mut rdis0 = 0i64;
2105        let mut rpdis0 = 1i64;
2106        let mut r = 1i64;
2107        while i - r >= s {
2108            match action[(i - r) as usize] {
2109                DISCARD => rdis0 += 1,
2110                INVESTIGATE => rpdis0 += 1,
2111                _ => break, // KEEP
2112            }
2113            r += 1;
2114        }
2115        if rdis0 == 0 {
2116            return false;
2117        }
2118        let mut rdis1 = 0i64;
2119        let mut rpdis1 = 1i64;
2120        let mut r = 1i64;
2121        while i + r <= e {
2122            match action[(i + r) as usize] {
2123                DISCARD => rdis1 += 1,
2124                INVESTIGATE => rpdis1 += 1,
2125                _ => break, // KEEP
2126            }
2127            r += 1;
2128        }
2129        if rdis1 == 0 {
2130            return false;
2131        }
2132        rdis1 += rdis0;
2133        rpdis1 += rpdis0;
2134        rpdis1 * XDL_KPDIS_RUN < (rpdis1 + rdis1)
2135    }
2136
2137    /// Build the `changed` flags for both token streams using Git's xdiff engine.
2138    /// When `need_min` is true, Git's minimal diff (`--minimal`) is used.
2139    /// `ids1`/`ids2` are dense interned token ids from one [`crate::line_interner::LineInterner`]
2140    /// (equal id <=> equal token); per-id tables are sized by the largest id.
2141    /// Returns `(changed1, changed2)`, one bool per original token.
2142    pub(crate) fn changed_flags(
2143        ids1: &[u32],
2144        ids2: &[u32],
2145        need_min: bool,
2146    ) -> (Vec<bool>, Vec<bool>) {
2147        changed_flags_with_shared(ids1, ids2, &[], 0, need_min)
2148    }
2149
2150    /// Like [`changed_flags`] for slices cut from two larger files whose other records are
2151    /// identical on both sides.
2152    ///
2153    /// Git classifies each record by how often it occurs in the whole other file, so the
2154    /// records left out still count: `shared_counts[id]` is how many of them have token `id`
2155    /// (ids past its end have none), and `shared_records` is how many there are on each side.
2156    pub(crate) fn changed_flags_with_shared(
2157        ids1: &[u32],
2158        ids2: &[u32],
2159        shared_counts: &[u32],
2160        shared_records: usize,
2161        need_min: bool,
2162    ) -> (Vec<bool>, Vec<bool>) {
2163        XDIFF_SCRATCH.with(|cell| {
2164            changed_flags_with_shared_inner(
2165                cell,
2166                ids1,
2167                ids2,
2168                shared_counts,
2169                shared_records,
2170                need_min,
2171            )
2172        })
2173    }
2174
2175    fn changed_flags_with_shared_inner(
2176        scratch: &RefCell<XdiffScratch>,
2177        ids1: &[u32],
2178        ids2: &[u32],
2179        shared_counts: &[u32],
2180        shared_records: usize,
2181        need_min: bool,
2182    ) -> (Vec<bool>, Vec<bool>) {
2183        let nrec1 = ids1.len();
2184        let nrec2 = ids2.len();
2185
2186        // Per-token occurrence counts on each side (class len1/len2), indexed by the dense ids.
2187        let nclasses = ids1
2188            .iter()
2189            .chain(ids2)
2190            .max()
2191            .map_or(0, |&max| max as usize + 1);
2192        let mut work = scratch.borrow_mut();
2193        work.prepare_counts(nclasses);
2194        for &id in ids1 {
2195            work.count1[id as usize] += 1;
2196        }
2197        for &id in ids2 {
2198            work.count2[id as usize] += 1;
2199        }
2200        for (id, &shared) in shared_counts.iter().enumerate().take(nclasses) {
2201            work.count1[id] += shared;
2202            work.count2[id] += shared;
2203        }
2204
2205        let mut xdf1 = XdFile {
2206            changed: vec![0u8; nrec1],
2207            reference_index: Vec::new(),
2208            effective_ids: Vec::new(),
2209            dstart: 0,
2210            dend: nrec1 as i64 - 1,
2211            nreff: 0,
2212        };
2213        let mut xdf2 = XdFile {
2214            changed: vec![0u8; nrec2],
2215            reference_index: Vec::new(),
2216            effective_ids: Vec::new(),
2217            dstart: 0,
2218            dend: nrec2 as i64 - 1,
2219            nreff: 0,
2220        };
2221
2222        // xdl_trim_ends: trim leading/trailing matching records.
2223        let lim = nrec1.min(nrec2) as i64;
2224        let mut i = 0i64;
2225        while i < lim && ids1[i as usize] == ids2[i as usize] {
2226            i += 1;
2227        }
2228        xdf1.dstart = i;
2229        xdf2.dstart = i;
2230        let mut j = 0i64;
2231        let rem = lim - i;
2232        while j < rem && ids1[nrec1 - 1 - j as usize] == ids2[nrec2 - 1 - j as usize] {
2233            j += 1;
2234        }
2235        xdf1.dend = nrec1 as i64 - j - 1;
2236        xdf2.dend = nrec2 as i64 - j - 1;
2237
2238        // xdl_cleanup_records: classify and reduce to effective records.
2239        work.prepare_actions(nrec1, nrec2);
2240        for xdf in [&mut xdf1, &mut xdf2] {
2241            let span = usize::try_from(xdf.dend - xdf.dstart + 1).unwrap_or(0);
2242            xdf.reference_index.reserve_exact(span);
2243            xdf.effective_ids.reserve_exact(span);
2244        }
2245
2246        let mlim1 = if need_min {
2247            i64::MAX
2248        } else {
2249            let mut mlim = bogosqrt((nrec1 + shared_records) as i64);
2250            if mlim > XDL_MAX_EQLIMIT {
2251                mlim = XDL_MAX_EQLIMIT;
2252            }
2253            mlim
2254        };
2255        let mut idx = xdf1.dstart;
2256        while idx <= xdf1.dend {
2257            let id = ids1[idx as usize];
2258            let nm = i64::from(work.count2[id as usize]);
2259            work.action1[idx as usize] = if nm == 0 {
2260                DISCARD
2261            } else if nm >= mlim1 {
2262                INVESTIGATE
2263            } else {
2264                KEEP
2265            };
2266            idx += 1;
2267        }
2268
2269        let mlim2 = if need_min {
2270            i64::MAX
2271        } else {
2272            let mut mlim = bogosqrt((nrec2 + shared_records) as i64);
2273            if mlim > XDL_MAX_EQLIMIT {
2274                mlim = XDL_MAX_EQLIMIT;
2275            }
2276            mlim
2277        };
2278        let mut idx = xdf2.dstart;
2279        while idx <= xdf2.dend {
2280            let id = ids2[idx as usize];
2281            let nm = i64::from(work.count1[id as usize]);
2282            work.action2[idx as usize] = if nm == 0 {
2283                DISCARD
2284            } else if nm >= mlim2 {
2285                INVESTIGATE
2286            } else {
2287                KEEP
2288            };
2289            idx += 1;
2290        }
2291
2292        let mut idx = xdf1.dstart;
2293        while idx <= xdf1.dend {
2294            let id = ids1[idx as usize];
2295            let a = work.action1[idx as usize];
2296            if a == KEEP
2297                || (a == INVESTIGATE
2298                    && !clean_mmatch(&work.action1[..nrec1 + 1], idx, xdf1.dstart, xdf1.dend))
2299            {
2300                xdf1.reference_index.push(idx as usize);
2301                xdf1.effective_ids.push(id);
2302            } else {
2303                xdf1.changed[idx as usize] = 1;
2304            }
2305            idx += 1;
2306        }
2307        xdf1.nreff = xdf1.reference_index.len() as i64;
2308
2309        let mut idx = xdf2.dstart;
2310        while idx <= xdf2.dend {
2311            let id = ids2[idx as usize];
2312            let a = work.action2[idx as usize];
2313            if a == KEEP
2314                || (a == INVESTIGATE
2315                    && !clean_mmatch(&work.action2[..nrec2 + 1], idx, xdf2.dstart, xdf2.dend))
2316            {
2317                xdf2.reference_index.push(idx as usize);
2318                xdf2.effective_ids.push(id);
2319            } else {
2320                xdf2.changed[idx as usize] = 1;
2321            }
2322            idx += 1;
2323        }
2324        xdf2.nreff = xdf2.reference_index.len() as i64;
2325
2326        // Allocate K vectors (xdl_do_diff). koff lets us use negative diagonals.
2327        let ndiags = xdf1.nreff + xdf2.nreff + 3;
2328        let kvd_len = (2 * ndiags + 2) as usize;
2329        work.prepare_kvd(kvd_len);
2330        let kvd = &mut work.kvd[..kvd_len];
2331        // kvdf base offset = nreff2 + 1; kvdb base offset = ndiags + nreff2 + 1.
2332        let koff = xdf2.nreff + 1;
2333        let (kvdf_slice, kvdb_slice) = kvd.split_at_mut(ndiags as usize);
2334        // Both slices are indexed as [k + koff]; their length covers the diagonal range.
2335
2336        let xenv = XdAlgoEnv {
2337            mxcost: bogosqrt(ndiags).max(XDL_MAX_COST_MIN),
2338            snake_cnt: XDL_SNAKE_CNT,
2339            heur_min: XDL_HEUR_MIN_COST,
2340        };
2341
2342        let nreff1 = xdf1.nreff;
2343        let nreff2 = xdf2.nreff;
2344        xdl_recs_cmp(
2345            &mut xdf1, 0, nreff1, &mut xdf2, 0, nreff2, kvdf_slice, kvdb_slice, koff, need_min,
2346            &xenv,
2347        );
2348
2349        (bool_changed(xdf1.changed), bool_changed(xdf2.changed))
2350    }
2351
2352    /// Patience diff: match unique lines, then recurse on gaps with xdiff Myers.
2353    pub(crate) fn patience_changed_flags(
2354        lines1: &[&str],
2355        lines2: &[&str],
2356    ) -> (Vec<bool>, Vec<bool>) {
2357        let b1: Vec<&[u8]> = lines1.iter().map(|l| l.as_bytes()).collect();
2358        let b2: Vec<&[u8]> = lines2.iter().map(|l| l.as_bytes()).collect();
2359        patience_changed_flags_bytes(&b1, &b2)
2360    }
2361
2362    pub(crate) fn patience_changed_flags_bytes(
2363        lines1: &[&[u8]],
2364        lines2: &[&[u8]],
2365    ) -> (Vec<bool>, Vec<bool>) {
2366        let mut changed1 = vec![false; lines1.len()];
2367        let mut changed2 = vec![false; lines2.len()];
2368        patience_recurse_bytes(
2369            lines1,
2370            lines2,
2371            &mut changed1,
2372            &mut changed2,
2373            0,
2374            lines1.len(),
2375            0,
2376            lines2.len(),
2377        );
2378        (changed1, changed2)
2379    }
2380
2381    #[allow(clippy::too_many_arguments)]
2382    fn patience_recurse_bytes(
2383        lines1: &[&[u8]],
2384        lines2: &[&[u8]],
2385        changed1: &mut [bool],
2386        changed2: &mut [bool],
2387        o_start: usize,
2388        o_end: usize,
2389        n_start: usize,
2390        n_end: usize,
2391    ) {
2392        if o_start >= o_end && n_start >= n_end {
2393            return;
2394        }
2395        if o_start >= o_end {
2396            changed2[n_start..n_end].fill(true);
2397            return;
2398        }
2399        if n_start >= n_end {
2400            changed1[o_start..o_end].fill(true);
2401            return;
2402        }
2403
2404        let anchors = patience_anchors_bytes(lines1, lines2, o_start, o_end, n_start, n_end);
2405        if anchors.is_empty() {
2406            merge_myers_region_bytes(
2407                lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
2408            );
2409            return;
2410        }
2411
2412        let mut prev_o = o_start;
2413        let mut prev_n = n_start;
2414        for (ao, an) in anchors {
2415            patience_recurse_bytes(lines1, lines2, changed1, changed2, prev_o, ao, prev_n, an);
2416            prev_o = ao + 1;
2417            prev_n = an + 1;
2418        }
2419        patience_recurse_bytes(
2420            lines1, lines2, changed1, changed2, prev_o, o_end, prev_n, n_end,
2421        );
2422    }
2423
2424    #[allow(clippy::too_many_arguments)]
2425    fn patience_anchors_bytes(
2426        lines1: &[&[u8]],
2427        lines2: &[&[u8]],
2428        o_start: usize,
2429        o_end: usize,
2430        n_start: usize,
2431        n_end: usize,
2432    ) -> Vec<(usize, usize)> {
2433        use hashbrown::HashMap;
2434
2435        let old_slice = &lines1[o_start..o_end];
2436        let new_slice = &lines2[n_start..n_end];
2437        let mut old_pos: HashMap<&[u8], Vec<usize>> = HashMap::new();
2438        let mut new_pos: HashMap<&[u8], Vec<usize>> = HashMap::new();
2439        for (i, line) in old_slice.iter().enumerate() {
2440            old_pos.entry(*line).or_default().push(o_start + i);
2441        }
2442        for (i, line) in new_slice.iter().enumerate() {
2443            new_pos.entry(*line).or_default().push(n_start + i);
2444        }
2445
2446        let mut pairs: Vec<(usize, usize)> = Vec::new();
2447        for (line, o_indices) in &old_pos {
2448            if o_indices.len() != 1 {
2449                continue;
2450            }
2451            if let Some(n_indices) = new_pos.get(line) {
2452                if n_indices.len() == 1 {
2453                    pairs.push((o_indices[0], n_indices[0]));
2454                }
2455            }
2456        }
2457        pairs.sort_by_key(|p| p.0);
2458        longest_increasing_by_second(&pairs)
2459    }
2460
2461    fn longest_increasing_by_second(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
2462        if pairs.is_empty() {
2463            return Vec::new();
2464        }
2465        let n = pairs.len();
2466        let mut tails = vec![0usize; n];
2467        let mut prev = vec![usize::MAX; n];
2468        let mut len = 0usize;
2469        for i in 0..n {
2470            let v = pairs[i].1;
2471            let mut lo = 0usize;
2472            let mut hi = len;
2473            while lo < hi {
2474                let mid = (lo + hi) / 2;
2475                if pairs[tails[mid]].1 < v {
2476                    lo = mid + 1;
2477                } else {
2478                    hi = mid;
2479                }
2480            }
2481            if lo > 0 {
2482                prev[i] = tails[lo - 1];
2483            }
2484            tails[lo] = i;
2485            if lo == len {
2486                len += 1;
2487            }
2488        }
2489        let mut out = Vec::with_capacity(len);
2490        let mut k = tails[len.saturating_sub(1)];
2491        while k != usize::MAX {
2492            out.push(pairs[k]);
2493            k = prev[k];
2494        }
2495        out.reverse();
2496        out
2497    }
2498
2499    pub(crate) fn histogram_changed_flags(
2500        lines1: &[&str],
2501        lines2: &[&str],
2502    ) -> (Vec<bool>, Vec<bool>) {
2503        crate::diff::git_histogram::histogram_changed_flags(
2504            lines1,
2505            lines2,
2506            |changed1, changed2, o_start, o_end, n_start, n_end| {
2507                merge_myers_region(
2508                    lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
2509                );
2510            },
2511        )
2512    }
2513
2514    #[allow(clippy::too_many_arguments)]
2515    fn merge_myers_region(
2516        lines1: &[&str],
2517        lines2: &[&str],
2518        changed1: &mut [bool],
2519        changed2: &mut [bool],
2520        o_start: usize,
2521        o_end: usize,
2522        n_start: usize,
2523        n_end: usize,
2524    ) {
2525        let b1: Vec<&[u8]> = lines1.iter().map(|l| l.as_bytes()).collect();
2526        let b2: Vec<&[u8]> = lines2.iter().map(|l| l.as_bytes()).collect();
2527        merge_myers_region_bytes(&b1, &b2, changed1, changed2, o_start, o_end, n_start, n_end);
2528    }
2529
2530    #[allow(clippy::too_many_arguments)]
2531    pub(super) fn merge_myers_region_bytes(
2532        lines1: &[&[u8]],
2533        lines2: &[&[u8]],
2534        changed1: &mut [bool],
2535        changed2: &mut [bool],
2536        o_start: usize,
2537        o_end: usize,
2538        n_start: usize,
2539        n_end: usize,
2540    ) {
2541        use crate::line_interner::ByteLineInterner;
2542
2543        let old_slice = &lines1[o_start..o_end];
2544        let new_slice = &lines2[n_start..n_end];
2545        if old_slice.is_empty() && new_slice.is_empty() {
2546            return;
2547        }
2548        let mut interner = ByteLineInterner::with_capacity(old_slice.len() + new_slice.len());
2549        let ids1 = interner.intern_all_bytes(old_slice);
2550        let ids2 = interner.intern_all_bytes(new_slice);
2551        let (sub1, sub2) = changed_flags(&ids1, &ids2, false);
2552        for (i, ch) in sub1.iter().enumerate() {
2553            if *ch {
2554                changed1[o_start + i] = true;
2555            }
2556        }
2557        for (j, ch) in sub2.iter().enumerate() {
2558            if *ch {
2559                changed2[n_start + j] = true;
2560            }
2561        }
2562    }
2563}
2564
2565/// Myers changed-line masks for a line slice, honoring [`super::WhitespaceMode`] like Git `xdl_fall_back_diff`.
2566pub(crate) fn myers_rediff_changed_masks_for_region<'a>(
2567    old_lines: &[&'a str],
2568    new_lines: &[&'a str],
2569    ws: super::WhitespaceMode,
2570) -> (Vec<bool>, Vec<bool>) {
2571    use crate::line_interner::LineInterner;
2572
2573    if old_lines.is_empty() && new_lines.is_empty() {
2574        return (Vec::new(), Vec::new());
2575    }
2576    let normalize = |lines: &[&'a str]| -> Vec<Cow<'a, str>> {
2577        lines
2578            .iter()
2579            .map(|line| ws.normalize_line_for_compare(line))
2580            .collect()
2581    };
2582    let (old_normalized, new_normalized) = (normalize(old_lines), normalize(new_lines));
2583    let old_keys: Vec<&str> = old_normalized.iter().map(Cow::as_ref).collect();
2584    let new_keys: Vec<&str> = new_normalized.iter().map(Cow::as_ref).collect();
2585    let mut interner = LineInterner::with_capacity(old_keys.len() + new_keys.len());
2586    let ids1 = interner.intern_all(&old_keys);
2587    let ids2 = interner.intern_all(&new_keys);
2588    git_xdiff::changed_flags(&ids1, &ids2, false)
2589}
2590
2591/// Myers rediff on pre-interned merge compare-key ids (byte-accurate).
2592pub(crate) fn myers_rediff_changed_masks_for_region_ids(
2593    old_ids: &[u32],
2594    new_ids: &[u32],
2595) -> (Vec<bool>, Vec<bool>) {
2596    if old_ids.is_empty() && new_ids.is_empty() {
2597        return (Vec::new(), Vec::new());
2598    }
2599    git_xdiff::changed_flags(old_ids, new_ids, false)
2600}
2601
2602/// Convert per-token `changed` flags (Git xdiff style) into `similar::DiffOp`s.
2603fn changed_flags_to_ops(
2604    changed1: &[bool],
2605    changed2: &[bool],
2606    old_len: usize,
2607    new_len: usize,
2608) -> Vec<similar::DiffOp> {
2609    use similar::DiffOp;
2610    let mut ops: Vec<DiffOp> = Vec::new();
2611    let mut i = 0usize;
2612    let mut j = 0usize;
2613    while i < old_len || j < new_len {
2614        let del = i < old_len && changed1[i];
2615        let ins = j < new_len && changed2[j];
2616        if !del && !ins {
2617            if i < old_len && j < new_len {
2618                // Equal run on both sides.
2619                let start_i = i;
2620                let start_j = j;
2621                while i < old_len && j < new_len && !changed1[i] && !changed2[j] {
2622                    i += 1;
2623                    j += 1;
2624                }
2625                ops.push(DiffOp::Equal {
2626                    old_index: start_i,
2627                    new_index: start_j,
2628                    len: i - start_i,
2629                });
2630            } else if i < old_len {
2631                let start_i = i;
2632                while i < old_len {
2633                    i += 1;
2634                }
2635                ops.push(DiffOp::Delete {
2636                    old_index: start_i,
2637                    old_len: i - start_i,
2638                    new_index: j,
2639                });
2640            } else if j < new_len {
2641                let start_j = j;
2642                while j < new_len {
2643                    j += 1;
2644                }
2645                ops.push(DiffOp::Insert {
2646                    old_index: i,
2647                    new_index: start_j,
2648                    new_len: j - start_j,
2649                });
2650            }
2651        } else {
2652            // Changed run: consecutive deletions then/and insertions.
2653            let start_i = i;
2654            let start_j = j;
2655            while i < old_len && changed1[i] {
2656                i += 1;
2657            }
2658            while j < new_len && changed2[j] {
2659                j += 1;
2660            }
2661            let dlen = i - start_i;
2662            let ilen = j - start_j;
2663            if dlen > 0 && ilen > 0 {
2664                ops.push(DiffOp::Replace {
2665                    old_index: start_i,
2666                    old_len: dlen,
2667                    new_index: start_j,
2668                    new_len: ilen,
2669                });
2670            } else if dlen > 0 {
2671                ops.push(DiffOp::Delete {
2672                    old_index: start_i,
2673                    old_len: dlen,
2674                    new_index: start_j,
2675                });
2676            } else if ilen > 0 {
2677                ops.push(DiffOp::Insert {
2678                    old_index: start_i,
2679                    new_index: start_j,
2680                    new_len: ilen,
2681                });
2682            }
2683        }
2684    }
2685    ops
2686}
2687
2688/// Line-diff ops for string slices after Git `xdl_change_compact` (and optional indent heuristic).
2689#[must_use]
2690#[allow(dead_code)]
2691fn diff_slice_ops_compacted(
2692    old_lines: &[&str],
2693    new_lines: &[&str],
2694    algorithm: similar::Algorithm,
2695    indent_heuristic: bool,
2696) -> Vec<similar::DiffOp> {
2697    diff_indent_heuristic::diff_slice_ops_compacted(
2698        old_lines,
2699        new_lines,
2700        algorithm,
2701        indent_heuristic,
2702    )
2703}
2704
2705/// Compacted line-diff ops from Git's xdiff Myers engine over precomputed line ids for each side
2706/// (default blame path; faster than `similar` on large files).
2707#[must_use]
2708///
2709/// `shared_counts` and `shared_records` describe identical records of both files left out of
2710/// the slices, as for [`git_xdiff::changed_flags_with_shared`]; pass `&[]` and `0` for whole files.
2711#[allow(clippy::too_many_arguments)]
2712pub(crate) fn line_diff_ops_git_xdiff_compacted_ids(
2713    old_ids: &[u32],
2714    new_ids: &[u32],
2715    shared_counts: &[u32],
2716    shared_records: usize,
2717    old_lines: &[&str],
2718    new_lines: &[&str],
2719    indent_heuristic: bool,
2720    ws: super::WhitespaceMode,
2721) -> Vec<similar::DiffOp> {
2722    let (changed1, changed2) = git_xdiff::changed_flags_with_shared(
2723        old_ids,
2724        new_ids,
2725        shared_counts,
2726        shared_records,
2727        false,
2728    );
2729    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2730    diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
2731        &ops,
2732        old_lines,
2733        new_lines,
2734        indent_heuristic,
2735        ws,
2736    )
2737}
2738
2739/// Whether each new-side line is marked changed by Git's xdiff engine (after change compaction).
2740#[cfg(test)]
2741#[must_use]
2742pub(crate) fn xdiff_new_line_changed_flags(
2743    old_lines: &[&str],
2744    new_lines: &[&str],
2745    indent_heuristic: bool,
2746) -> Vec<bool> {
2747    let mut interner =
2748        crate::line_interner::LineInterner::with_capacity(old_lines.len() + new_lines.len());
2749    let ids1 = interner.intern_all(old_lines);
2750    let ids2 = interner.intern_all(new_lines);
2751    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
2752    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2753    let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
2754        &ops,
2755        old_lines,
2756        new_lines,
2757        indent_heuristic,
2758    );
2759    let mut flags = vec![false; new_lines.len()];
2760    for op in compacted {
2761        match op.tag() {
2762            similar::DiffTag::Insert | similar::DiffTag::Replace => {
2763                for idx in op.new_range() {
2764                    if idx < flags.len() {
2765                        flags[idx] = true;
2766                    }
2767                }
2768            }
2769            similar::DiffTag::Equal | similar::DiffTag::Delete => {}
2770        }
2771    }
2772    flags
2773}
2774
2775/// The kind of change between two sides of a diff.
2776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2777#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2778#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
2779pub enum DiffStatus {
2780    /// File was added.
2781    Added,
2782    /// File was deleted.
2783    Deleted,
2784    /// File was modified (content or mode change).
2785    Modified,
2786    /// File was renamed (with optional content change).
2787    Renamed,
2788    /// File was copied.
2789    Copied,
2790    /// File type changed (e.g. regular → symlink).
2791    TypeChanged,
2792    /// Unmerged (conflict).
2793    Unmerged,
2794}
2795
2796/// Unstaged status letter for a worktree diff entry (gitlink `m` vs `M` for submodules).
2797#[must_use]
2798pub(crate) fn gitlink_unstaged_status_letter(entry: &DiffEntry) -> char {
2799    if entry.old_mode.is_gitlink() && entry.status == DiffStatus::Modified {
2800        if entry.new_oid.is_zero() {
2801            return 'm';
2802        }
2803        return 'M';
2804    }
2805    entry.status.letter()
2806}
2807
2808impl DiffStatus {
2809    /// Single-character status letter used in raw diff output.
2810    #[must_use]
2811    pub fn letter(&self) -> char {
2812        match self {
2813            Self::Added => 'A',
2814            Self::Deleted => 'D',
2815            Self::Modified => 'M',
2816            Self::Renamed => 'R',
2817            Self::Copied => 'C',
2818            Self::TypeChanged => 'T',
2819            Self::Unmerged => 'U',
2820        }
2821    }
2822
2823    /// Status for a path present on both sides with differing content or mode.
2824    ///
2825    /// Returns [`DiffStatus::TypeChanged`] when the object type bits of the two
2826    /// modes differ (regular file, symlink, gitlink) and [`DiffStatus::Modified`]
2827    /// otherwise, so a permission-only change stays `Modified`. Every diff mode
2828    /// (tree, index, work tree) shares this rule.
2829    #[must_use]
2830    pub(crate) fn for_content_change(old_mode: u32, new_mode: u32) -> Self {
2831        if old_mode & 0o170000 != new_mode & 0o170000 {
2832            Self::TypeChanged
2833        } else {
2834            Self::Modified
2835        }
2836    }
2837}
2838
2839/// A single diff entry representing one changed path.
2840#[derive(Debug, Clone, PartialEq, Eq)]
2841#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2842pub struct DiffEntry {
2843    /// The status of this change.
2844    pub status: DiffStatus,
2845    /// Path in the "old" side (None for Added).
2846    pub old_path: Option<String>,
2847    /// Path in the "new" side (None for Deleted).
2848    pub new_path: Option<String>,
2849    /// Old file mode ([`FileMode::ABSENT`] for Added).
2850    pub old_mode: FileMode,
2851    /// New file mode ([`FileMode::ABSENT`] for Deleted).
2852    pub new_mode: FileMode,
2853    /// Old object ID (zero OID for Added).
2854    pub old_oid: ObjectId,
2855    /// New object ID (zero OID for Deleted, or for Modified index↔worktree / tree↔worktree
2856    /// entries whose content lives only on disk — raw output uses the null OID on that side).
2857    pub new_oid: ObjectId,
2858    /// Similarity score (0–100) for renames/copies.
2859    pub score: Option<u32>,
2860}
2861
2862impl DiffEntry {
2863    /// The primary path for display (new_path for adds, old_path for deletes).
2864    #[must_use]
2865    pub fn path(&self) -> &str {
2866        self.new_path
2867            .as_deref()
2868            .or(self.old_path.as_deref())
2869            .unwrap_or("")
2870    }
2871
2872    /// Return a human-oriented path display for this entry.
2873    ///
2874    /// For renames and copies this returns `old -> new`; for all other entry
2875    /// kinds this returns the primary path.
2876    #[must_use]
2877    pub fn display_path(&self) -> String {
2878        match self.status {
2879            DiffStatus::Renamed | DiffStatus::Copied => {
2880                let old = self.old_path.as_deref().unwrap_or("");
2881                let new = self.new_path.as_deref().unwrap_or("");
2882                if old.is_empty() || new.is_empty() {
2883                    self.path().to_owned()
2884                } else {
2885                    format!("{old} -> {new}")
2886                }
2887            }
2888            _ => self.path().to_owned(),
2889        }
2890    }
2891}
2892
2893/// Return the zero ObjectId.
2894#[must_use]
2895pub fn zero_oid() -> ObjectId {
2896    ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
2897        // This should never fail since we pass exactly 20 bytes
2898        panic!("internal error: failed to create zero OID");
2899    })
2900}
2901
2902/// Return the ObjectId for the empty blob object.
2903#[must_use]
2904pub fn empty_blob_oid() -> ObjectId {
2905    ObjectId::from_hex("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap_or_else(|_| {
2906        // This should never fail since the object ID literal is valid.
2907        panic!("internal error: failed to create empty blob OID");
2908    })
2909}
2910
2911// ── Tree-to-tree diff ───────────────────────────────────────────────
2912
2913/// Compare two trees and return the list of changed entries.
2914///
2915/// # Parameters
2916///
2917/// - `odb` — object database to read tree objects from.
2918/// - `old_tree_oid` — OID of the old tree (or `None` for comparison against empty).
2919/// - `new_tree_oid` — OID of the new tree (or `None` for comparison against empty).
2920/// - `prefix` — path prefix for nested tree recursion (empty string for root).
2921///
2922/// # Errors
2923///
2924/// Returns errors from object database reads.
2925pub fn diff_trees(
2926    odb: &Odb,
2927    old_tree_oid: Option<&ObjectId>,
2928    new_tree_oid: Option<&ObjectId>,
2929    prefix: &str,
2930) -> Result<Vec<DiffEntry>> {
2931    crate::trace::trace_span!("grit.diff.tree");
2932    diff_trees_with_cancel(odb, old_tree_oid, new_tree_oid, prefix, None)
2933}
2934
2935pub(crate) fn diff_trees_with_cancel(
2936    odb: &Odb,
2937    old_tree_oid: Option<&ObjectId>,
2938    new_tree_oid: Option<&ObjectId>,
2939    prefix: &str,
2940    cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
2941) -> Result<Vec<DiffEntry>> {
2942    crate::trace::trace_span!("grit.diff.tree");
2943    let hint = cancel_watch.map(|watch| DiffTreesWalkHint {
2944        cancel_watch: Some(watch),
2945        ..DiffTreesWalkHint::default()
2946    });
2947    diff_trees_opts(
2948        odb,
2949        old_tree_oid,
2950        new_tree_oid,
2951        prefix,
2952        false,
2953        hint.as_ref(),
2954    )
2955}
2956
2957/// Like [`diff_trees`], but when `index` matches `old_tree_oid` deleted subtrees are expanded
2958/// from sorted index entries instead of reading every tree object from the ODB.
2959pub(crate) fn diff_trees_with_index(
2960    odb: &Odb,
2961    index: &Index,
2962    old_tree_oid: Option<&ObjectId>,
2963    new_tree_oid: Option<&ObjectId>,
2964    prefix: &str,
2965    trust_index_for_old_tree: bool,
2966    cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
2967) -> Result<Vec<DiffEntry>> {
2968    let hint = (trust_index_for_old_tree
2969        && old_tree_oid.is_some()
2970        && index.entries_sorted
2971        && !index.entries.iter().any(|e| e.stage() != 0))
2972    .then_some(DiffTreesWalkHint {
2973        index: Some(index),
2974        old_tree_matches: true,
2975        cancel_watch,
2976        ..DiffTreesWalkHint::default()
2977    });
2978    diff_trees_opts(
2979        odb,
2980        old_tree_oid,
2981        new_tree_oid,
2982        prefix,
2983        false,
2984        hint.as_ref(),
2985    )
2986}
2987
2988/// Like [`diff_trees`], but subtrees at directories `index` holds as sparse-directory rows are
2989/// not descended into, so no entry lies inside one. Paths of those directories themselves (a
2990/// blob replaced by a tree, say) are still reported.
2991pub(crate) fn diff_trees_outside_sparse_directories(
2992    odb: &Odb,
2993    index: &Index,
2994    old_tree_oid: Option<&ObjectId>,
2995    new_tree_oid: Option<&ObjectId>,
2996    sparse: Option<&SparseCheckout>,
2997    index_sparse: bool,
2998    cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
2999) -> Result<Vec<DiffEntry>> {
3000    crate::trace::trace_span!("grit.diff.tree");
3001    let hint = index
3002        .has_sparse_directory_placeholders()
3003        .then_some(DiffTreesWalkHint {
3004            index: Some(index),
3005            skip_sparse_directories: true,
3006            sparse,
3007            index_sparse,
3008            cancel_watch,
3009            ..DiffTreesWalkHint::default()
3010        });
3011    diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, hint.as_ref())
3012}
3013
3014/// Tree diff for hard reset and similar checkouts: index-backed old tree when clean, and one
3015/// entry per wholly added or removed subtree instead of every file path.
3016pub(crate) fn diff_trees_coalesced_for_checkout(
3017    odb: &Odb,
3018    index: &Index,
3019    old_tree_oid: Option<&ObjectId>,
3020    new_tree_oid: Option<&ObjectId>,
3021    sparse: Option<&SparseCheckout>,
3022    index_sparse: bool,
3023) -> Result<Vec<DiffEntry>> {
3024    crate::trace::trace_span!("grit.diff.tree");
3025    let trust_index_for_old_tree = old_tree_oid.is_some()
3026        && index.entries_sorted
3027        && !index.entries.iter().any(|e| e.stage() != 0);
3028    let hint = DiffTreesWalkHint {
3029        index: trust_index_for_old_tree.then_some(index),
3030        old_tree_matches: trust_index_for_old_tree,
3031        coalesce_subtrees_for_checkout: true,
3032        skip_sparse_directories: index.has_sparse_directory_placeholders(),
3033        sparse,
3034        index_sparse,
3035        ..DiffTreesWalkHint::default()
3036    };
3037    diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, Some(&hint))
3038}
3039
3040/// Whether directory `dir` may contain paths selected by `pathspecs` during a tree walk.
3041fn tree_walk_directory_relevant(
3042    dir: &str,
3043    pathspecs: &[String],
3044    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3045) -> bool {
3046    if pathspecs.is_empty() {
3047        return true;
3048    }
3049    if let Some(matcher) = pathspec_matcher {
3050        return matcher.matches_entry(dir, 0o040000)
3051            || crate::pathspec::pathspec_wants_descent_for_rev_tree(dir, pathspecs);
3052    }
3053    crate::pathspec::pathspec_entry_is_relevant(dir, true, pathspecs)
3054}
3055
3056fn diff_entry_matches_pathspecs(
3057    entry: &DiffEntry,
3058    pathspecs: &[String],
3059    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3060) -> bool {
3061    let path = entry.path();
3062    if path.is_empty() {
3063        return false;
3064    }
3065    if let Some(matcher) = pathspec_matcher {
3066        return matcher.matches_change(path, entry.old_path.as_deref());
3067    }
3068    crate::pathspec::matches_pathspec_list(path, pathspecs)
3069}
3070
3071/// Like [`diff_trees`], but only paths `pathspecs` select (Git's pathspec-limited tree diff).
3072///
3073/// Directories no pathspec can reach are not read. An empty list selects every path.
3074/// When `pathspec_matcher` is set, matching uses the repository attribute stack (required for
3075/// `:(attr:...)` magic).
3076pub(crate) fn diff_trees_for_pathspecs(
3077    odb: &Odb,
3078    old_tree_oid: Option<&ObjectId>,
3079    new_tree_oid: Option<&ObjectId>,
3080    pathspecs: &[String],
3081    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3082) -> Result<Vec<DiffEntry>> {
3083    if pathspecs.is_empty() {
3084        return diff_trees(odb, old_tree_oid, new_tree_oid, "");
3085    }
3086    crate::trace::trace_span!("grit.diff.tree");
3087    let hint = DiffTreesWalkHint {
3088        pathspecs,
3089        pathspec_matcher,
3090        ..DiffTreesWalkHint::default()
3091    };
3092    let mut entries = diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, Some(&hint))?;
3093    entries.retain(|entry| diff_entry_matches_pathspecs(entry, pathspecs, pathspec_matcher));
3094    Ok(entries)
3095}
3096
3097/// Directories a tree diff may skip, and an index that can stand in for the old tree.
3098#[derive(Clone, Default)]
3099struct DiffTreesWalkHint<'a> {
3100    index: Option<&'a Index>,
3101    /// `index` holds exactly the old tree's entries, so deleted subtrees may be listed from it.
3102    old_tree_matches: bool,
3103    /// Emit one tree entry per removed or added subtree instead of every descendant path.
3104    coalesce_subtrees_for_checkout: bool,
3105    /// Do not descend into directories `index` holds as sparse-directory rows.
3106    skip_sparse_directories: bool,
3107    sparse: Option<&'a SparseCheckout>,
3108    index_sparse: bool,
3109    /// When non-empty, do not descend into directories these pathspecs cannot match inside.
3110    pathspecs: &'a [String],
3111    pathspec_matcher: Option<&'a crate::pathspec::PathspecMatcher<'a>>,
3112    /// When set, long walks poll this flag every [`crate::progress::CANCEL_POLL_STRIDE`] entries.
3113    cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
3114}
3115
3116impl DiffTreesWalkHint<'_> {
3117    /// Cone sparse checkout keeps `dir` (no trailing slash) as one opaque tree row.
3118    fn sparse_excludes_tree(&self, dir: &str) -> bool {
3119        if !self.index_sparse {
3120            return false;
3121        }
3122        let Some(sparse) = self.sparse else {
3123            return false;
3124        };
3125        let mut path = crate::repo_relpath::storage_bytes(dir).into_owned();
3126        if !path.ends_with(b"/") {
3127            path.push(b'/');
3128        }
3129        sparse.excludes_directory(&path)
3130    }
3131
3132    /// Whether the walk must not enter directory `dir` (stored form, no trailing slash).
3133    fn skips(&self, dir: &str) -> bool {
3134        if dir.is_empty() {
3135            return false;
3136        }
3137        if !self.pathspecs.is_empty()
3138            && !tree_walk_directory_relevant(dir, self.pathspecs, self.pathspec_matcher)
3139        {
3140            return true;
3141        }
3142        let Some(index) = self.index.filter(|_| self.skip_sparse_directories) else {
3143            return false;
3144        };
3145        let mut row = crate::repo_relpath::storage_bytes(dir).into_owned();
3146        row.push(b'/');
3147        index
3148            .find_entry_pos(&row, 0)
3149            .is_some_and(|pos| index.entries[pos].is_sparse_directory_placeholder())
3150    }
3151}
3152
3153/// Count path-level differences between two trees without building a [`DiffEntry`] list.
3154///
3155/// Same semantics as [`diff_trees`], but avoids allocating diff records (issue #1064 commit hot path).
3156///
3157/// # Errors
3158///
3159/// Returns errors from object database reads.
3160pub(crate) fn diff_tree_path_change_count(
3161    odb: &Odb,
3162    old_tree_oid: Option<&ObjectId>,
3163    new_tree_oid: Option<&ObjectId>,
3164    prefix: &str,
3165) -> Result<usize> {
3166    if old_tree_oid == new_tree_oid {
3167        return Ok(0);
3168    }
3169    let old_entries = match old_tree_oid {
3170        Some(oid) => read_tree(odb, oid)?,
3171        None => Vec::new(),
3172    };
3173    let new_entries = match new_tree_oid {
3174        Some(oid) => read_tree(odb, oid)?,
3175        None => Vec::new(),
3176    };
3177    let mut count = 0usize;
3178    diff_tree_entries_count(odb, &old_entries, &new_entries, prefix, false, &mut count)?;
3179    Ok(count)
3180}
3181
3182/// Diff two trees and pair deleted/added paths as renames when content is similar enough.
3183///
3184/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
3185/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
3186///
3187/// # Errors
3188///
3189/// Propagates errors from [`diff_trees`].
3190pub fn diff_trees_with_renames(
3191    odb: &Odb,
3192    old_tree_oid: Option<&ObjectId>,
3193    new_tree_oid: Option<&ObjectId>,
3194    prefix: &str,
3195    rename_threshold: u32,
3196) -> Result<Vec<DiffEntry>> {
3197    let config = ConfigSet::new();
3198    let policy = ThreadPolicy::default();
3199    diff_trees_with_renames_and_policy(
3200        odb,
3201        old_tree_oid,
3202        new_tree_oid,
3203        prefix,
3204        rename_threshold,
3205        &config,
3206        &policy,
3207    )
3208}
3209
3210/// Like [`diff_trees_with_renames`], but honors `index.threads` / embedder thread policy.
3211///
3212/// # Errors
3213///
3214/// Propagates errors from [`diff_trees`].
3215pub(crate) fn diff_trees_with_renames_and_policy(
3216    odb: &Odb,
3217    old_tree_oid: Option<&ObjectId>,
3218    new_tree_oid: Option<&ObjectId>,
3219    prefix: &str,
3220    rename_threshold: u32,
3221    config: &ConfigSet,
3222    thread_policy: &ThreadPolicy,
3223) -> Result<Vec<DiffEntry>> {
3224    Ok(diff_trees_with_renames_and_policy_result(
3225        odb,
3226        old_tree_oid,
3227        new_tree_oid,
3228        prefix,
3229        rename_threshold,
3230        config,
3231        thread_policy,
3232    )?
3233    .entries)
3234}
3235
3236/// Like [`diff_trees_with_renames_and_policy`], but also returns rename-limit skip metadata.
3237///
3238/// # Errors
3239///
3240/// Propagates errors from [`diff_trees`] and rename detection.
3241pub(crate) fn diff_trees_with_renames_and_policy_result(
3242    odb: &Odb,
3243    old_tree_oid: Option<&ObjectId>,
3244    new_tree_oid: Option<&ObjectId>,
3245    prefix: &str,
3246    rename_threshold: u32,
3247    config: &ConfigSet,
3248    thread_policy: &ThreadPolicy,
3249) -> Result<DiffEntriesResult> {
3250    diff_trees_with_renames_for_pathspecs_and_policy_result(
3251        odb,
3252        old_tree_oid,
3253        new_tree_oid,
3254        prefix,
3255        &[],
3256        None,
3257        rename_threshold,
3258        config,
3259        thread_policy,
3260    )
3261}
3262
3263/// Like [`diff_trees_with_renames_and_policy`], but limits the tree walk to `pathspecs` before
3264/// rename detection (Git's pathspec-limited tree diff).
3265///
3266/// An empty `pathspecs` list diffs every path under `prefix`.
3267///
3268/// # Errors
3269///
3270/// Propagates errors from [`diff_trees`] and [`detect_renames_with_policy`].
3271#[allow(clippy::too_many_arguments)]
3272pub(crate) fn diff_trees_with_renames_for_pathspecs_and_policy(
3273    odb: &Odb,
3274    old_tree_oid: Option<&ObjectId>,
3275    new_tree_oid: Option<&ObjectId>,
3276    prefix: &str,
3277    pathspecs: &[String],
3278    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3279    rename_threshold: u32,
3280    config: &ConfigSet,
3281    thread_policy: &ThreadPolicy,
3282) -> Result<Vec<DiffEntry>> {
3283    Ok(diff_trees_with_renames_for_pathspecs_and_policy_result(
3284        odb,
3285        old_tree_oid,
3286        new_tree_oid,
3287        prefix,
3288        pathspecs,
3289        pathspec_matcher,
3290        rename_threshold,
3291        config,
3292        thread_policy,
3293    )?
3294    .entries)
3295}
3296
3297/// Pathspec-limited tree diff with rename detection and rename-limit skip metadata.
3298#[allow(clippy::too_many_arguments)]
3299pub(crate) fn diff_trees_with_renames_for_pathspecs_and_policy_result(
3300    odb: &Odb,
3301    old_tree_oid: Option<&ObjectId>,
3302    new_tree_oid: Option<&ObjectId>,
3303    prefix: &str,
3304    pathspecs: &[String],
3305    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3306    rename_threshold: u32,
3307    config: &ConfigSet,
3308    thread_policy: &ThreadPolicy,
3309) -> Result<DiffEntriesResult> {
3310    let entries = if pathspecs.is_empty() {
3311        diff_trees(odb, old_tree_oid, new_tree_oid, prefix)?
3312    } else {
3313        diff_trees_for_pathspecs(odb, old_tree_oid, new_tree_oid, pathspecs, pathspec_matcher)?
3314    };
3315    if rename_threshold <= 100 {
3316        let out = detect_renames_with_policy(
3317            odb,
3318            None,
3319            entries,
3320            rename_threshold,
3321            config,
3322            thread_policy,
3323            None,
3324            DetectRenamesOptions::default(),
3325        )?;
3326        return Ok(DiffEntriesResult {
3327            entries: out.entries,
3328            inexact_rename_skipped: out.inexact_skipped,
3329        });
3330    }
3331    Ok(DiffEntriesResult {
3332        entries,
3333        inexact_rename_skipped: None,
3334    })
3335}
3336
3337/// Return the tree OID stored in a commit object.
3338///
3339/// # Errors
3340///
3341/// Returns [`Error::InvalidRef`] when `commit_oid` is not a commit, and object read/parse
3342/// errors when it is missing or malformed.
3343pub fn tree_oid_of_commit(odb: &Odb, commit_oid: &ObjectId) -> Result<ObjectId> {
3344    odb.commit_tree(commit_oid).map_err(|err| match err {
3345        Error::UnexpectedObjectKind { .. } => {
3346            Error::InvalidRef(format!("invalid ref: {commit_oid} is not a commit"))
3347        }
3348        other => other,
3349    })
3350}
3351
3352/// Tree OID to use as one side of a tree diff when the id names a commit or a tree.
3353///
3354/// # Errors
3355///
3356/// Returns [`Error::InvalidRef`] when `oid` is not a commit or tree, and object read/parse
3357/// errors when it is missing or malformed.
3358pub(crate) fn tree_oid_for_diff_side(odb: &Odb, oid: &ObjectId) -> Result<ObjectId> {
3359    use crate::objects::{CommitRef, ObjectKind};
3360    let object = odb.read(oid)?;
3361    match object.kind {
3362        ObjectKind::Commit => Ok(CommitRef::parse(&object.data)?.tree()),
3363        ObjectKind::Tree => Ok(*oid),
3364        actual => Err(Error::InvalidRef(format!(
3365            "invalid ref: {oid} is not a commit or tree ({actual:?})"
3366        ))),
3367    }
3368}
3369
3370/// Like [`tree_oid_of_commit`], but uses the repository commit-graph cache when available.
3371///
3372/// # Errors
3373///
3374/// Returns object read/parse errors when `commit_oid` is missing or not a commit.
3375pub(crate) fn tree_oid_of_commit_in_repo(
3376    repo: &crate::repo::Repository,
3377    commit_oid: &ObjectId,
3378) -> Result<ObjectId> {
3379    if repo.object_id_after_replace(commit_oid) == *commit_oid {
3380        if let Some(chain) = repo.odb().commit_graph_chain() {
3381            if let Some(tree) = chain.graph_commit_tree(commit_oid) {
3382                return Ok(tree);
3383            }
3384        }
3385    }
3386    let effective = repo.object_id_after_replace(commit_oid);
3387    tree_oid_of_commit(repo.odb(), &effective)
3388}
3389
3390/// Diff the trees recorded at two commits (`base` is the old side, `tip` the new side).
3391///
3392/// # Errors
3393///
3394/// Propagates object read errors and tree diff failures.
3395pub fn diff_commits(odb: &Odb, base: &ObjectId, tip: &ObjectId) -> Result<Vec<DiffEntry>> {
3396    let base_tree = tree_oid_of_commit(odb, base)?;
3397    let tip_tree = tree_oid_of_commit(odb, tip)?;
3398    diff_trees(odb, Some(&base_tree), Some(&tip_tree), "")
3399}
3400
3401/// Diff the trees at two commits and pair deleted/added paths as renames when similar enough.
3402///
3403/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
3404/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
3405///
3406/// # Errors
3407///
3408/// Propagates object read errors and tree diff failures.
3409pub fn diff_commits_with_renames(
3410    odb: &Odb,
3411    base: &ObjectId,
3412    tip: &ObjectId,
3413    rename_threshold: u32,
3414) -> Result<Vec<DiffEntry>> {
3415    let config = ConfigSet::new();
3416    let policy = ThreadPolicy::default();
3417    diff_commits_with_renames_and_policy(odb, base, tip, rename_threshold, &config, &policy)
3418}
3419
3420/// Like [`diff_commits_with_renames`], but honors `index.threads` / embedder thread policy.
3421///
3422/// # Errors
3423///
3424/// Propagates object read errors and tree diff failures.
3425pub(crate) fn diff_commits_with_renames_and_policy(
3426    odb: &Odb,
3427    base: &ObjectId,
3428    tip: &ObjectId,
3429    rename_threshold: u32,
3430    config: &ConfigSet,
3431    thread_policy: &ThreadPolicy,
3432) -> Result<Vec<DiffEntry>> {
3433    let base_tree = tree_oid_of_commit(odb, base)?;
3434    let tip_tree = tree_oid_of_commit(odb, tip)?;
3435    diff_trees_with_renames_and_policy(
3436        odb,
3437        Some(&base_tree),
3438        Some(&tip_tree),
3439        "",
3440        rename_threshold,
3441        config,
3442        thread_policy,
3443    )
3444}
3445
3446/// Like [`diff_trees`], but emit entries for tree objects themselves in addition
3447/// to their recursive contents (Git's `diff-tree -t` behavior).
3448///
3449/// # Errors
3450///
3451/// Returns errors from object database reads.
3452pub fn diff_trees_show_tree_entries(
3453    odb: &Odb,
3454    old_tree_oid: Option<&ObjectId>,
3455    new_tree_oid: Option<&ObjectId>,
3456    prefix: &str,
3457) -> Result<Vec<DiffEntry>> {
3458    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, true, None)
3459}
3460
3461fn diff_subtrees_in_place(
3462    odb: &Odb,
3463    old_tree_oid: Option<&ObjectId>,
3464    new_tree_oid: Option<&ObjectId>,
3465    prefix: &str,
3466    show_trees: bool,
3467    index_hint: Option<&DiffTreesWalkHint<'_>>,
3468    result: &mut Vec<DiffEntry>,
3469) -> Result<()> {
3470    if index_hint.is_some_and(|hint| hint.skips(prefix)) {
3471        return Ok(());
3472    }
3473    if !show_trees {
3474        if let (Some(a), Some(b)) = (old_tree_oid, new_tree_oid) {
3475            if a == b {
3476                return Ok(());
3477            }
3478        }
3479    }
3480    // The ODB already budgets cached trees. Keep only active recursion frames
3481    // here so completing siblings can release trees evicted from that cache.
3482    let old_tree = old_tree_oid
3483        .map(|oid| odb.cached_tree_for_walk(oid))
3484        .transpose()?;
3485    let new_tree = new_tree_oid
3486        .map(|oid| odb.cached_tree_for_walk(oid))
3487        .transpose()?;
3488    let oid_len = odb.hash_algo().len();
3489    diff_tree_entries_opts(
3490        odb,
3491        WalkEntries::new(old_tree.as_ref(), oid_len),
3492        WalkEntries::new(new_tree.as_ref(), oid_len),
3493        prefix,
3494        show_trees,
3495        index_hint,
3496        result,
3497    )
3498}
3499
3500/// Entries of a tree from [`Odb::cached_tree_for_walk`] in stored order, borrowing names
3501/// from it.
3502enum WalkEntries<'a> {
3503    Parsed(std::slice::Iter<'a, TreeEntry>),
3504    Raw(TreeRefIter<'a>),
3505}
3506
3507impl<'a> WalkEntries<'a> {
3508    /// Iterate `tree`, or nothing when the side is absent.
3509    fn new(tree: Option<&'a SessionTree>, oid_len: usize) -> Self {
3510        match tree {
3511            Some(SessionTree::Parsed(entries)) => Self::Parsed(entries.iter()),
3512            Some(SessionTree::Raw(data)) => Self::Raw(TreeRefIter::with_oid_len(data, oid_len)),
3513            None => Self::Parsed([].iter()),
3514        }
3515    }
3516
3517    fn next_entry(&mut self) -> Result<Option<TreeEntryRef<'a>>> {
3518        match self {
3519            Self::Parsed(entries) => Ok(entries.next().map(|entry| TreeEntryRef {
3520                mode: entry.mode,
3521                name: &entry.name,
3522                oid: entry.oid,
3523            })),
3524            Self::Raw(entries) => entries.next().transpose(),
3525        }
3526    }
3527}
3528
3529fn diff_trees_opts(
3530    odb: &Odb,
3531    old_tree_oid: Option<&ObjectId>,
3532    new_tree_oid: Option<&ObjectId>,
3533    prefix: &str,
3534    show_trees: bool,
3535    index_hint: Option<&DiffTreesWalkHint<'_>>,
3536) -> Result<Vec<DiffEntry>> {
3537    let mut result = Vec::new();
3538    diff_subtrees_in_place(
3539        odb,
3540        old_tree_oid,
3541        new_tree_oid,
3542        prefix,
3543        show_trees,
3544        index_hint,
3545        &mut result,
3546    )?;
3547    Ok(result)
3548}
3549
3550#[cfg(test)]
3551use std::cell::RefCell;
3552#[cfg(test)]
3553use std::rc::Rc;
3554#[cfg(test)]
3555use std::sync::atomic::{AtomicUsize, Ordering};
3556
3557#[cfg(test)]
3558thread_local! {
3559    static DIFF_TREE_READ_HOOK: RefCell<Option<Rc<AtomicUsize>>> = const { RefCell::new(None) };
3560}
3561
3562/// Install a per-thread tree read counter for unit tests (cleared when `hook` is `None`).
3563#[cfg(test)]
3564pub(crate) fn set_diff_tree_read_hook_for_tests(hook: Option<Rc<AtomicUsize>>) {
3565    DIFF_TREE_READ_HOOK.with(|cell| *cell.borrow_mut() = hook);
3566}
3567
3568/// Read and parse a tree object from the ODB (no session cache).
3569fn read_tree_from_odb(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
3570    #[cfg(test)]
3571    if let Some(counter) = DIFF_TREE_READ_HOOK.with(|cell| cell.borrow().clone()) {
3572        counter.fetch_add(1, Ordering::Relaxed);
3573    }
3574    Ok((*odb.cached_tree_entries(oid)?).clone())
3575}
3576
3577/// Read and parse a tree object from the ODB.
3578fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
3579    read_tree_from_odb(odb, oid)
3580}
3581
3582fn diff_tree_entries_count(
3583    odb: &Odb,
3584    old: &[TreeEntry],
3585    new: &[TreeEntry],
3586    prefix: &str,
3587    show_trees: bool,
3588    count: &mut usize,
3589) -> Result<()> {
3590    let mut oi = 0;
3591    let mut ni = 0;
3592
3593    while oi < old.len() || ni < new.len() {
3594        match (old.get(oi), new.get(ni)) {
3595            (Some(o), Some(n)) => {
3596                let cmp = crate::objects::tree_entry_cmp(
3597                    &o.name,
3598                    is_tree_mode(o.mode),
3599                    &n.name,
3600                    is_tree_mode(n.mode),
3601                );
3602                match cmp {
3603                    std::cmp::Ordering::Less => {
3604                        emit_deleted_count(odb, o, prefix, show_trees, count)?;
3605                        oi += 1;
3606                    }
3607                    std::cmp::Ordering::Greater => {
3608                        emit_added_count(odb, n, prefix, show_trees, count)?;
3609                        ni += 1;
3610                    }
3611                    std::cmp::Ordering::Equal => {
3612                        if o.oid != n.oid
3613                            || crate::index::canon_mode(o.mode) != crate::index::canon_mode(n.mode)
3614                        {
3615                            let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
3616                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3617                                if show_trees {
3618                                    *count += 1;
3619                                }
3620                                *count += diff_tree_path_change_count(
3621                                    odb,
3622                                    Some(&o.oid),
3623                                    Some(&n.oid),
3624                                    &path,
3625                                )?;
3626                            } else if is_tree_mode(o.mode) != is_tree_mode(n.mode) {
3627                                emit_deleted_count(odb, o, prefix, show_trees, count)?;
3628                                emit_added_count(odb, n, prefix, show_trees, count)?;
3629                            } else {
3630                                *count += 1;
3631                            }
3632                        }
3633                        oi += 1;
3634                        ni += 1;
3635                    }
3636                }
3637            }
3638            (Some(o), None) => {
3639                emit_deleted_count(odb, o, prefix, show_trees, count)?;
3640                oi += 1;
3641            }
3642            (None, Some(n)) => {
3643                emit_added_count(odb, n, prefix, show_trees, count)?;
3644                ni += 1;
3645            }
3646            (None, None) => break,
3647        }
3648    }
3649
3650    Ok(())
3651}
3652
3653fn emit_deleted_count(
3654    odb: &Odb,
3655    entry: &TreeEntry,
3656    prefix: &str,
3657    show_trees: bool,
3658    count: &mut usize,
3659) -> Result<()> {
3660    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3661    if is_tree_mode(entry.mode) {
3662        if show_trees {
3663            *count += 1;
3664        }
3665        *count += diff_tree_path_change_count(odb, Some(&entry.oid), None, &path)?;
3666    } else {
3667        *count += 1;
3668    }
3669    Ok(())
3670}
3671
3672fn emit_added_count(
3673    odb: &Odb,
3674    entry: &TreeEntry,
3675    prefix: &str,
3676    show_trees: bool,
3677    count: &mut usize,
3678) -> Result<()> {
3679    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3680    if is_tree_mode(entry.mode) {
3681        if show_trees {
3682            *count += 1;
3683        }
3684        *count += diff_tree_path_change_count(odb, None, Some(&entry.oid), &path)?;
3685    } else {
3686        *count += 1;
3687    }
3688    Ok(())
3689}
3690
3691/// Compare two sorted streams of tree entries, recursing into subtrees.
3692fn poll_tree_diff_cancel(
3693    index_hint: Option<&DiffTreesWalkHint<'_>>,
3694    stride: &mut usize,
3695) -> Result<()> {
3696    use crate::progress::{poll_watch, CANCEL_POLL_STRIDE};
3697    *stride = stride.wrapping_add(1);
3698    if stride.is_multiple_of(CANCEL_POLL_STRIDE) {
3699        if let Some(watch) = index_hint.and_then(|hint| hint.cancel_watch.as_ref()) {
3700            poll_watch(watch)?;
3701        }
3702    }
3703    Ok(())
3704}
3705
3706fn diff_tree_entries_opts(
3707    odb: &Odb,
3708    mut old: WalkEntries<'_>,
3709    mut new: WalkEntries<'_>,
3710    prefix: &str,
3711    show_trees: bool,
3712    index_hint: Option<&DiffTreesWalkHint<'_>>,
3713    result: &mut Vec<DiffEntry>,
3714) -> Result<()> {
3715    let mut old_entry = old.next_entry()?;
3716    let mut new_entry = new.next_entry()?;
3717    let mut cancel_stride = 0usize;
3718
3719    loop {
3720        poll_tree_diff_cancel(index_hint, &mut cancel_stride)?;
3721        match (&old_entry, &new_entry) {
3722            (Some(o), Some(n)) => {
3723                let cmp = crate::objects::tree_entry_cmp(
3724                    o.name,
3725                    is_tree_mode(o.mode),
3726                    n.name,
3727                    is_tree_mode(n.mode),
3728                );
3729                match cmp {
3730                    std::cmp::Ordering::Less => {
3731                        // Old entry not in new → deleted
3732                        emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3733                        old_entry = old.next_entry()?;
3734                    }
3735                    std::cmp::Ordering::Greater => {
3736                        // New entry not in old → added
3737                        emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3738                        new_entry = new.next_entry()?;
3739                    }
3740                    std::cmp::Ordering::Equal => {
3741                        // Both present — check for changes
3742                        if o.oid != n.oid
3743                            || crate::index::canon_mode(o.mode) != crate::index::canon_mode(n.mode)
3744                        {
3745                            let path = crate::repo_relpath::format_path_stored(prefix, o.name);
3746                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3747                                // Both are trees
3748                                if show_trees {
3749                                    result.push(DiffEntry {
3750                                        status: DiffStatus::Modified,
3751                                        old_path: Some(path.clone()),
3752                                        new_path: Some(path.clone()),
3753                                        old_mode: FileMode::new(crate::index::canon_mode(o.mode)),
3754                                        new_mode: FileMode::new(crate::index::canon_mode(n.mode)),
3755                                        old_oid: o.oid,
3756                                        new_oid: n.oid,
3757                                        score: None,
3758                                    });
3759                                }
3760                                diff_subtrees_in_place(
3761                                    odb,
3762                                    Some(&o.oid),
3763                                    Some(&n.oid),
3764                                    &path,
3765                                    show_trees,
3766                                    index_hint,
3767                                    result,
3768                                )?;
3769                            } else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
3770                                // Tree → blob: delete tree contents, add blob
3771                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3772                                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3773                            } else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3774                                // Blob → tree: delete blob, add tree contents
3775                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3776                                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3777                            } else {
3778                                result.push(DiffEntry {
3779                                    status: DiffStatus::for_content_change(o.mode, n.mode),
3780                                    old_path: Some(path.clone()),
3781                                    new_path: Some(path),
3782                                    old_mode: FileMode::new(crate::index::canon_mode(o.mode)),
3783                                    new_mode: FileMode::new(crate::index::canon_mode(n.mode)),
3784                                    old_oid: o.oid,
3785                                    new_oid: n.oid,
3786                                    score: None,
3787                                });
3788                            }
3789                        }
3790                        old_entry = old.next_entry()?;
3791                        new_entry = new.next_entry()?;
3792                    }
3793                }
3794            }
3795            (Some(o), None) => {
3796                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3797                old_entry = old.next_entry()?;
3798            }
3799            (None, Some(n)) => {
3800                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3801                new_entry = new.next_entry()?;
3802            }
3803            (None, None) => break,
3804        }
3805    }
3806
3807    Ok(())
3808}
3809
3810fn emit_deleted_tree_from_index(
3811    index: &Index,
3812    dir_prefix: &str,
3813    index_hint: Option<&DiffTreesWalkHint<'_>>,
3814    result: &mut Vec<DiffEntry>,
3815) -> Result<()> {
3816    let scan_prefix: Vec<u8> = if dir_prefix.is_empty() {
3817        Vec::new()
3818    } else {
3819        format!("{dir_prefix}/").into_bytes()
3820    };
3821    let mut cancel_stride = 0usize;
3822    if index.entries_sorted {
3823        let start = if scan_prefix.is_empty() {
3824            0
3825        } else {
3826            index
3827                .entries
3828                .partition_point(|e| e.path.as_slice() < scan_prefix.as_slice())
3829        };
3830        for ie in index.entries[start..].iter() {
3831            poll_tree_diff_cancel(index_hint, &mut cancel_stride)?;
3832            if ie.stage() != 0 {
3833                continue;
3834            }
3835            if !scan_prefix.is_empty() && !ie.path.starts_with(&scan_prefix) {
3836                break;
3837            }
3838            if scan_prefix.is_empty() && ie.path.contains(&b'/') {
3839                continue;
3840            }
3841            let path = crate::repo_relpath::storage_string(&ie.path);
3842            result.push(DiffEntry {
3843                status: DiffStatus::Deleted,
3844                old_path: Some(path),
3845                new_path: None,
3846                old_mode: FileMode::new(ie.mode),
3847                new_mode: FileMode::ABSENT,
3848                old_oid: ie.oid,
3849                new_oid: zero_oid(),
3850                score: None,
3851            });
3852        }
3853        return Ok(());
3854    }
3855    for ie in &index.entries {
3856        poll_tree_diff_cancel(index_hint, &mut cancel_stride)?;
3857        if ie.stage() != 0 {
3858            continue;
3859        }
3860        let Ok(path) = std::str::from_utf8(&ie.path) else {
3861            continue;
3862        };
3863        let under = if dir_prefix.is_empty() {
3864            !path.contains('/')
3865        } else if path == dir_prefix {
3866            false
3867        } else {
3868            path.strip_prefix(dir_prefix)
3869                .is_some_and(|rest| rest.starts_with('/'))
3870        };
3871        if !under {
3872            continue;
3873        }
3874        result.push(DiffEntry {
3875            status: DiffStatus::Deleted,
3876            old_path: Some(path.to_owned()),
3877            new_path: None,
3878            old_mode: FileMode::new(ie.mode),
3879            new_mode: FileMode::ABSENT,
3880            old_oid: ie.oid,
3881            new_oid: zero_oid(),
3882            score: None,
3883        });
3884    }
3885    Ok(())
3886}
3887
3888fn emit_deleted_opts(
3889    odb: &Odb,
3890    entry: &TreeEntryRef<'_>,
3891    prefix: &str,
3892    show_trees: bool,
3893    index_hint: Option<&DiffTreesWalkHint<'_>>,
3894    result: &mut Vec<DiffEntry>,
3895) -> Result<()> {
3896    let path = crate::repo_relpath::format_path_stored(prefix, entry.name);
3897    if is_tree_mode(entry.mode) {
3898        if index_hint.is_some_and(|hint| hint.sparse_excludes_tree(&path)) {
3899            return Ok(());
3900        }
3901        if show_trees || index_hint.is_some_and(|hint| hint.coalesce_subtrees_for_checkout) {
3902            result.push(DiffEntry {
3903                status: DiffStatus::Deleted,
3904                old_path: Some(path.clone()),
3905                new_path: None,
3906                old_mode: FileMode::new(entry.mode),
3907                new_mode: FileMode::ABSENT,
3908                old_oid: entry.oid,
3909                new_oid: zero_oid(),
3910                score: None,
3911            });
3912        }
3913        if index_hint.is_some_and(|hint| hint.coalesce_subtrees_for_checkout) {
3914            return Ok(());
3915        }
3916        if let Some(index) = index_hint
3917            .filter(|hint| hint.old_tree_matches)
3918            .and_then(|hint| hint.index)
3919            .filter(|index| !index.has_sparse_directory_placeholders())
3920        {
3921            emit_deleted_tree_from_index(index, &path, index_hint, result)?;
3922            return Ok(());
3923        }
3924        diff_subtrees_in_place(
3925            odb,
3926            Some(&entry.oid),
3927            None,
3928            &path,
3929            show_trees,
3930            index_hint,
3931            result,
3932        )?;
3933    } else {
3934        result.push(DiffEntry {
3935            status: DiffStatus::Deleted,
3936            old_path: Some(path.clone()),
3937            new_path: None,
3938            old_mode: FileMode::new(crate::index::canon_mode(entry.mode)),
3939            new_mode: FileMode::ABSENT,
3940            old_oid: entry.oid,
3941            new_oid: zero_oid(),
3942            score: None,
3943        });
3944    }
3945    Ok(())
3946}
3947
3948fn emit_added_opts(
3949    odb: &Odb,
3950    entry: &TreeEntryRef<'_>,
3951    prefix: &str,
3952    show_trees: bool,
3953    index_hint: Option<&DiffTreesWalkHint<'_>>,
3954    result: &mut Vec<DiffEntry>,
3955) -> Result<()> {
3956    let path = crate::repo_relpath::format_path_stored(prefix, entry.name);
3957    if is_tree_mode(entry.mode) {
3958        if index_hint.is_some_and(|hint| hint.sparse_excludes_tree(&path)) {
3959            result.push(DiffEntry {
3960                status: DiffStatus::Added,
3961                old_path: None,
3962                new_path: Some(crate::repo_relpath::sparse_directory_stored_path(&path)),
3963                old_mode: FileMode::ABSENT,
3964                new_mode: FileMode::new(MODE_TREE),
3965                old_oid: zero_oid(),
3966                new_oid: entry.oid,
3967                score: None,
3968            });
3969            return Ok(());
3970        }
3971        if show_trees || index_hint.is_some_and(|hint| hint.coalesce_subtrees_for_checkout) {
3972            result.push(DiffEntry {
3973                status: DiffStatus::Added,
3974                old_path: None,
3975                new_path: Some(path.clone()),
3976                old_mode: FileMode::ABSENT,
3977                new_mode: FileMode::new(entry.mode),
3978                old_oid: zero_oid(),
3979                new_oid: entry.oid,
3980                score: None,
3981            });
3982        }
3983        if index_hint.is_some_and(|hint| hint.coalesce_subtrees_for_checkout) {
3984            return Ok(());
3985        }
3986        diff_subtrees_in_place(
3987            odb,
3988            None,
3989            Some(&entry.oid),
3990            &path,
3991            show_trees,
3992            index_hint,
3993            result,
3994        )?;
3995    } else {
3996        result.push(DiffEntry {
3997            status: DiffStatus::Added,
3998            old_path: None,
3999            new_path: Some(path),
4000            old_mode: FileMode::ABSENT,
4001            new_mode: FileMode::new(crate::index::canon_mode(entry.mode)),
4002            old_oid: zero_oid(),
4003            new_oid: entry.oid,
4004            score: None,
4005        });
4006    }
4007    Ok(())
4008}
4009
4010// ── Index-to-tree diff (staged changes) ─────────────────────────────
4011
4012/// Compare the index against a tree (usually HEAD's tree).
4013///
4014/// This shows "staged" changes — what would be committed.
4015///
4016/// # Parameters
4017///
4018/// - `odb` — object database.
4019/// - `index` — the current index.
4020/// - `tree_oid` — the tree to compare against (e.g. HEAD's tree), or `None`
4021///   for comparison against an empty tree (initial commit).
4022///
4023/// # Errors
4024///
4025/// Returns errors from ODB reads.
4026///
4027/// When `ignore_submodules` is true, gitlink (`160000`) paths are omitted from the diff, matching
4028/// Git's `require_clean_work_tree(..., ignore_submodules=1)` used by `git rebase` / `git pull`.
4029pub fn diff_index_to_tree(
4030    odb: &Odb,
4031    index: &Index,
4032    tree_oid: Option<&ObjectId>,
4033    ignore_submodules: bool,
4034) -> Result<Vec<DiffEntry>> {
4035    match index_cache_tree_walk(index, tree_oid) {
4036        CacheTreeWalk::Fast {
4037            head_tree,
4038            cache_root,
4039        } => {
4040            diff_index_to_tree_with_cache_tree(odb, index, head_tree, cache_root, ignore_submodules)
4041        }
4042        CacheTreeWalk::Flatten => {
4043            diff_index_to_tree_flatten(odb, index, tree_oid, ignore_submodules)
4044        }
4045    }
4046}
4047
4048/// Diff the index against a tree and pair deleted/added paths as renames when similar enough.
4049///
4050/// `rename_threshold` is Git's `-M` percentage (50 is the usual default); pass `100` to skip
4051/// rename detection.
4052///
4053/// # Errors
4054///
4055/// Propagates errors from [`diff_index_to_tree`].
4056pub(crate) fn diff_index_to_tree_with_renames(
4057    odb: &Odb,
4058    index: &Index,
4059    tree_oid: Option<&ObjectId>,
4060    ignore_submodules: bool,
4061    rename_threshold: u32,
4062    config: &ConfigSet,
4063    thread_policy: &ThreadPolicy,
4064) -> Result<Vec<DiffEntry>> {
4065    let entries = diff_index_to_tree(odb, index, tree_oid, ignore_submodules)?;
4066    apply_rename_detection_to_diff_entries(
4067        odb,
4068        entries,
4069        None,
4070        rename_threshold,
4071        config,
4072        thread_policy,
4073    )
4074}
4075
4076/// Apply Git `-M` rename detection to a flat diff (index↔tree, tree↔tree, or index↔worktree).
4077///
4078/// Pass `work_root` when added sides may be read from the working tree (index↔worktree diffs).
4079/// Use `rename_threshold` `100` to leave entries unchanged.
4080///
4081/// # Errors
4082///
4083/// Propagates object read failures while scoring renames.
4084pub(crate) fn apply_rename_detection_to_diff_entries(
4085    odb: &Odb,
4086    entries: Vec<DiffEntry>,
4087    work_root: Option<&Path>,
4088    rename_threshold: u32,
4089    config: &ConfigSet,
4090    thread_policy: &ThreadPolicy,
4091) -> Result<Vec<DiffEntry>> {
4092    if entries.is_empty() || rename_threshold >= 100 {
4093        return Ok(entries);
4094    }
4095    if work_root.is_some() {
4096        let opts = DiffPostprocessOptions {
4097            rename_threshold,
4098            ..DiffPostprocessOptions::default()
4099        };
4100        postprocess_diff_entries(
4101            odb,
4102            entries,
4103            None,
4104            work_root,
4105            None,
4106            config,
4107            thread_policy,
4108            &opts,
4109            None,
4110        )
4111    } else {
4112        detect_renames_with_policy(
4113            odb,
4114            None,
4115            entries,
4116            rename_threshold,
4117            config,
4118            thread_policy,
4119            None,
4120            DetectRenamesOptions::default(),
4121        )
4122        .map(|r| r.entries)
4123    }
4124}
4125
4126/// Diff the index against the working tree and pair deleted/added paths as renames when similar.
4127///
4128/// Runs `diff_index_to_worktree_with_options`, then `apply_rename_detection_to_diff_entries`
4129/// with `work_root` set to `work_tree`. Untracked worktree paths are not diff entries (see
4130/// [`crate::porcelain::status`]); they are never fed into rename detection here.
4131///
4132/// # Errors
4133///
4134/// Propagates errors from the index↔worktree diff or rename post-processing.
4135pub fn diff_index_to_worktree_with_renames(
4136    repo: &Repository,
4137    index: &mut Index,
4138    work_tree: &Path,
4139    options: DiffIndexToWorktreeOptions,
4140    rename_threshold: u32,
4141) -> Result<(Vec<DiffEntry>, bool)> {
4142    let config = repo.config()?.as_ref().clone();
4143    let thread_policy = repo.thread_policy();
4144    let odb = repo.odb();
4145    let (entries, changed) = diff_index_to_worktree_with_options(odb, index, work_tree, options)?;
4146    let entries = apply_rename_detection_to_diff_entries(
4147        odb,
4148        entries,
4149        Some(work_tree),
4150        rename_threshold,
4151        &config,
4152        &thread_policy,
4153    )?;
4154    Ok((entries, changed))
4155}
4156
4157enum CacheTreeWalk<'a> {
4158    Fast {
4159        head_tree: &'a ObjectId,
4160        cache_root: &'a CacheTreeNode,
4161    },
4162    Flatten,
4163}
4164
4165/// Whether the index may use the cache-tree walker (possibly with an invalid root and valid
4166/// descendant nodes after partial invalidation).
4167fn index_cache_tree_walk<'a>(
4168    index: &'a Index,
4169    tree_oid: Option<&'a ObjectId>,
4170) -> CacheTreeWalk<'a> {
4171    let Some(head_tree) = tree_oid else {
4172        return CacheTreeWalk::Flatten;
4173    };
4174    let Some(cache_root) = index.cache_tree.as_ref() else {
4175        return CacheTreeWalk::Flatten;
4176    };
4177    if index.entries.iter().any(|e| e.stage() != 0) {
4178        return CacheTreeWalk::Flatten;
4179    }
4180    CacheTreeWalk::Fast {
4181        head_tree,
4182        cache_root,
4183    }
4184}
4185
4186/// Flatten-tree index-vs-tree diff (used when the index has no cache-tree extension).
4187fn diff_index_to_tree_flatten(
4188    odb: &Odb,
4189    index: &Index,
4190    tree_oid: Option<&ObjectId>,
4191    ignore_submodules: bool,
4192) -> Result<Vec<DiffEntry>> {
4193    let sparse_index = index.has_sparse_directory_placeholders();
4194    // Flatten the tree into a sorted list of (path, mode, oid)
4195    let tree_entries = match tree_oid {
4196        Some(oid) if sparse_index => flatten_tree_with_index(odb, oid, "", Some(index))?,
4197        Some(oid) => flatten_tree(odb, oid, "")?,
4198        None => Vec::new(),
4199    };
4200
4201    // Build maps keyed by path
4202    let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
4203        std::collections::BTreeMap::new();
4204    for entry in &tree_entries {
4205        tree_map.insert(&entry.path, entry);
4206    }
4207
4208    let mut result = Vec::new();
4209    let mut stage0_paths = std::collections::BTreeSet::new();
4210    let mut unmerged_modes: std::collections::BTreeMap<String, (u8, u32)> =
4211        std::collections::BTreeMap::new();
4212
4213    // Check index entries against tree
4214    for ie in &index.entries {
4215        let path = String::from_utf8_lossy(&ie.path).to_string();
4216        if ie.stage() == 0 && ie.intent_to_add() {
4217            // Intent-to-add entries are not "staged" for diff-index / status
4218            // (matches Git: `git diff --cached` is empty for `-N` paths).
4219            continue;
4220        }
4221        if ie.stage() == 0 && ie.is_sparse_directory_placeholder() {
4222            let dir = path.strip_suffix('/').unwrap_or(path.as_str());
4223            let index_flat = flatten_tree(odb, &ie.oid, dir)?;
4224            let head_flat = match tree_oid {
4225                None => Vec::new(),
4226                Some(head_root) => match subtree_oid_at_path(odb, head_root, dir)? {
4227                    None => Vec::new(),
4228                    Some(head_sub) if head_sub == ie.oid => {
4229                        continue;
4230                    }
4231                    Some(head_sub) => flatten_tree(odb, &head_sub, dir)?,
4232                },
4233            };
4234            diff_flattened_subtrees(
4235                &head_flat,
4236                &index_flat,
4237                ignore_submodules,
4238                &mut result,
4239                &mut stage0_paths,
4240                &mut tree_map,
4241            );
4242            continue;
4243        }
4244        if ie.stage() != 0 {
4245            let rank = match ie.stage() {
4246                2 => 0u8,
4247                3 => 1u8,
4248                1 => 2u8,
4249                _ => 3u8,
4250            };
4251            match unmerged_modes.get(&path) {
4252                Some((existing_rank, _)) if *existing_rank <= rank => {}
4253                _ => {
4254                    unmerged_modes.insert(path, (rank, ie.mode));
4255                }
4256            }
4257            continue;
4258        }
4259        if ignore_submodules && ie.mode == 0o160000 {
4260            let _ = tree_map.remove(path.as_str());
4261            stage0_paths.insert(path.clone());
4262            continue;
4263        }
4264        stage0_paths.insert(path.clone());
4265        match tree_map.remove(path.as_str()) {
4266            Some(te) => {
4267                // Present in both — check for differences
4268                if te.oid != ie.oid
4269                    || crate::index::canon_mode(te.mode) != crate::index::canon_mode(ie.mode)
4270                {
4271                    let tree_mode = crate::index::canon_mode(te.mode);
4272                    let index_mode = crate::index::canon_mode(ie.mode);
4273                    result.push(DiffEntry {
4274                        status: DiffStatus::for_content_change(tree_mode, index_mode),
4275                        old_path: Some(path.clone()),
4276                        new_path: Some(path),
4277                        old_mode: FileMode::new(tree_mode),
4278                        new_mode: FileMode::new(index_mode),
4279                        old_oid: te.oid,
4280                        new_oid: ie.oid,
4281                        score: None,
4282                    });
4283                }
4284            }
4285            None => {
4286                // In index but not tree → added
4287                result.push(DiffEntry {
4288                    status: DiffStatus::Added,
4289                    old_path: None,
4290                    new_path: Some(path),
4291                    old_mode: FileMode::ABSENT,
4292                    new_mode: FileMode::new(ie.mode),
4293                    old_oid: zero_oid(),
4294                    new_oid: ie.oid,
4295                    score: None,
4296                });
4297            }
4298        }
4299    }
4300
4301    diff_index_to_tree_finish_unmerged_and_deleted(
4302        ignore_submodules,
4303        &mut tree_map,
4304        &unmerged_modes,
4305        &stage0_paths,
4306        &mut result,
4307    );
4308
4309    sort_entries_by_path(&mut result);
4310    Ok(result)
4311}
4312
4313fn diff_index_to_tree_finish_unmerged_and_deleted(
4314    ignore_submodules: bool,
4315    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
4316    unmerged_modes: &std::collections::BTreeMap<String, (u8, u32)>,
4317    stage0_paths: &std::collections::BTreeSet<String>,
4318    result: &mut Vec<DiffEntry>,
4319) {
4320    for (path, (_, mode)) in unmerged_modes {
4321        if stage0_paths.contains(path) {
4322            continue;
4323        }
4324        tree_map.remove(path.as_str());
4325        result.push(DiffEntry {
4326            status: DiffStatus::Unmerged,
4327            old_path: Some(path.clone()),
4328            new_path: Some(path.clone()),
4329            old_mode: FileMode::ABSENT,
4330            new_mode: FileMode::new(*mode),
4331            old_oid: zero_oid(),
4332            new_oid: zero_oid(),
4333            score: None,
4334        });
4335    }
4336
4337    // Remaining tree entries not in index → deleted
4338    for (path, te) in std::mem::take(tree_map) {
4339        if ignore_submodules && te.mode == 0o160000 {
4340            continue;
4341        }
4342        result.push(DiffEntry {
4343            status: DiffStatus::Deleted,
4344            old_path: Some(path.to_owned()),
4345            new_path: None,
4346            old_mode: FileMode::new(te.mode),
4347            new_mode: FileMode::ABSENT,
4348            old_oid: te.oid,
4349            new_oid: zero_oid(),
4350            score: None,
4351        });
4352    }
4353}
4354
4355/// Cache-tree-aware index-vs-tree diff: walk HEAD tree and index together and skip
4356/// subtrees whose valid cache-tree node OID matches the tree entry (Git unpack-trees).
4357fn diff_index_to_tree_with_cache_tree(
4358    odb: &Odb,
4359    index: &Index,
4360    head_tree: &ObjectId,
4361    cache_root: &CacheTreeNode,
4362    ignore_submodules: bool,
4363) -> Result<Vec<DiffEntry>> {
4364    let mut result = Vec::new();
4365    let mut idx_cursor = 0usize;
4366
4367    let mut walk = DiffIndexTreeWalk {
4368        odb,
4369        index,
4370        idx_cursor: &mut idx_cursor,
4371        ignore_submodules,
4372        result: &mut result,
4373        abort_to_flatten: false,
4374    };
4375    diff_index_tree_at(&mut walk, head_tree, Some(cache_root), b"")?;
4376    if walk.abort_to_flatten {
4377        return diff_index_to_tree_flatten(odb, index, Some(head_tree), ignore_submodules);
4378    }
4379
4380    // Index entries not consumed by the tree walk (added paths).
4381    while idx_cursor < index.entries.len() {
4382        let ie = &index.entries[idx_cursor];
4383        idx_cursor += 1;
4384        let path = String::from_utf8_lossy(&ie.path).to_string();
4385        if ie.stage() == 0 && ie.intent_to_add() {
4386            continue;
4387        }
4388        if ie.stage() != 0 {
4389            continue;
4390        }
4391        if ignore_submodules && ie.mode == 0o160000 {
4392            continue;
4393        }
4394        result.push(DiffEntry {
4395            status: DiffStatus::Added,
4396            old_path: None,
4397            new_path: Some(path),
4398            old_mode: FileMode::ABSENT,
4399            new_mode: FileMode::new(ie.mode),
4400            old_oid: zero_oid(),
4401            new_oid: ie.oid,
4402            score: None,
4403        });
4404    }
4405
4406    sort_entries_by_path(&mut result);
4407    Ok(result)
4408}
4409
4410enum IndexLevelKind<'a> {
4411    Blob {
4412        entry: &'a IndexEntry,
4413        name: &'a [u8],
4414    },
4415    Tree {
4416        name: &'a [u8],
4417    },
4418}
4419
4420/// `path` relative to the directory `dir_prefix` (raw bytes, no trailing slash), or `None` when
4421/// `path` lies outside it. The directory's own path maps to an empty slice.
4422fn index_relative_in_dir<'a>(path: &'a [u8], dir_prefix: &[u8]) -> Option<&'a [u8]> {
4423    if dir_prefix.is_empty() {
4424        return Some(path);
4425    }
4426    let rest = path.strip_prefix(dir_prefix)?;
4427    match rest.split_first() {
4428        None => Some(rest),
4429        Some((b'/', tail)) => Some(tail),
4430        Some(_) => None,
4431    }
4432}
4433
4434fn join_index_path(dir_prefix: &[u8], name: &[u8]) -> Vec<u8> {
4435    if dir_prefix.is_empty() {
4436        return name.to_vec();
4437    }
4438    let mut path = Vec::with_capacity(dir_prefix.len() + 1 + name.len());
4439    path.extend_from_slice(dir_prefix);
4440    path.push(b'/');
4441    path.extend_from_slice(name);
4442    path
4443}
4444
4445fn peek_index_level_entry<'a>(
4446    index: &'a Index,
4447    cursor: &mut usize,
4448    dir_prefix: &[u8],
4449    ignore_submodules: bool,
4450) -> Option<IndexLevelKind<'a>> {
4451    while let Some(ie) = index.entries.get(*cursor) {
4452        let rel = index_relative_in_dir(&ie.path, dir_prefix)?;
4453        if ie.stage() != 0 || ie.intent_to_add() || rel.is_empty() {
4454            *cursor += 1;
4455            continue;
4456        }
4457        if let Some(slash) = rel.iter().position(|&b| b == b'/') {
4458            return Some(IndexLevelKind::Tree {
4459                name: &rel[..slash],
4460            });
4461        }
4462        if ignore_submodules && ie.mode == 0o160000 {
4463            *cursor += 1;
4464            continue;
4465        }
4466        return Some(IndexLevelKind::Blob {
4467            entry: ie,
4468            name: rel,
4469        });
4470    }
4471    None
4472}
4473
4474fn skip_index_subtree(index: &Index, cursor: &mut usize, dir_prefix: &[u8]) {
4475    while index
4476        .entries
4477        .get(*cursor)
4478        .is_some_and(|ie| index_relative_in_dir(&ie.path, dir_prefix).is_some())
4479    {
4480        *cursor += 1;
4481    }
4482}
4483
4484/// Children of a cache-tree node up to which a linear name scan beats sorting.
4485const CACHE_CHILD_LINEAR_SCAN_MAX: usize = 16;
4486
4487/// Name lookup over one cache-tree node's subtrees.
4488///
4489/// The index extension does not promise any child order, and a wide directory is looked up
4490/// once per tree entry, so nodes with many children are sorted once per visit instead of
4491/// being scanned for every entry. The first child with a matching name wins either way.
4492struct CacheChildren<'a> {
4493    children: &'a [CacheTreeNode],
4494    sorted: Vec<&'a CacheTreeNode>,
4495}
4496
4497impl<'a> CacheChildren<'a> {
4498    fn new(node: Option<&'a CacheTreeNode>) -> Self {
4499        let children = node.map_or(&[][..], |n| n.children.as_slice());
4500        let sorted = if children.len() > CACHE_CHILD_LINEAR_SCAN_MAX {
4501            let mut sorted: Vec<&CacheTreeNode> = children.iter().collect();
4502            sorted.sort_by(|a, b| a.name.cmp(&b.name));
4503            sorted
4504        } else {
4505            Vec::new()
4506        };
4507        Self { children, sorted }
4508    }
4509
4510    fn get(&self, name: &[u8]) -> Option<&'a CacheTreeNode> {
4511        if self.sorted.is_empty() {
4512            return self.children.iter().find(|c| c.name == name);
4513        }
4514        let pos = self.sorted.partition_point(|c| c.name.as_slice() < name);
4515        self.sorted.get(pos).copied().filter(|c| c.name == name)
4516    }
4517}
4518
4519struct DiffIndexTreeWalk<'a, 'b> {
4520    odb: &'a Odb,
4521    index: &'a Index,
4522    idx_cursor: &'b mut usize,
4523    ignore_submodules: bool,
4524    result: &'b mut Vec<DiffEntry>,
4525    /// Set when a tree object has duplicate entry names; the caller falls back to flatten.
4526    abort_to_flatten: bool,
4527}
4528
4529fn tree_entries_have_duplicate_names(entries: &[TreeEntry]) -> bool {
4530    let mut seen = std::collections::BTreeSet::<&[u8]>::new();
4531    for entry in entries {
4532        if !seen.insert(entry.name.as_slice()) {
4533            return true;
4534        }
4535    }
4536    false
4537}
4538
4539/// Walk the tree `tree_oid` against the index entries under `dir_prefix` (raw path bytes).
4540fn diff_index_tree_at(
4541    walk: &mut DiffIndexTreeWalk<'_, '_>,
4542    tree_oid: &ObjectId,
4543    cache_node: Option<&CacheTreeNode>,
4544    dir_prefix: &[u8],
4545) -> Result<()> {
4546    if !dir_prefix.is_empty() {
4547        let mut path = dir_prefix.to_vec();
4548        path.push(b'/');
4549        if let Some(entry) = walk
4550            .index
4551            .entries
4552            .get(*walk.idx_cursor)
4553            .filter(|entry| entry.path == path && entry.is_sparse_directory_placeholder())
4554        {
4555            if entry.oid == *tree_oid {
4556                *walk.idx_cursor += 1;
4557                return Ok(());
4558            }
4559            let dir = String::from_utf8_lossy(dir_prefix);
4560            let head_flat = flatten_tree(walk.odb, tree_oid, dir.as_ref())?;
4561            let index_flat = flatten_tree(walk.odb, &entry.oid, dir.as_ref())?;
4562            let mut stage0_paths = std::collections::BTreeSet::new();
4563            let mut tree_map = std::collections::BTreeMap::new();
4564            diff_flattened_subtrees(
4565                &head_flat,
4566                &index_flat,
4567                walk.ignore_submodules,
4568                walk.result,
4569                &mut stage0_paths,
4570                &mut tree_map,
4571            );
4572            *walk.idx_cursor += 1;
4573            return Ok(());
4574        }
4575    }
4576    let tree_entries = read_tree(walk.odb, tree_oid)?;
4577    if tree_entries_have_duplicate_names(&tree_entries) {
4578        walk.abort_to_flatten = true;
4579        return Ok(());
4580    }
4581    let cache_children = CacheChildren::new(cache_node);
4582    let mut ti = 0usize;
4583
4584    loop {
4585        let te = tree_entries.get(ti);
4586        let ie_kind = peek_index_level_entry(
4587            walk.index,
4588            walk.idx_cursor,
4589            dir_prefix,
4590            walk.ignore_submodules,
4591        );
4592
4593        match (te, ie_kind) {
4594            (Some(t_entry), Some(ie)) => {
4595                let t_name = &t_entry.name;
4596                let (i_name, i_is_tree) = match &ie {
4597                    IndexLevelKind::Blob { name, .. } => (*name, false),
4598                    IndexLevelKind::Tree { name } => (*name, true),
4599                };
4600                let t_is_tree = is_tree_mode(t_entry.mode);
4601                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4602                match cmp {
4603                    std::cmp::Ordering::Less => {
4604                        emit_index_tree_deleted(
4605                            walk.odb,
4606                            t_entry,
4607                            dir_prefix,
4608                            walk.ignore_submodules,
4609                            walk.result,
4610                        )?;
4611                        ti += 1;
4612                    }
4613                    std::cmp::Ordering::Greater => {
4614                        emit_index_tree_added(walk, dir_prefix)?;
4615                    }
4616                    std::cmp::Ordering::Equal => {
4617                        if t_is_tree && i_is_tree {
4618                            let full_path = join_index_path(dir_prefix, t_name);
4619                            if diff_index_tree_skip_sparse_placeholder(walk, &full_path, t_entry)? {
4620                                ti += 1;
4621                                continue;
4622                            }
4623                            let child_cache = cache_children.get(t_name);
4624                            if child_cache
4625                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
4626                            {
4627                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4628                                ti += 1;
4629                                continue;
4630                            }
4631                            diff_index_tree_at(walk, &t_entry.oid, child_cache, &full_path)?;
4632                        } else if t_is_tree != i_is_tree {
4633                            emit_index_tree_deleted(
4634                                walk.odb,
4635                                t_entry,
4636                                dir_prefix,
4637                                walk.ignore_submodules,
4638                                walk.result,
4639                            )?;
4640                            emit_index_tree_added(walk, dir_prefix)?;
4641                        } else {
4642                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
4643                                ti += 1;
4644                                continue;
4645                            };
4646                            *walk.idx_cursor += 1;
4647                            if t_entry.oid != ie.oid
4648                                || crate::index::canon_mode(t_entry.mode)
4649                                    != crate::index::canon_mode(ie.mode)
4650                            {
4651                                let path = String::from_utf8_lossy(&ie.path).into_owned();
4652                                let tree_mode = crate::index::canon_mode(t_entry.mode);
4653                                let index_mode = crate::index::canon_mode(ie.mode);
4654                                walk.result.push(DiffEntry {
4655                                    status: DiffStatus::for_content_change(tree_mode, index_mode),
4656                                    old_path: Some(path.clone()),
4657                                    new_path: Some(path),
4658                                    old_mode: FileMode::new(tree_mode),
4659                                    new_mode: FileMode::new(index_mode),
4660                                    old_oid: t_entry.oid,
4661                                    new_oid: ie.oid,
4662                                    score: None,
4663                                });
4664                            }
4665                        }
4666                        ti += 1;
4667                    }
4668                }
4669            }
4670            (Some(t_entry), None) => {
4671                emit_index_tree_deleted(
4672                    walk.odb,
4673                    t_entry,
4674                    dir_prefix,
4675                    walk.ignore_submodules,
4676                    walk.result,
4677                )?;
4678                ti += 1;
4679            }
4680            (None, Some(_)) => {
4681                emit_index_tree_added(walk, dir_prefix)?;
4682            }
4683            (None, None) => break,
4684        }
4685    }
4686
4687    Ok(())
4688}
4689
4690fn diff_index_tree_skip_sparse_placeholder(
4691    walk: &mut DiffIndexTreeWalk<'_, '_>,
4692    full_path: &[u8],
4693    t_entry: &TreeEntry,
4694) -> Result<bool> {
4695    let Some(entry) = walk.index.entries.get(*walk.idx_cursor) else {
4696        return Ok(false);
4697    };
4698    if !entry.is_sparse_directory_placeholder() {
4699        return Ok(false);
4700    }
4701    let mut dir = full_path.to_vec();
4702    if !dir.ends_with(b"/") {
4703        dir.push(b'/');
4704    }
4705    if entry.path != dir || entry.oid != t_entry.oid {
4706        return Ok(false);
4707    }
4708    *walk.idx_cursor += 1;
4709    Ok(true)
4710}
4711
4712fn emit_index_tree_deleted(
4713    odb: &Odb,
4714    entry: &TreeEntry,
4715    dir_prefix: &[u8],
4716    ignore_submodules: bool,
4717    result: &mut Vec<DiffEntry>,
4718) -> Result<()> {
4719    if ignore_submodules && entry.mode == 0o160000 {
4720        return Ok(());
4721    }
4722    let path = String::from_utf8_lossy(&join_index_path(dir_prefix, &entry.name)).into_owned();
4723    if is_tree_mode(entry.mode) {
4724        for fe in flatten_tree(odb, &entry.oid, &path)? {
4725            if ignore_submodules && fe.mode == 0o160000 {
4726                continue;
4727            }
4728            result.push(DiffEntry {
4729                status: DiffStatus::Deleted,
4730                old_path: Some(fe.path.clone()),
4731                new_path: None,
4732                old_mode: FileMode::new(fe.mode),
4733                new_mode: FileMode::ABSENT,
4734                old_oid: fe.oid,
4735                new_oid: zero_oid(),
4736                score: None,
4737            });
4738        }
4739    } else {
4740        result.push(DiffEntry {
4741            status: DiffStatus::Deleted,
4742            old_path: Some(path),
4743            new_path: None,
4744            old_mode: FileMode::new(entry.mode),
4745            new_mode: FileMode::ABSENT,
4746            old_oid: entry.oid,
4747            new_oid: zero_oid(),
4748            score: None,
4749        });
4750    }
4751    Ok(())
4752}
4753
4754fn emit_index_tree_added(walk: &mut DiffIndexTreeWalk<'_, '_>, dir_prefix: &[u8]) -> Result<()> {
4755    let ie_kind = peek_index_level_entry(
4756        walk.index,
4757        walk.idx_cursor,
4758        dir_prefix,
4759        walk.ignore_submodules,
4760    )
4761    .ok_or_else(|| Error::IndexError("missing index entry during tree walk".to_owned()))?;
4762    match ie_kind {
4763        IndexLevelKind::Blob { entry: ie, .. } => {
4764            let path = String::from_utf8_lossy(&ie.path).into_owned();
4765            *walk.idx_cursor += 1;
4766            walk.result.push(DiffEntry {
4767                status: DiffStatus::Added,
4768                old_path: None,
4769                new_path: Some(path),
4770                old_mode: FileMode::ABSENT,
4771                new_mode: FileMode::new(ie.mode),
4772                old_oid: zero_oid(),
4773                new_oid: ie.oid,
4774                score: None,
4775            });
4776        }
4777        IndexLevelKind::Tree { name } => {
4778            let full_path = join_index_path(dir_prefix, name);
4779            let mut path_slash = full_path.clone();
4780            path_slash.push(b'/');
4781            if let Some(entry) =
4782                walk.index.entries.get(*walk.idx_cursor).filter(|entry| {
4783                    entry.path == path_slash && entry.is_sparse_directory_placeholder()
4784                })
4785            {
4786                let dir = String::from_utf8_lossy(if dir_prefix.is_empty() {
4787                    name
4788                } else {
4789                    &full_path
4790                });
4791                emit_flattened_subtree_as_added(
4792                    walk.odb,
4793                    &entry.oid,
4794                    dir.as_ref(),
4795                    walk.ignore_submodules,
4796                    walk.result,
4797                )?;
4798                *walk.idx_cursor += 1;
4799                return Ok(());
4800            }
4801            let start = *walk.idx_cursor;
4802            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4803            for ie in &walk.index.entries[start..*walk.idx_cursor] {
4804                if ie.stage() != 0 || ie.intent_to_add() {
4805                    continue;
4806                }
4807                if walk.ignore_submodules && ie.mode == 0o160000 {
4808                    continue;
4809                }
4810                let path = String::from_utf8_lossy(&ie.path).into_owned();
4811                walk.result.push(DiffEntry {
4812                    status: DiffStatus::Added,
4813                    old_path: None,
4814                    new_path: Some(path),
4815                    old_mode: FileMode::ABSENT,
4816                    new_mode: FileMode::new(ie.mode),
4817                    old_oid: zero_oid(),
4818                    new_oid: ie.oid,
4819                    score: None,
4820                });
4821            }
4822        }
4823    }
4824    Ok(())
4825}
4826
4827/// Paths and rows to reconcile an index with a target tree during hard reset.
4828#[derive(Default)]
4829pub(crate) struct HardResetIndexPlan {
4830    pub remove_paths: Vec<Vec<u8>>,
4831    pub updates: Vec<(Vec<u8>, u32, ObjectId)>,
4832    pub insert_entries: Vec<IndexEntry>,
4833}
4834
4835/// Plan index changes to match `target_tree` using a cache-tree-aware lockstep walk (raw paths).
4836///
4837/// Never builds a flattened tree map; when the cache-tree extension is absent, walks the full tree
4838/// against the index instead of calling [`diff_index_to_tree_flatten`].
4839pub(crate) fn plan_hard_reset_index_align(
4840    odb: &Odb,
4841    index: &Index,
4842    target_tree: &ObjectId,
4843    sparse: Option<&SparseCheckout>,
4844    index_sparse: bool,
4845) -> Result<HardResetIndexPlan> {
4846    let mut plan = HardResetIndexPlan::default();
4847    let mut idx_cursor = 0usize;
4848    let cache_root = index.cache_tree.as_ref();
4849    let use_cache = cache_root.is_some()
4850        && index
4851            .entries
4852            .iter()
4853            .all(|entry| entry.stage() == 0 && !entry.intent_to_add());
4854    let cache_node = if use_cache { cache_root } else { None };
4855    let retry = {
4856        let mut walk = HardResetAlignWalk {
4857            odb,
4858            index,
4859            idx_cursor: &mut idx_cursor,
4860            ignore_submodules: false,
4861            sparse,
4862            index_sparse,
4863            plan: &mut plan,
4864            retry_without_cache: false,
4865        };
4866        hard_reset_align_index_tree_at(&mut walk, target_tree, cache_node, b"")?;
4867        walk.retry_without_cache
4868    };
4869    if retry {
4870        idx_cursor = 0;
4871        plan = HardResetIndexPlan::default();
4872        let mut walk = HardResetAlignWalk {
4873            odb,
4874            index,
4875            idx_cursor: &mut idx_cursor,
4876            ignore_submodules: false,
4877            sparse,
4878            index_sparse,
4879            plan: &mut plan,
4880            retry_without_cache: false,
4881        };
4882        hard_reset_align_index_tree_at(&mut walk, target_tree, None, b"")?;
4883    }
4884    while idx_cursor < index.entries.len() {
4885        let ie = &index.entries[idx_cursor];
4886        idx_cursor += 1;
4887        if ie.stage() != 0 || ie.intent_to_add() {
4888            continue;
4889        }
4890        plan.remove_paths.push(ie.path.clone());
4891    }
4892    Ok(plan)
4893}
4894
4895/// Apply a [`HardResetIndexPlan`] and invalidate cache-tree nodes for touched paths.
4896pub(crate) fn apply_hard_reset_index_plan(index: &mut Index, plan: HardResetIndexPlan) {
4897    let change_count = plan
4898        .remove_paths
4899        .len()
4900        .saturating_add(plan.updates.len())
4901        .saturating_add(plan.insert_entries.len());
4902    const BULK_HARD_RESET_INDEX_MIN: usize = 64;
4903    let bulk = change_count >= BULK_HARD_RESET_INDEX_MIN;
4904    if bulk {
4905        index.drop_derived_index_extensions();
4906    }
4907    for (path, mode, oid) in plan.updates {
4908        if let Some(pos) = index.find_entry_pos(&path, 0) {
4909            let entry = &mut index.entries[pos];
4910            let content_unchanged = entry.oid == oid && entry.mode == mode;
4911            entry.mode = mode;
4912            entry.oid = oid;
4913            if !content_unchanged {
4914                entry.size = 0;
4915            }
4916            if !bulk {
4917                index.invalidate_cache_tree_for_path(&path);
4918            }
4919        }
4920    }
4921    index.defer_untracked_cache_invalidation = bulk;
4922    index.remove_prefixes_paths_and_insert(
4923        &std::collections::BTreeSet::new(),
4924        plan.remove_paths.iter().map(|p| p.as_slice()),
4925        plan.insert_entries,
4926    );
4927    index.defer_untracked_cache_invalidation = false;
4928    if !bulk {
4929        for path in &plan.remove_paths {
4930            index.invalidate_cache_tree_for_path(path);
4931        }
4932    }
4933    index.sort();
4934}
4935
4936/// Rebuild the cache-tree extension after hard-reset index alignment, reusing valid seed
4937/// subtrees from the lockstep tree/index walk.
4938pub(crate) fn rebuild_hard_reset_cache_tree(
4939    odb: &Odb,
4940    index: &mut Index,
4941    target_tree: &ObjectId,
4942) -> Result<()> {
4943    let stage0_count = index
4944        .entries
4945        .iter()
4946        .filter(|entry| entry.stage() == 0 && !entry.intent_to_add())
4947        .count();
4948    let seed_root = index.cache_tree.as_ref();
4949    let mut idx_cursor = 0usize;
4950    let root = rebuild_hard_reset_cache_tree_at(
4951        odb,
4952        index,
4953        &mut idx_cursor,
4954        target_tree,
4955        seed_root,
4956        b"",
4957        Vec::new(),
4958    )?;
4959    if usize::try_from(root.entry_count).ok() == Some(stage0_count) {
4960        index.set_cache_tree(root);
4961        Ok(())
4962    } else {
4963        Err(Error::IndexError(
4964            "hard reset cache-tree entry count does not match index".into(),
4965        ))
4966    }
4967}
4968
4969fn rebuild_hard_reset_cache_tree_at(
4970    odb: &Odb,
4971    index: &Index,
4972    idx_cursor: &mut usize,
4973    tree_oid: &ObjectId,
4974    seed_node: Option<&CacheTreeNode>,
4975    dir_prefix: &[u8],
4976    name: Vec<u8>,
4977) -> Result<CacheTreeNode> {
4978    if !dir_prefix.is_empty() {
4979        let mut path = dir_prefix.to_vec();
4980        path.push(b'/');
4981        if let Some(entry) = index.entries.get(*idx_cursor).filter(|entry| {
4982            entry.path == path && entry.is_sparse_directory_placeholder() && entry.oid == *tree_oid
4983        }) {
4984            let _ = entry;
4985            *idx_cursor += 1;
4986            return Ok(CacheTreeNode::valid(name, 1, *tree_oid, Vec::new()));
4987        }
4988    }
4989    let tree_entries = read_tree(odb, tree_oid)?;
4990    let cache_children = CacheChildren::new(seed_node);
4991    let mut entry_count: i32 = 0;
4992    let mut children: Vec<CacheTreeNode> = Vec::new();
4993    let mut ti = 0usize;
4994
4995    loop {
4996        let te = tree_entries.get(ti);
4997        let ie_kind = peek_index_level_entry(index, idx_cursor, dir_prefix, false);
4998        match (te, ie_kind) {
4999            (Some(t_entry), Some(ie)) => {
5000                let t_name = &t_entry.name;
5001                let (i_name, i_is_tree) = match &ie {
5002                    IndexLevelKind::Blob { name, .. } => (*name, false),
5003                    IndexLevelKind::Tree { name } => (*name, true),
5004                };
5005                let t_is_tree = is_tree_mode(t_entry.mode);
5006                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
5007                if cmp != std::cmp::Ordering::Equal {
5008                    return Err(Error::IndexError(
5009                        "hard reset cache-tree rebuild: index out of sync with target tree".into(),
5010                    ));
5011                }
5012                if t_is_tree && i_is_tree {
5013                    let full_path = join_index_path(dir_prefix, t_name);
5014                    if hard_reset_skip_sparse_cache_rebuild(index, idx_cursor, &full_path, t_entry)?
5015                    {
5016                        entry_count = entry_count.saturating_add(1);
5017                        ti += 1;
5018                        continue;
5019                    }
5020                    let child = if let Some(child_cache) = cache_children
5021                        .get(t_name)
5022                        .filter(|node| node.is_valid() && node.oid == Some(t_entry.oid))
5023                    {
5024                        skip_index_subtree(index, idx_cursor, &full_path);
5025                        let mut reused = (*child_cache).clone();
5026                        reused.name = t_name.clone();
5027                        reused
5028                    } else {
5029                        rebuild_hard_reset_cache_tree_at(
5030                            odb,
5031                            index,
5032                            idx_cursor,
5033                            &t_entry.oid,
5034                            cache_children.get(t_name),
5035                            &full_path,
5036                            t_name.clone(),
5037                        )?
5038                    };
5039                    entry_count = entry_count.saturating_add(child.entry_count.max(0));
5040                    children.push(child);
5041                } else if t_is_tree != i_is_tree {
5042                    return Err(Error::IndexError(
5043                        "hard reset cache-tree rebuild: tree/index type mismatch".into(),
5044                    ));
5045                } else {
5046                    *idx_cursor += 1;
5047                    entry_count = entry_count.saturating_add(1);
5048                }
5049                ti += 1;
5050            }
5051            (None, None) => break,
5052            _ => {
5053                return Err(Error::IndexError(
5054                    "hard reset cache-tree rebuild: index out of sync with target tree".into(),
5055                ));
5056            }
5057        }
5058    }
5059    children.sort_by(|a, b| a.name.cmp(&b.name));
5060    Ok(CacheTreeNode::valid(name, entry_count, *tree_oid, children))
5061}
5062
5063fn hard_reset_skip_sparse_cache_rebuild(
5064    index: &Index,
5065    idx_cursor: &mut usize,
5066    full_path: &[u8],
5067    t_entry: &TreeEntry,
5068) -> Result<bool> {
5069    let Some(entry) = index.entries.get(*idx_cursor) else {
5070        return Ok(false);
5071    };
5072    if !entry.is_sparse_directory_placeholder() {
5073        return Ok(false);
5074    }
5075    let mut dir = full_path.to_vec();
5076    if !dir.ends_with(b"/") {
5077        dir.push(b'/');
5078    }
5079    if entry.path != dir || entry.oid != t_entry.oid {
5080        return Ok(false);
5081    }
5082    *idx_cursor += 1;
5083    Ok(true)
5084}
5085
5086struct HardResetAlignWalk<'a, 'b> {
5087    odb: &'a Odb,
5088    index: &'a Index,
5089    idx_cursor: &'b mut usize,
5090    ignore_submodules: bool,
5091    sparse: Option<&'a SparseCheckout>,
5092    index_sparse: bool,
5093    plan: &'b mut HardResetIndexPlan,
5094    retry_without_cache: bool,
5095}
5096
5097fn hard_reset_align_index_tree_at(
5098    walk: &mut HardResetAlignWalk<'_, '_>,
5099    tree_oid: &ObjectId,
5100    cache_node: Option<&CacheTreeNode>,
5101    dir_prefix: &[u8],
5102) -> Result<()> {
5103    let tree_entries = read_tree(walk.odb, tree_oid)?;
5104    if tree_entries_have_duplicate_names(&tree_entries) {
5105        walk.retry_without_cache = true;
5106        return Ok(());
5107    }
5108    let cache_children = CacheChildren::new(cache_node);
5109    let mut ti = 0usize;
5110
5111    loop {
5112        let te = tree_entries.get(ti);
5113        let ie_kind = peek_index_level_entry(
5114            walk.index,
5115            walk.idx_cursor,
5116            dir_prefix,
5117            walk.ignore_submodules,
5118        );
5119
5120        match (te, ie_kind) {
5121            (Some(t_entry), Some(ie)) => {
5122                let t_name = &t_entry.name;
5123                let (i_name, i_is_tree) = match &ie {
5124                    IndexLevelKind::Blob { name, .. } => (*name, false),
5125                    IndexLevelKind::Tree { name } => (*name, true),
5126                };
5127                let t_is_tree = is_tree_mode(t_entry.mode);
5128                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
5129                match cmp {
5130                    std::cmp::Ordering::Less => {
5131                        hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
5132                        ti += 1;
5133                    }
5134                    std::cmp::Ordering::Greater => {
5135                        hard_reset_plan_index_only(walk, dir_prefix)?;
5136                    }
5137                    std::cmp::Ordering::Equal => {
5138                        if t_is_tree && i_is_tree {
5139                            let full_path = join_index_path(dir_prefix, t_name);
5140                            if hard_reset_skip_sparse_placeholder(walk, &full_path, t_entry)? {
5141                                ti += 1;
5142                                continue;
5143                            }
5144                            let child_cache = cache_children.get(t_name);
5145                            if child_cache
5146                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
5147                            {
5148                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
5149                                ti += 1;
5150                                continue;
5151                            }
5152                            hard_reset_align_index_tree_at(
5153                                walk,
5154                                &t_entry.oid,
5155                                child_cache,
5156                                &full_path,
5157                            )?;
5158                        } else if t_is_tree != i_is_tree {
5159                            hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
5160                            hard_reset_plan_index_only(walk, dir_prefix)?;
5161                        } else {
5162                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
5163                                ti += 1;
5164                                continue;
5165                            };
5166                            *walk.idx_cursor += 1;
5167                            if t_entry.oid != ie.oid
5168                                || crate::index::canon_mode(t_entry.mode)
5169                                    != crate::index::canon_mode(ie.mode)
5170                            {
5171                                walk.plan.updates.push((
5172                                    ie.path.clone(),
5173                                    crate::index::canon_mode(t_entry.mode),
5174                                    t_entry.oid,
5175                                ));
5176                            }
5177                        }
5178                        ti += 1;
5179                    }
5180                }
5181            }
5182            (Some(t_entry), None) => {
5183                hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
5184                ti += 1;
5185            }
5186            (None, Some(_)) => {
5187                hard_reset_plan_index_only(walk, dir_prefix)?;
5188            }
5189            (None, None) => break,
5190        }
5191    }
5192    Ok(())
5193}
5194
5195fn hard_reset_skip_sparse_placeholder(
5196    walk: &mut HardResetAlignWalk<'_, '_>,
5197    full_path: &[u8],
5198    t_entry: &TreeEntry,
5199) -> Result<bool> {
5200    let Some(ie) = walk.index.entries.get(*walk.idx_cursor) else {
5201        return Ok(false);
5202    };
5203    if !ie.is_sparse_directory_placeholder() {
5204        return Ok(false);
5205    }
5206    let mut dir = full_path.to_vec();
5207    if !dir.ends_with(b"/") {
5208        dir.push(b'/');
5209    }
5210    if ie.path != dir || ie.oid != t_entry.oid {
5211        return Ok(false);
5212    }
5213    *walk.idx_cursor += 1;
5214    Ok(true)
5215}
5216
5217fn hard_reset_plan_index_only(
5218    walk: &mut HardResetAlignWalk<'_, '_>,
5219    dir_prefix: &[u8],
5220) -> Result<()> {
5221    let ie_kind = peek_index_level_entry(
5222        walk.index,
5223        walk.idx_cursor,
5224        dir_prefix,
5225        walk.ignore_submodules,
5226    )
5227    .ok_or_else(|| Error::IndexError("missing index entry during hard reset align".into()))?;
5228    match ie_kind {
5229        IndexLevelKind::Blob { entry: ie, .. } => {
5230            walk.plan.remove_paths.push(ie.path.clone());
5231            *walk.idx_cursor += 1;
5232        }
5233        IndexLevelKind::Tree { name } => {
5234            let full_path = join_index_path(dir_prefix, name);
5235            let start = *walk.idx_cursor;
5236            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
5237            for ie in &walk.index.entries[start..*walk.idx_cursor] {
5238                if ie.stage() != 0 || ie.intent_to_add() {
5239                    continue;
5240                }
5241                walk.plan.remove_paths.push(ie.path.clone());
5242            }
5243        }
5244    }
5245    Ok(())
5246}
5247
5248fn hard_reset_plan_tree_only(
5249    walk: &mut HardResetAlignWalk<'_, '_>,
5250    entry: &TreeEntry,
5251    dir_prefix: &[u8],
5252) -> Result<()> {
5253    if walk.ignore_submodules && entry.mode == 0o160000 {
5254        return Ok(());
5255    }
5256    let path = join_index_path(dir_prefix, &entry.name);
5257    if is_tree_mode(entry.mode) {
5258        if hard_reset_sparse_excludes_tree(walk, &path) {
5259            walk.plan
5260                .insert_entries
5261                .push(hard_reset_sparse_directory_row(path, entry.oid));
5262        } else {
5263            hard_reset_append_entries_from_tree(
5264                walk.odb,
5265                &entry.oid,
5266                &path,
5267                &mut walk.plan.insert_entries,
5268            )?;
5269        }
5270    } else {
5271        walk.plan
5272            .insert_entries
5273            .push(hard_reset_index_tree_row(path, entry.mode, entry.oid));
5274    }
5275    Ok(())
5276}
5277
5278fn hard_reset_sparse_excludes_tree(walk: &HardResetAlignWalk<'_, '_>, path: &[u8]) -> bool {
5279    if !walk.index_sparse {
5280        return false;
5281    }
5282    let Some(sparse) = walk.sparse else {
5283        return false;
5284    };
5285    let mut dir = path.to_vec();
5286    if !dir.ends_with(b"/") {
5287        dir.push(b'/');
5288    }
5289    sparse.excludes_directory(&dir)
5290}
5291
5292fn hard_reset_sparse_directory_row(mut path: Vec<u8>, oid: ObjectId) -> IndexEntry {
5293    if !path.ends_with(b"/") {
5294        path.push(b'/');
5295    }
5296    let mut row = hard_reset_index_tree_row(path, MODE_TREE, oid);
5297    row.set_skip_worktree(true);
5298    row
5299}
5300
5301fn hard_reset_index_tree_row(path: Vec<u8>, mode: u32, oid: ObjectId) -> IndexEntry {
5302    IndexEntry {
5303        ctime_sec: 0,
5304        ctime_nsec: 0,
5305        mtime_sec: 0,
5306        mtime_nsec: 0,
5307        dev: 0,
5308        ino: 0,
5309        mode: crate::index::canon_mode(mode),
5310        uid: 0,
5311        gid: 0,
5312        size: 0,
5313        oid,
5314        flags: path.len().min(0xFFF) as u16,
5315        flags_extended: None,
5316        path,
5317        base_index_pos: 0,
5318    }
5319}
5320
5321fn hard_reset_append_entries_from_tree(
5322    odb: &Odb,
5323    tree_oid: &ObjectId,
5324    prefix: &[u8],
5325    out: &mut Vec<IndexEntry>,
5326) -> Result<()> {
5327    for te in read_tree(odb, tree_oid)? {
5328        let mut path = prefix.to_vec();
5329        if !path.is_empty() {
5330            path.push(b'/');
5331        }
5332        path.extend_from_slice(&te.name);
5333        if is_tree_mode(te.mode) {
5334            hard_reset_append_entries_from_tree(odb, &te.oid, &path, out)?;
5335        } else {
5336            out.push(hard_reset_index_tree_row(path, te.mode, te.oid));
5337        }
5338    }
5339    Ok(())
5340}
5341
5342// ── Index-to-worktree diff (unstaged changes) ───────────────────────
5343
5344/// Compare the index against the working tree.
5345///
5346/// This shows "unstaged" changes — modifications not yet staged.
5347///
5348/// Entries with [`IndexEntry::assume_unchanged`] or [`IndexEntry::skip_worktree`] are treated as
5349/// matching the work tree without examining the filesystem (Git `CE_VALID` / skip-worktree).
5350///
5351/// # Parameters
5352///
5353/// - `odb` — object database (for hashing worktree files).
5354/// - `index` — the current index.
5355/// - `work_tree` — path to the working tree root.
5356/// - `ignore_submodule_untracked` — when true, gitlink entries are not dirty solely from untracked
5357///   files inside the submodule (matches `git status -uno`).
5358/// - `simplify_gitlinks` — when true, nested gitlink entries only compare the submodule checkout
5359///   HEAD to the recorded OID (ignore dirty work trees inside nested submodules). Used when
5360///   computing `submodule_porcelain_flags` so untracked files under a nested submodule do not set
5361///   the parent submodule's `modified` bit (Git `DIRTY_SUBMODULE_MODIFIED`; t7506).
5362///
5363/// # Errors
5364///
5365/// Returns errors from I/O or hashing.
5366pub fn diff_index_to_worktree(
5367    odb: &Odb,
5368    index: &mut Index,
5369    work_tree: &Path,
5370    ignore_submodule_untracked: bool,
5371    simplify_gitlinks: bool,
5372) -> Result<Vec<DiffEntry>> {
5373    diff_index_to_worktree_with_options(
5374        odb,
5375        index,
5376        work_tree,
5377        DiffIndexToWorktreeOptions {
5378            ignore_submodule_untracked,
5379            simplify_gitlinks,
5380            ..DiffIndexToWorktreeOptions::default()
5381        },
5382    )
5383    .map(|(entries, _)| entries)
5384}
5385
5386/// Predicate over stored index path bytes for [`DiffIndexToWorktreeOptions::skip_index_path`].
5387pub(crate) type IndexPathSkipPredicate = std::sync::Arc<dyn Fn(&[u8]) -> bool + Send + Sync>;
5388
5389/// Additional inputs for `diff_index_to_worktree_with_options`.
5390#[derive(Clone)]
5391pub struct DiffIndexToWorktreeOptions {
5392    /// Optional index mtime pair `(sec, nsec)` sampled when the index was read.
5393    ///
5394    /// When provided, entries with matching stat data are still considered dirty candidates if
5395    /// their recorded mtime is "racy" (at or after this timestamp), matching Git's
5396    /// `is_racy_timestamp` behavior.
5397    pub index_mtime: Option<(u32, u32)>,
5398    /// When true, gitlink entries are not dirty solely from untracked files inside the submodule.
5399    pub ignore_submodule_untracked: bool,
5400    /// When true, nested gitlink entries only compare the submodule checkout HEAD to the recorded OID.
5401    pub simplify_gitlinks: bool,
5402    /// When true, a populated gitlink checkout whose `.git` indirection cannot resolve to a HEAD
5403    /// is returned as an error instead of a normal modified gitlink.
5404    pub error_on_broken_gitlinks: bool,
5405    /// Repository git directory used to load config (separate git dir / linked worktrees).
5406    ///
5407    /// When unset, defaults to `work_tree/.git`, which is wrong for gitfile layouts.
5408    pub repository_git_dir: Option<std::path::PathBuf>,
5409    /// When true, adopt refreshed stat data for unchanged blobs during the same directory scan
5410    /// as the worktree diff (one lstat per tracked path).
5411    pub refresh_index_stat_in_pass: bool,
5412    /// When set, used instead of loading config from [`Self::repository_git_dir`].
5413    pub config: Option<std::sync::Arc<crate::config::ConfigSet>>,
5414    /// Cap parallel stat workers (`Some(1)` forces serial), also applied to hashing the
5415    /// racily clean entries the stat refresh verifies. When `None`, honors
5416    /// `core.preloadIndex` and `PARALLEL_STAT_MIN_ENTRIES`.
5417    pub stat_parallel_threads: Option<usize>,
5418    /// CLI / embedder thread override applied to stat preload and hashing in this diff.
5419    pub thread_policy: Option<crate::parallel::ThreadPolicy>,
5420    /// Shared per-operation attribute/conversion context (see [`crate::worktree_rules::WorktreeRules`]).
5421    pub worktree_rules:
5422        Option<std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
5423    /// Status-only shortcuts (default `false`).
5424    ///
5425    /// When true, a tracked file whose size differs from its index entry is reported as
5426    /// modified without reading it, with a null worktree `new_oid`, and intent-to-add
5427    /// entries are not hashed. Content diffs must leave this off so a content change always
5428    /// carries a null worktree `new_oid` (`old_oid != new_oid`).
5429    pub for_status: bool,
5430    /// Report a tracked file as modified without reading it when its size differs from the
5431    /// index entry and its clean conversion is the identity (default `false`, implied by
5432    /// [`Self::for_status`]).
5433    ///
5434    /// The entry carries a null worktree `new_oid`, so `old_oid != new_oid` still marks a
5435    /// content change. Set this when the caller reads the worktree file afterwards anyway (a
5436    /// patch or diffstat), so hashing it here would only repeat that read.
5437    pub size_change_proves_modified: bool,
5438    /// When true (default), query `core.fsmonitor` before scanning the worktree.
5439    pub refresh_fsmonitor: bool,
5440    /// Injectable subprocess runner for fsmonitor hooks; defaults to the system runner.
5441    pub command_runner: Option<std::sync::Arc<dyn crate::command_runner::CommandRunner>>,
5442    /// When set, used as the hook protocol v1 query boundary; otherwise the wall-clock nanosecond timestamp is sampled once per diff.
5443    pub fsmonitor_query_boundary: Option<crate::fsmonitor::FsmonitorQueryBoundary>,
5444    /// When set, only index paths in this set are compared against the work tree.
5445    ///
5446    /// Used by branch switch to verify dirty paths only where the tree-to-tree diff would
5447    /// update the worktree, avoiding a full tracked scan on large indexes. Callers must
5448    /// refuse the operation when the index has unmerged entries; this filter does not
5449    /// substitute for a full unmerged scan.
5450    pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
5451    /// When set, index paths for which this returns true are omitted from the worktree diff.
5452    pub skip_index_path: Option<IndexPathSkipPredicate>,
5453    /// When true with staging materialization, compute blob OIDs without writing loose objects.
5454    pub hash_blobs_without_write: bool,
5455    /// When set, long loops abort with [`crate::error::Error::Cancelled`] once the flag is raised
5456    /// (see [`crate::progress::cancel_watch_arc`] and [`crate::progress::poll_cancel_watch`]).
5457    pub cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
5458    /// When set, work-tree access failures during status emit [`crate::diagnostics::Warning`]
5459    /// values here instead of aborting the diff.
5460    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
5461    /// When true with [`Self::diagnostics`], permission-denied worktree paths warn and are omitted
5462    /// from the diff (not reported as deleted). Used by content diffs and stash; status sets
5463    /// [`Self::for_status`] instead.
5464    pub skip_inaccessible_paths: bool,
5465}
5466
5467impl Default for DiffIndexToWorktreeOptions {
5468    fn default() -> Self {
5469        Self {
5470            index_mtime: None,
5471            ignore_submodule_untracked: false,
5472            simplify_gitlinks: false,
5473            error_on_broken_gitlinks: false,
5474            repository_git_dir: None,
5475            refresh_index_stat_in_pass: false,
5476            config: None,
5477            stat_parallel_threads: None,
5478            thread_policy: None,
5479            worktree_rules: None,
5480            for_status: false,
5481            size_change_proves_modified: false,
5482            refresh_fsmonitor: true,
5483            command_runner: None,
5484            fsmonitor_query_boundary: None,
5485            restrict_to_paths: None,
5486            skip_index_path: None,
5487            hash_blobs_without_write: false,
5488            cancel_watch: None,
5489            diagnostics: None,
5490            skip_inaccessible_paths: false,
5491        }
5492    }
5493}
5494
5495/// Optional worktree access policy for [`diff_tree_to_worktree_with_git_dir_and_rules`]'s slow path.
5496#[derive(Clone, Default)]
5497pub(crate) struct DiffTreeToWorktreeExtras {
5498    /// When true with [`Self::diagnostics`], skip permission-denied paths with a warning.
5499    pub skip_inaccessible_paths: bool,
5500    /// Warning sink paired with [`Self::skip_inaccessible_paths`].
5501    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
5502    /// Limit the walk to these index paths (expanded pathspecs).
5503    pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
5504}
5505
5506#[inline]
5507fn worktree_diff_skips_inaccessible_paths(
5508    for_status: bool,
5509    skip_inaccessible_paths: bool,
5510    materialize_dirty_blobs: bool,
5511) -> bool {
5512    (for_status || skip_inaccessible_paths) && !materialize_dirty_blobs
5513}
5514
5515#[inline]
5516fn poll_diff_cancel_watch(
5517    watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
5518) -> crate::error::Result<()> {
5519    if let Some(flag) = watch {
5520        crate::progress::poll_watch(flag)?;
5521    }
5522    Ok(())
5523}
5524
5525#[inline]
5526fn poll_diff_cancel(
5527    options: &DiffIndexToWorktreeOptions,
5528    owner_poll: Option<&std::sync::Arc<crate::progress::StackCancelPoll>>,
5529) -> crate::error::Result<()> {
5530    if let Some(poll) = owner_poll {
5531        poll.poll_owner()?;
5532    } else {
5533        poll_diff_cancel_watch(options.cancel_watch.as_ref())?;
5534    }
5535    Ok(())
5536}
5537
5538/// One tracked file the parallel pass must hash, queued once per stat-dirty index entry.
5539///
5540/// A stale index can queue every entry, so the job names its entry by position instead of
5541/// owning copies of it, and the workers rebuild paths from the shared index.
5542struct StagingHashJob {
5543    entry_index: usize,
5544    /// Worktree mode after [`crate::index::WorktreeModePolicy`] against the entry's mode.
5545    worktree_mode: u32,
5546    /// `None` when the path has default attributes, which is the common case.
5547    file_attrs: Option<Box<crate::crlf::FileAttrs>>,
5548}
5549
5550impl StagingHashJob {
5551    fn new(entry_index: usize, worktree_mode: u32, file_attrs: crate::crlf::FileAttrs) -> Self {
5552        let file_attrs =
5553            (file_attrs != crate::crlf::FileAttrs::default()).then(|| Box::new(file_attrs));
5554        Self {
5555            entry_index,
5556            worktree_mode,
5557            file_attrs,
5558        }
5559    }
5560}
5561
5562/// Where the parallel pass finds the index entries and files its jobs name.
5563struct StagingHashSource<'a> {
5564    index: &'a Index,
5565    work_tree: &'a Path,
5566    precompose_unicode: bool,
5567    ignorecase: bool,
5568}
5569
5570#[allow(clippy::too_many_arguments)]
5571fn parallel_process_blob_worktree_jobs(
5572    odb: &Odb,
5573    source: &StagingHashSource<'_>,
5574    jobs: &[StagingHashJob],
5575    thread_policy: &crate::parallel::ThreadPolicy,
5576    config: &crate::config::ConfigSet,
5577    materialize_dirty_blobs: bool,
5578    hash_blobs_without_write: bool,
5579    for_status: bool,
5580    skip_inaccessible_paths: bool,
5581    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5582    conv: &crate::crlf::ConversionConfig,
5583    filter_process: Option<&crate::filter_process::FilterProcessState>,
5584    _icase_collisions: Option<&crate::path_icase::IcaseCheckoutCollisionMap>,
5585    result: &mut Vec<DiffEntry>,
5586) -> Result<()> {
5587    use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
5588
5589    if jobs.is_empty() {
5590        return Ok(());
5591    }
5592    let mut par_opts = thread_policy.resolve(ParallelOp::IndexRefresh, config);
5593    par_opts.min_items = 0;
5594    par_opts.min_total_bytes = None;
5595    let est_bytes = jobs.len().saturating_mul(8192);
5596    let store_dirty_blobs = (materialize_dirty_blobs && !hash_blobs_without_write)
5597        .then(|| odb.bulk_stage_write_options());
5598    let stream_from = if store_dirty_blobs.is_some() {
5599        odb.big_file_threshold()?
5600    } else {
5601        hash_stream_threshold(odb)?
5602    };
5603    let default_attrs = crate::crlf::FileAttrs::default();
5604
5605    // Each job yields only the worktree side's oid when the entry changed; most jobs on a
5606    // stale index find clean files, so diff entries are built afterwards for the few changes.
5607    let new_oids = try_par_map::<StagingHashJob, Option<ObjectId>, Error, _>(
5608        jobs,
5609        par_opts,
5610        est_bytes,
5611        |job| {
5612            let entry = &source.index.entries[job.entry_index];
5613            let rel_path = crate::repo_relpath::storage_string(&entry.path);
5614            let modified = |new_oid| Ok(Some(new_oid));
5615            let file_path = index_entry_worktree_abs_bytes(
5616                source.work_tree,
5617                &entry.path,
5618                source.precompose_unicode,
5619                source.ignorecase,
5620            );
5621            let content = match read_regular_worktree_blob(
5622                odb,
5623                &file_path,
5624                conv,
5625                job.file_attrs.as_deref().unwrap_or(&default_attrs),
5626                &rel_path,
5627                Some(entry),
5628                filter_process,
5629                stream_from,
5630            ) {
5631                Ok(content) => content,
5632                Err(err)
5633                    if status_skips_inaccessible_worktree_error(
5634                        &err,
5635                        for_status,
5636                        skip_inaccessible_paths,
5637                        materialize_dirty_blobs,
5638                    ) =>
5639                {
5640                    warn_and_skip_status_inaccessible(diagnostics, rel_path.as_str(), &err);
5641                    return Ok(None);
5642                }
5643                Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
5644                    return modified(zero_oid());
5645                }
5646                Err(err) => return Err(err),
5647            };
5648            let eff_oid = content.object_id(odb, store_dirty_blobs)?;
5649            if eff_oid != entry.oid || job.worktree_mode != entry.mode {
5650                let new_oid = if eff_oid != entry.oid && !materialize_dirty_blobs {
5651                    zero_oid()
5652                } else {
5653                    eff_oid
5654                };
5655                modified(new_oid)
5656            } else {
5657                Ok(None)
5658            }
5659        },
5660    )
5661    .map_err(|e| match e {
5662        ParallelHashError::Task(err) => err,
5663    })?;
5664
5665    let mut changed: Vec<(usize, u32, ObjectId)> = jobs
5666        .iter()
5667        .zip(new_oids)
5668        .filter_map(|(job, new_oid)| new_oid.map(|oid| (job.entry_index, job.worktree_mode, oid)))
5669        .collect();
5670    changed.sort_by_key(|(idx, _, _)| *idx);
5671    result.extend(
5672        changed
5673            .into_iter()
5674            .map(|(entry_index, worktree_mode, new_oid)| {
5675                let entry = &source.index.entries[entry_index];
5676                let rel_path = crate::repo_relpath::storage_string(&entry.path);
5677                worktree_content_change_entry(entry, &rel_path, worktree_mode, new_oid)
5678            }),
5679    );
5680    Ok(())
5681}
5682
5683/// Index-vs-worktree entry for a tracked path whose content or mode differs from `ie`.
5684fn worktree_content_change_entry(
5685    ie: &IndexEntry,
5686    path: &str,
5687    worktree_mode: u32,
5688    new_oid: ObjectId,
5689) -> DiffEntry {
5690    DiffEntry {
5691        status: DiffStatus::for_content_change(ie.mode, worktree_mode),
5692        old_path: Some(path.to_owned()),
5693        new_path: Some(path.to_owned()),
5694        old_mode: FileMode::new(ie.mode),
5695        new_mode: FileMode::new(worktree_mode),
5696        old_oid: ie.oid,
5697        new_oid,
5698        score: None,
5699    }
5700}
5701
5702/// Whether a failed read of a tracked file's content reports the path as modified (with an
5703/// unknown worktree oid) instead of failing the whole scan.
5704///
5705/// A file that cannot be opened (no permission, held exclusively by another process) cannot
5706/// be proven to match the index, so it counts as changed, as in Git. Callers that need the
5707/// bytes themselves (`materialize_dirty_blobs`, i.e. staging) still get the error.
5708fn unreadable_worktree_blob_is_modified(err: &Error, materialize_dirty_blobs: bool) -> bool {
5709    !materialize_dirty_blobs
5710        && matches!(
5711            err,
5712            Error::WorktreePathIo {
5713                operation: crate::worktree::WorktreePathOperation::Read,
5714                ..
5715            }
5716        )
5717}
5718
5719fn warn_worktree_access_failed(
5720    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5721    path: &str,
5722    err: &std::io::Error,
5723) {
5724    if let Some(handle) = diagnostics {
5725        crate::diagnostics::warn(
5726            handle,
5727            crate::diagnostics::Warning::WorktreeAccessFailed {
5728                path: path.to_owned(),
5729                detail: err.to_string(),
5730            },
5731        );
5732    }
5733}
5734
5735fn blob_disk_lookup_io_result(
5736    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5737    path: &str,
5738    err: std::io::Error,
5739    for_status: bool,
5740    skip_inaccessible_paths: bool,
5741    materialize_dirty_blobs: bool,
5742) -> Result<()> {
5743    if worktree_diff_skips_inaccessible_paths(
5744        for_status,
5745        skip_inaccessible_paths,
5746        materialize_dirty_blobs,
5747    ) && io_err_is_permission_denied(&err)
5748    {
5749        warn_worktree_access_failed(diagnostics, path, &err);
5750        return Ok(());
5751    }
5752    Err(Error::WorktreePathIo {
5753        operation: crate::worktree::WorktreePathOperation::Metadata,
5754        path: path.to_owned(),
5755        source: err,
5756    })
5757}
5758
5759fn io_err_is_permission_denied(err: &std::io::Error) -> bool {
5760    err.kind() == std::io::ErrorKind::PermissionDenied
5761}
5762
5763fn status_skips_inaccessible_worktree_error(
5764    err: &Error,
5765    for_status: bool,
5766    skip_inaccessible_paths: bool,
5767    materialize_dirty_blobs: bool,
5768) -> bool {
5769    if !worktree_diff_skips_inaccessible_paths(
5770        for_status,
5771        skip_inaccessible_paths,
5772        materialize_dirty_blobs,
5773    ) {
5774        return false;
5775    }
5776    match err {
5777        Error::Io(e) => io_err_is_permission_denied(e),
5778        Error::WorktreePathIo {
5779            operation: crate::worktree::WorktreePathOperation::Metadata,
5780            source,
5781            ..
5782        } => io_err_is_permission_denied(source),
5783        _ => false,
5784    }
5785}
5786
5787fn warn_and_skip_status_inaccessible(
5788    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5789    path: &str,
5790    err: &Error,
5791) -> bool {
5792    let detail = match err {
5793        Error::Io(e) => e.to_string(),
5794        Error::WorktreePathIo { source, .. } => source.to_string(),
5795        _ => return false,
5796    };
5797    if let Some(handle) = diagnostics {
5798        crate::diagnostics::warn(
5799            handle,
5800            crate::diagnostics::Warning::WorktreeAccessFailed {
5801                path: path.to_owned(),
5802                detail,
5803            },
5804        );
5805    }
5806    true
5807}
5808
5809enum WorktreeSymlinkMetadata {
5810    Found(fs::Metadata),
5811    Absent,
5812    Inaccessible,
5813}
5814
5815fn probe_worktree_symlink_metadata(
5816    file_path: &Path,
5817    path_str: &str,
5818    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5819    for_status: bool,
5820    skip_inaccessible_paths: bool,
5821    materialize_dirty_blobs: bool,
5822) -> Result<WorktreeSymlinkMetadata> {
5823    match fs::symlink_metadata(file_path) {
5824        Ok(meta) => Ok(WorktreeSymlinkMetadata::Found(meta)),
5825        Err(e)
5826            if e.kind() == std::io::ErrorKind::NotFound
5827                || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5828        {
5829            Ok(WorktreeSymlinkMetadata::Absent)
5830        }
5831        Err(e) => {
5832            if worktree_diff_skips_inaccessible_paths(
5833                for_status,
5834                skip_inaccessible_paths,
5835                materialize_dirty_blobs,
5836            ) && io_err_is_permission_denied(&e)
5837            {
5838                warn_worktree_access_failed(diagnostics, path_str, &e);
5839                Ok(WorktreeSymlinkMetadata::Inaccessible)
5840            } else {
5841                Err(Error::WorktreePathIo {
5842                    operation: crate::worktree::WorktreePathOperation::Metadata,
5843                    path: path_str.to_owned(),
5844                    source: e,
5845                })
5846            }
5847        }
5848    }
5849}
5850
5851fn symlink_metadata_worktree_diff(
5852    file_path: &Path,
5853    path_str: &str,
5854    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5855    for_status: bool,
5856    skip_inaccessible_paths: bool,
5857    materialize_dirty_blobs: bool,
5858) -> Result<Option<fs::Metadata>> {
5859    Ok(
5860        match probe_worktree_symlink_metadata(
5861            file_path,
5862            path_str,
5863            diagnostics,
5864            for_status,
5865            skip_inaccessible_paths,
5866            materialize_dirty_blobs,
5867        )? {
5868            WorktreeSymlinkMetadata::Found(meta) => Some(meta),
5869            WorktreeSymlinkMetadata::Absent | WorktreeSymlinkMetadata::Inaccessible => None,
5870        },
5871    )
5872}
5873
5874/// Whether status may report `ie` modified because the worktree size (already munged to the
5875/// index's 32-bit form) differs from the cached one, without reading the file (Git
5876/// `compare_stat_data`).
5877///
5878/// Only sound when clean conversion is the identity: a line-ending rule, filter, encoding or
5879/// `ident` can turn a file of another size into exactly the indexed blob. The
5880/// `conversion_is_identity` check runs only once the sizes differ, so clean entries never
5881/// resolve attributes here.
5882fn status_size_change_proves_modified(
5883    ie: &IndexEntry,
5884    worktree_size: u32,
5885    for_status: bool,
5886    conversion_is_identity: impl FnOnce() -> bool,
5887) -> bool {
5888    for_status && !ie.stat_cache_unset() && worktree_size != ie.size && conversion_is_identity()
5889}
5890
5891/// Report `ie` modified when [`status_size_change_proves_modified`] holds; returns whether it
5892/// did. The worktree side uses the null OID because the file was not hashed.
5893#[allow(clippy::too_many_arguments)]
5894fn try_push_status_modified_on_size_mismatch(
5895    odb: &Odb,
5896    ie: &IndexEntry,
5897    path_str_ref: &str,
5898    worktree_size: u32,
5899    worktree_mode: u32,
5900    raw_worktree_len: u64,
5901    for_status: bool,
5902    conversion_is_identity: impl FnOnce() -> bool,
5903    result: &mut Vec<DiffEntry>,
5904) -> Result<bool> {
5905    let identity = conversion_is_identity();
5906    let size_differs = worktree_size != ie.size;
5907    if !size_differs || !identity {
5908        return Ok(false);
5909    }
5910    let threshold = odb.big_file_threshold()?;
5911    let big_file = raw_worktree_len > threshold;
5912    let proves_modified = if big_file {
5913        true
5914    } else {
5915        status_size_change_proves_modified(ie, worktree_size, for_status, || identity)
5916    };
5917    if !proves_modified {
5918        return Ok(false);
5919    }
5920    result.push(worktree_content_change_entry(
5921        ie,
5922        path_str_ref,
5923        worktree_mode,
5924        zero_oid(),
5925    ));
5926    Ok(true)
5927}
5928
5929#[allow(clippy::too_many_arguments)]
5930fn push_index_blob_worktree_diff(
5931    odb: &Odb,
5932    ie: &IndexEntry,
5933    path_str_ref: &str,
5934    file_path: &Path,
5935    meta: fs::Metadata,
5936    index_mtime: Option<(u32, u32)>,
5937    mode_policy: crate::index::WorktreeModePolicy,
5938    stat_policy: crate::index::StatMatchPolicy,
5939    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
5940    conv: &crate::crlf::ConversionConfig,
5941    materialize_dirty_blobs: bool,
5942    hash_blobs_without_write: bool,
5943    for_status: bool,
5944    size_change_proves_modified: bool,
5945    skip_inaccessible_paths: bool,
5946    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5947    filter_process: Option<&crate::filter_process::FilterProcessState>,
5948    icase_collisions: Option<&crate::path_icase::IcaseCheckoutCollisionMap>,
5949    result: &mut Vec<DiffEntry>,
5950) -> Result<()> {
5951    if meta.is_dir() {
5952        if file_path.join(".git").exists() {
5953            let head = read_submodule_head_oid(file_path).unwrap_or_else(zero_oid);
5954            let path_owned = path_str_ref.to_owned();
5955            result.push(DiffEntry {
5956                status: DiffStatus::TypeChanged,
5957                old_path: Some(path_owned.clone()),
5958                new_path: Some(path_owned),
5959                old_mode: FileMode::new(ie.mode),
5960                new_mode: FileMode::new(0o160000),
5961                old_oid: ie.oid,
5962                new_oid: head,
5963                score: None,
5964            });
5965            return Ok(());
5966        }
5967        result.push(DiffEntry {
5968            status: DiffStatus::Deleted,
5969            old_path: Some(path_str_ref.to_owned()),
5970            new_path: None,
5971            old_mode: FileMode::new(ie.mode),
5972            new_mode: FileMode::ABSENT,
5973            old_oid: ie.oid,
5974            new_oid: zero_oid(),
5975            score: None,
5976        });
5977        return Ok(());
5978    }
5979
5980    let worktree_mode =
5981        mode_policy.index_mode_for_worktree(mode_from_metadata(&meta), Some(ie.mode));
5982    let stat_same = stat_matches_with_policy(ie, &meta, stat_policy);
5983    if stat_same && worktree_mode != ie.mode {
5984        result.push(worktree_content_change_entry(
5985            ie,
5986            path_str_ref,
5987            worktree_mode,
5988            ie.oid,
5989        ));
5990        return Ok(());
5991    }
5992
5993    if stat_same
5994        && index_entry_skips_worktree_content_hash_after_stat_match(ie, index_mtime, stat_policy)
5995    {
5996        return Ok(());
5997    }
5998
5999    let file_attrs = file_attrs_for(path_str_ref);
6000    if !icase_collisions.is_some_and(|map| map.diff_requires_worktree_hash(ie.path.as_slice()))
6001        && try_push_status_modified_on_size_mismatch(
6002            odb,
6003            ie,
6004            path_str_ref,
6005            crate::index::munge_index_size(meta.len()),
6006            worktree_mode,
6007            meta.len(),
6008            size_change_proves_modified,
6009            || crate::crlf::git_conversion_is_identity(conv, &file_attrs),
6010            result,
6011        )?
6012    {
6013        return Ok(());
6014    }
6015
6016    let store = (materialize_dirty_blobs && !hash_blobs_without_write)
6017        .then(|| odb.bulk_stage_write_options());
6018    let stream_from = if store.is_some() {
6019        odb.big_file_threshold()?
6020    } else {
6021        hash_stream_threshold(odb)?
6022    };
6023    let content = match worktree_blob_content_from(
6024        odb,
6025        file_path,
6026        &meta,
6027        conv,
6028        &file_attrs,
6029        path_str_ref,
6030        Some(ie),
6031        filter_process,
6032        stream_from,
6033    ) {
6034        Ok(content) => content,
6035        Err(err)
6036            if status_skips_inaccessible_worktree_error(
6037                &err,
6038                for_status,
6039                skip_inaccessible_paths,
6040                materialize_dirty_blobs,
6041            ) =>
6042        {
6043            warn_and_skip_status_inaccessible(diagnostics, path_str_ref, &err);
6044            return Ok(());
6045        }
6046        Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
6047            result.push(worktree_content_change_entry(
6048                ie,
6049                path_str_ref,
6050                worktree_mode,
6051                zero_oid(),
6052            ));
6053            return Ok(());
6054        }
6055        Err(err) => return Err(err),
6056    };
6057    let eff_oid = content.object_id(odb, store)?;
6058
6059    if eff_oid != ie.oid || worktree_mode != ie.mode {
6060        // `git diff-files --raw` uses the null OID for the worktree side when content differs.
6061        // Staging (`diff_index_to_worktree_for_staging`) materializes blobs and needs the real OID.
6062        let new_oid = if eff_oid != ie.oid && !materialize_dirty_blobs {
6063            zero_oid()
6064        } else {
6065            eff_oid
6066        };
6067        result.push(worktree_content_change_entry(
6068            ie,
6069            path_str_ref,
6070            worktree_mode,
6071            new_oid,
6072        ));
6073    }
6074    Ok(())
6075}
6076
6077/// Compare tracked index entries with the worktree, using link-target bytes for symlinks.
6078///
6079/// Symlink targets bypass regular-file filters and line-ending conversion. Parallel and
6080/// serial scans use the same file-type classification, including directory replacements
6081/// and regular-file placeholders checked out with `core.symlinks=false`.
6082///
6083/// # Errors
6084///
6085/// Returns errors from I/O or hashing.
6086pub fn diff_index_to_worktree_with_options(
6087    odb: &Odb,
6088    index: &mut Index,
6089    work_tree: &Path,
6090    options: DiffIndexToWorktreeOptions,
6091) -> Result<(Vec<DiffEntry>, bool)> {
6092    let default_git_dir = work_tree.join(".git");
6093    let git_dir = options
6094        .repository_git_dir
6095        .clone()
6096        .unwrap_or(default_git_dir);
6097    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false, None)
6098        .map(|(entries, changed, _)| (entries, changed))
6099}
6100
6101/// Like [`diff_index_to_worktree_with_options`], with an owner-thread progress poll hook for
6102/// long stat scans (used by [`crate::porcelain::status::status`]).
6103pub(crate) fn diff_index_to_worktree_with_owner_poll(
6104    odb: &Odb,
6105    index: &mut Index,
6106    work_tree: &Path,
6107    options: DiffIndexToWorktreeOptions,
6108    owner_poll: Option<std::sync::Arc<crate::progress::StackCancelPoll>>,
6109) -> Result<(Vec<DiffEntry>, bool)> {
6110    let default_git_dir = work_tree.join(".git");
6111    let git_dir = options
6112        .repository_git_dir
6113        .clone()
6114        .unwrap_or(default_git_dir);
6115    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false, owner_poll)
6116        .map(|(entries, changed, _)| (entries, changed))
6117}
6118
6119/// Like [`diff_index_to_worktree_with_options`], but loads config from `repository_git_dir`
6120/// (the main repository for linked worktrees) and persists each hashed worktree blob into `odb`
6121/// so staging can update the index without re-reading file contents.
6122/// Symlinks store their target bytes. With [`DiffIndexToWorktreeOptions::hash_blobs_without_write`],
6123/// returns the same blob IDs without writing objects, for staging previews.
6124pub fn diff_index_to_worktree_for_staging(
6125    odb: &Odb,
6126    repository_git_dir: &Path,
6127    index: &mut Index,
6128    work_tree: &Path,
6129    options: DiffIndexToWorktreeOptions,
6130) -> Result<(
6131    Vec<DiffEntry>,
6132    bool,
6133    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
6134)> {
6135    diff_index_to_worktree_inner(
6136        odb,
6137        index,
6138        work_tree,
6139        repository_git_dir,
6140        options,
6141        true,
6142        None,
6143    )
6144}
6145
6146/// First pass of [`diff_index_to_worktree_inner`]: gitlinks and intent-to-add paths only.
6147#[allow(clippy::too_many_arguments)]
6148fn worktree_diff_pass1_index_entry(
6149    odb: &Odb,
6150    work_tree: &Path,
6151    ie: &IndexEntry,
6152    path_str_ref: &str,
6153    config: &ConfigSet,
6154    simplify_gitlinks: bool,
6155    ignore_submodule_untracked: bool,
6156    error_on_broken_gitlinks: bool,
6157    materialize_dirty_blobs: bool,
6158    filter_fp: Option<&crate::filter_process::FilterProcessState>,
6159    conv: &crate::crlf::ConversionConfig,
6160    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
6161    precompose_unicode: bool,
6162    ignorecase: bool,
6163    result: &mut Vec<DiffEntry>,
6164) -> Result<()> {
6165    if ie.skip_worktree() || ie.assume_unchanged() {
6166        return Ok(());
6167    }
6168    let is_intent_to_add = ie.intent_to_add();
6169
6170    if ie.mode == 0o160000 {
6171        let sub_dir =
6172            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
6173        match fs::symlink_metadata(&sub_dir) {
6174            Err(e)
6175                if e.kind() == std::io::ErrorKind::NotFound
6176                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
6177            {
6178                if !simplify_gitlinks {
6179                    let path_owned = path_str_ref.to_owned();
6180                    result.push(DiffEntry {
6181                        status: DiffStatus::Deleted,
6182                        old_path: Some(path_owned.clone()),
6183                        new_path: Some(path_owned),
6184                        old_mode: FileMode::new(ie.mode),
6185                        new_mode: FileMode::ABSENT,
6186                        old_oid: ie.oid,
6187                        new_oid: zero_oid(),
6188                        score: None,
6189                    });
6190                }
6191                return Ok(());
6192            }
6193            Err(e) => return Err(Error::Io(e)),
6194            Ok(meta) if !meta.is_dir() => {
6195                let worktree_mode = mode_from_metadata(&meta);
6196                let file_attrs = file_attrs_for(path_str_ref);
6197                let worktree_oid = worktree_file_oid(
6198                    odb,
6199                    &sub_dir,
6200                    &meta,
6201                    conv,
6202                    &file_attrs,
6203                    path_str_ref,
6204                    None,
6205                    materialize_dirty_blobs,
6206                    filter_fp,
6207                )?;
6208                let path_owned = path_str_ref.to_owned();
6209                result.push(DiffEntry {
6210                    status: DiffStatus::TypeChanged,
6211                    old_path: Some(path_owned.clone()),
6212                    new_path: Some(path_owned),
6213                    old_mode: FileMode::new(ie.mode),
6214                    new_mode: FileMode::new(worktree_mode),
6215                    old_oid: ie.oid,
6216                    new_oid: worktree_oid,
6217                    score: None,
6218                });
6219                return Ok(());
6220            }
6221            Ok(_) => {}
6222        }
6223        let sub_head_oid = read_submodule_head_oid(&sub_dir);
6224        let ref_matches = if let Some(oid) = sub_head_oid {
6225            oid == ie.oid
6226        } else {
6227            if error_on_broken_gitlinks && submodule_embedded_git_dir(&sub_dir).is_some() {
6228                return Err(Error::Submodule(SubmoduleError::HeadUnreadable {
6229                    path: path_str_ref.to_owned(),
6230                }));
6231            }
6232            uninitialized_gitlink_worktree_matches_index(&sub_dir)
6233        };
6234        if simplify_gitlinks {
6235            if !ref_matches {
6236                let path_owned = path_str_ref.to_owned();
6237                let new_oid = sub_head_oid.unwrap_or_else(zero_oid);
6238                result.push(DiffEntry {
6239                    status: DiffStatus::Modified,
6240                    old_path: Some(path_owned.clone()),
6241                    new_path: Some(path_owned),
6242                    old_mode: FileMode::new(ie.mode),
6243                    new_mode: FileMode::new(ie.mode),
6244                    old_oid: ie.oid,
6245                    new_oid,
6246                    score: None,
6247                });
6248            }
6249            return Ok(());
6250        }
6251        if sub_head_oid.is_some() && submodule_head_object_broken(&sub_dir) {
6252            return Err(Error::Submodule(SubmoduleError::HeadCommitMissing {
6253                path: path_str_ref.to_owned(),
6254            }));
6255        }
6256        let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
6257        if ignore_submodule_untracked {
6258            flags.untracked = false;
6259        }
6260        let ignore =
6261            crate::submodule_ignore::submodule_ignore_for_path(config, work_tree, path_str_ref);
6262        if crate::submodule_ignore::gitlink_worktree_change_suppressed(ignore, ref_matches, flags) {
6263            return Ok(());
6264        }
6265        let inner_dirty = flags.modified || flags.untracked;
6266        if !ref_matches || inner_dirty {
6267            let path_owned = path_str_ref.to_owned();
6268            let new_oid = if !ref_matches {
6269                sub_head_oid.unwrap_or_else(zero_oid)
6270            } else {
6271                zero_oid()
6272            };
6273            result.push(DiffEntry {
6274                status: DiffStatus::Modified,
6275                old_path: Some(path_owned.clone()),
6276                new_path: Some(path_owned),
6277                old_mode: FileMode::new(ie.mode),
6278                new_mode: FileMode::new(ie.mode),
6279                old_oid: ie.oid,
6280                new_oid,
6281                score: None,
6282            });
6283        }
6284        return Ok(());
6285    }
6286
6287    let file_path =
6288        index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
6289    let path_match = crate::repo_relpath::as_utf8_for_matching(path_str_ref);
6290
6291    if is_intent_to_add {
6292        match fs::symlink_metadata(&file_path) {
6293            Ok(meta) => {
6294                let file_attrs = file_attrs_for(path_match.as_ref());
6295                let worktree_oid = worktree_file_oid(
6296                    odb,
6297                    &file_path,
6298                    &meta,
6299                    conv,
6300                    &file_attrs,
6301                    path_match.as_ref(),
6302                    None,
6303                    materialize_dirty_blobs,
6304                    filter_fp,
6305                )?;
6306                let worktree_mode = mode_from_metadata(&meta);
6307                result.push(DiffEntry {
6308                    status: DiffStatus::Added,
6309                    old_path: None,
6310                    new_path: Some(path_str_ref.to_owned()),
6311                    old_mode: FileMode::ABSENT,
6312                    new_mode: FileMode::new(worktree_mode),
6313                    old_oid: zero_oid(),
6314                    new_oid: worktree_oid,
6315                    score: None,
6316                });
6317            }
6318            Err(e)
6319                if e.kind() == std::io::ErrorKind::NotFound
6320                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
6321            {
6322                result.push(DiffEntry {
6323                    status: DiffStatus::Deleted,
6324                    old_path: Some(path_str_ref.to_owned()),
6325                    new_path: None,
6326                    old_mode: FileMode::new(ie.mode),
6327                    new_mode: FileMode::ABSENT,
6328                    old_oid: ie.oid,
6329                    new_oid: zero_oid(),
6330                    score: None,
6331                });
6332            }
6333            Err(e) => return Err(Error::Io(e)),
6334        }
6335    }
6336    Ok(())
6337}
6338
6339#[inline]
6340fn index_entry_needs_worktree_diff_pass1(ie: &IndexEntry) -> bool {
6341    ie.mode == crate::index::MODE_GITLINK || ie.intent_to_add()
6342}
6343
6344fn diff_index_to_worktree_inner(
6345    odb: &Odb,
6346    index: &mut Index,
6347    work_tree: &Path,
6348    repository_git_dir: &Path,
6349    options: DiffIndexToWorktreeOptions,
6350    materialize_dirty_blobs: bool,
6351    owner_poll: Option<std::sync::Arc<crate::progress::StackCancelPoll>>,
6352) -> Result<(
6353    Vec<DiffEntry>,
6354    bool,
6355    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
6356)> {
6357    use crate::config::ConfigSet;
6358    use crate::crlf;
6359    use std::collections::BTreeMap;
6360
6361    let mut staging_stat_cache = BTreeMap::new();
6362    poll_diff_cancel(&options, owner_poll.as_ref())?;
6363    let index_mtime = index_mtime_for_diff(index, options.index_mtime);
6364
6365    let ignore_submodule_untracked = options.ignore_submodule_untracked;
6366    let simplify_gitlinks = options.simplify_gitlinks;
6367    // Staging stores the worktree blob, so it always needs the file's object id.
6368    let size_change_proves_modified =
6369        !materialize_dirty_blobs && (options.for_status || options.size_change_proves_modified);
6370
6371    let rules_lock = options
6372        .worktree_rules
6373        .as_ref()
6374        .map(|shared| {
6375            shared
6376                .lock()
6377                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
6378        })
6379        .transpose()?;
6380    let config = if let Some(rules) = rules_lock.as_ref() {
6381        rules.config().clone()
6382    } else if let Some(c) = options.config.as_ref() {
6383        c.as_ref().clone()
6384    } else {
6385        ConfigSet::load(
6386            &crate::environment::Environment::empty(),
6387            Some(repository_git_dir),
6388            true,
6389        )
6390        .unwrap_or_else(|_| ConfigSet::new())
6391    };
6392    let conv = rules_lock
6393        .as_ref()
6394        .map(|r| r.conversion().clone())
6395        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
6396    let legacy_attrs = if rules_lock.is_none() {
6397        Some(crlf::load_gitattributes_parsed(work_tree))
6398    } else {
6399        None
6400    };
6401    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
6402        if let Some(rules) = rules_lock.as_ref() {
6403            rules.file_attrs(path, false)
6404        } else if let Some(parsed) = legacy_attrs.as_ref() {
6405            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
6406        } else {
6407            crlf::FileAttrs::default()
6408        }
6409    };
6410    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
6411    let precompose_unicode = config.precompose_unicode();
6412    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
6413    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
6414    let ignorecase = config.ignore_case();
6415    let icase_collisions = ignorecase
6416        .then(|| crate::path_icase::IcaseCheckoutCollisionMap::from_index(work_tree, index, true));
6417    let hash_blobs_without_write = options.hash_blobs_without_write;
6418
6419    let mut result = Vec::new();
6420    let refresh_index_stat_in_pass = options.refresh_index_stat_in_pass;
6421    let mut index_stat_refresh_changed = false;
6422    let mut fsmonitor_valid_updated = false;
6423    let mut honor_fsmonitor_valid = false;
6424    if options.refresh_fsmonitor {
6425        let default_runner = crate::command_runner::system_command_runner();
6426        let runner = options
6427            .command_runner
6428            .as_deref()
6429            .unwrap_or(default_runner.as_ref());
6430        let boundary = options.fsmonitor_query_boundary.unwrap_or_else(|| {
6431            crate::fsmonitor::FsmonitorQueryBoundary(
6432                crate::environment::wall_clock_unix_timestamp_nanos(),
6433            )
6434        });
6435        let refresh_opts = crate::fsmonitor::FsmonitorRefreshOptions::with_query_boundary(boundary);
6436        let refresh = crate::fsmonitor::refresh_index(
6437            index,
6438            repository_git_dir,
6439            work_tree,
6440            &config,
6441            Some(runner),
6442            &refresh_opts,
6443        )?;
6444        if refresh.index_changed {
6445            index_stat_refresh_changed = true;
6446        }
6447        honor_fsmonitor_valid = refresh.honor_valid_bits;
6448    }
6449    let use_fsmonitor_stat_skip = honor_fsmonitor_valid;
6450    let mut unmerged_base: std::collections::BTreeMap<String, (u8, IndexEntry)> =
6451        std::collections::BTreeMap::new();
6452
6453    let error_on_broken_gitlinks = options.error_on_broken_gitlinks;
6454    if options.restrict_to_paths.is_none() {
6455        for ie in &index.entries {
6456            poll_diff_cancel(&options, owner_poll.as_ref())?;
6457            if ie.stage() != 0 {
6458                let path = String::from_utf8_lossy(&ie.path).to_string();
6459                let rank = match ie.stage() {
6460                    2 => 0u8,
6461                    3 => 1u8,
6462                    1 => 2u8,
6463                    _ => 3u8,
6464                };
6465                match unmerged_base.get(&path) {
6466                    Some((existing_rank, _)) if *existing_rank <= rank => {}
6467                    _ => {
6468                        unmerged_base.insert(path, (rank, ie.clone()));
6469                    }
6470                }
6471            }
6472        }
6473    }
6474
6475    if let Some(limit) = options.restrict_to_paths.as_ref() {
6476        let mut cursor = 0;
6477        for path in limit.iter() {
6478            let path_bytes = crate::repo_relpath::storage_bytes(path);
6479            let Some(pos) = index.find_stage0_pos_from(path_bytes.as_ref(), &mut cursor) else {
6480                continue;
6481            };
6482            let ie = &index.entries[pos];
6483            if options
6484                .skip_index_path
6485                .as_ref()
6486                .is_some_and(|skip| skip(&ie.path))
6487            {
6488                continue;
6489            }
6490            if !index_entry_needs_worktree_diff_pass1(ie) {
6491                continue;
6492            }
6493            worktree_diff_pass1_index_entry(
6494                odb,
6495                work_tree,
6496                ie,
6497                path.as_str(),
6498                &config,
6499                simplify_gitlinks,
6500                ignore_submodule_untracked,
6501                error_on_broken_gitlinks,
6502                materialize_dirty_blobs,
6503                filter_fp,
6504                &conv,
6505                &file_attrs_for,
6506                precompose_unicode,
6507                ignorecase,
6508                &mut result,
6509            )?;
6510        }
6511    } else {
6512        for ie in &index.entries {
6513            poll_diff_cancel(&options, owner_poll.as_ref())?;
6514            if ie.stage() != 0 {
6515                continue;
6516            }
6517            if options
6518                .skip_index_path
6519                .as_ref()
6520                .is_some_and(|skip| skip(&ie.path))
6521            {
6522                continue;
6523            }
6524            if !index_entry_needs_worktree_diff_pass1(ie) {
6525                continue;
6526            }
6527            let path_stored = crate::repo_relpath::storage_string(&ie.path);
6528            worktree_diff_pass1_index_entry(
6529                odb,
6530                work_tree,
6531                ie,
6532                &path_stored,
6533                &config,
6534                simplify_gitlinks,
6535                ignore_submodule_untracked,
6536                error_on_broken_gitlinks,
6537                materialize_dirty_blobs,
6538                filter_fp,
6539                &conv,
6540                &file_attrs_for,
6541                precompose_unicode,
6542                ignorecase,
6543                &mut result,
6544            )?;
6545        }
6546    }
6547
6548    use crate::worktree_scan::{
6549        collect_blob_lookup_batches_with_literal_paths, group_blob_entries_by_dir,
6550        stat_scan_thread_count, visit_blob_lookup_batches, BlobDiskLookup, BlobScanSparseMode,
6551        WorktreeBlobScanOptions, PARALLEL_STAT_MIN_ENTRIES,
6552    };
6553    let preload_index = config.preload_index();
6554    let blob_scan = WorktreeBlobScanOptions {
6555        preload_index,
6556        stat_parallel_threads: options.stat_parallel_threads,
6557        ..WorktreeBlobScanOptions::default()
6558    };
6559    let blob_dirs = if let Some(limit) = options.restrict_to_paths.as_ref() {
6560        if let Some(skip) = options.skip_index_path.as_ref() {
6561            let mut positions = Vec::new();
6562            let mut cursor = 0;
6563            for path in limit.iter() {
6564                let path_bytes = crate::repo_relpath::storage_bytes(path);
6565                let Some(pos) = index.find_stage0_pos_from(path_bytes.as_ref(), &mut cursor) else {
6566                    continue;
6567                };
6568                let ie = &index.entries[pos];
6569                if skip(&ie.path) {
6570                    continue;
6571                }
6572                if !crate::worktree_scan::index_blob_scan_eligible(ie, index, honor_fsmonitor_valid)
6573                {
6574                    continue;
6575                }
6576                positions.push(pos);
6577            }
6578            crate::worktree_scan::plan_blob_entries_by_dir_for_positions(
6579                index,
6580                &positions,
6581                honor_fsmonitor_valid,
6582            )
6583        } else {
6584            crate::worktree_scan::group_blob_entries_by_dir_for_paths(index, limit)
6585        }
6586    } else if let Some(skip) = options.skip_index_path.as_ref() {
6587        let positions: Vec<usize> = index
6588            .entries
6589            .iter()
6590            .enumerate()
6591            .filter(|(_, ie)| {
6592                ie.stage() == 0
6593                    && !skip(&ie.path)
6594                    && crate::worktree_scan::index_blob_scan_eligible(
6595                        ie,
6596                        index,
6597                        honor_fsmonitor_valid,
6598                    )
6599            })
6600            .map(|(pos, _)| pos)
6601            .collect();
6602        crate::worktree_scan::plan_blob_entries_by_dir_for_positions(
6603            index,
6604            &positions,
6605            honor_fsmonitor_valid,
6606        )
6607    } else {
6608        group_blob_entries_by_dir(index, honor_fsmonitor_valid)
6609    };
6610    let work_tree_owned = work_tree.to_path_buf();
6611    let dir_abs = move |dir: &[u8]| {
6612        if dir.is_empty() {
6613            work_tree_owned.clone()
6614        } else {
6615            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
6616        }
6617    };
6618    let work_tree_for_files = work_tree.to_path_buf();
6619    let file_abs = move |rel: &[u8]| {
6620        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
6621    };
6622    let blob_count = blob_dirs.len();
6623    let thread_policy = options.thread_policy.unwrap_or_default();
6624    let hash_threads = stat_scan_thread_count(&thread_policy, &config, blob_scan, blob_count);
6625    let parallel_blob_work = blob_count >= PARALLEL_STAT_MIN_ENTRIES && hash_threads > 1;
6626    let mut staging_hash_jobs: Vec<StagingHashJob> = Vec::new();
6627    // Entries the stat refresh just hashed and found matching their recorded blob.
6628    let mut refresh_content_verified = vec![false; index.entries.len()];
6629    // Parallel preload must lstat ancestor directories without following symlinks. When a
6630    // tracked directory is replaced by a symlink, indexed paths under it are deletions even if
6631    // the link target still holds a file at the same relative path with an unchanged stat.
6632    // This is independent of `core.symlinks` (checkout support); detection must stay enabled.
6633    let sparse = if parallel_blob_work {
6634        BlobScanSparseMode::StatusDiff {
6635            stat_policy,
6636            index_mtime,
6637            mode_policy,
6638            for_status: options.for_status,
6639            refresh_index_stat_in_pass,
6640            parallel_blob_work,
6641            honor_fsmonitor_stat_skip: use_fsmonitor_stat_skip,
6642        }
6643    } else {
6644        BlobScanSparseMode::Dense
6645    };
6646    let blob_stat_batches = collect_blob_lookup_batches_with_literal_paths(
6647        index,
6648        &blob_dirs,
6649        blob_scan,
6650        &thread_policy,
6651        &config,
6652        dir_abs,
6653        file_abs,
6654        !precompose_unicode && !ignorecase,
6655        || {
6656            let mut dir_symlinks = SymlinkDirCache::default();
6657            move |rel_path: &[u8]| {
6658                dir_symlinks.has_symlink_in_path_bytes(
6659                    work_tree,
6660                    rel_path,
6661                    precompose_unicode,
6662                    ignorecase,
6663                )
6664            }
6665        },
6666        sparse,
6667        options.cancel_watch.clone(),
6668        owner_poll.clone(),
6669    )?;
6670    // Size-changed entries ask whether their clean conversion is the identity in both the stat
6671    // refresh and the visit below; resolve each one's attributes once.
6672    let identity_by_entry = std::cell::RefCell::new(std::collections::HashMap::new());
6673    let conversion_is_identity = |entry_index: usize, path: &str| -> bool {
6674        *identity_by_entry
6675            .borrow_mut()
6676            .entry(entry_index)
6677            .or_insert_with(|| crlf::git_conversion_is_identity(&conv, &file_attrs_for(path)))
6678    };
6679    let refresh_checks = if refresh_index_stat_in_pass {
6680        let par_opts =
6681            refresh_hash_parallel_options(&thread_policy, &config, options.stat_parallel_threads);
6682        refresh_content_checks(
6683            odb,
6684            index,
6685            &blob_stat_batches,
6686            index_mtime,
6687            stat_policy,
6688            &|entry_index, ie, stat| {
6689                !(use_fsmonitor_stat_skip && crate::fsmonitor::entry_skips_worktree_stat(ie, index))
6690                    && worktree_diff_refreshes_entry(
6691                        ie,
6692                        stat,
6693                        index_mtime,
6694                        stat_policy,
6695                        options.for_status,
6696                        || {
6697                            conversion_is_identity(
6698                                entry_index,
6699                                &crate::repo_relpath::storage_string(&ie.path),
6700                            )
6701                        },
6702                    )
6703            },
6704            &WorktreeHashContext {
6705                work_tree,
6706                precompose_unicode,
6707                ignorecase,
6708                conv: &conv,
6709                file_attrs_for: &file_attrs_for,
6710                filter_process: filter_fp,
6711            },
6712            par_opts,
6713            options.cancel_watch.as_ref(),
6714        )?
6715    } else {
6716        RefreshChecks::default()
6717    };
6718    poll_diff_cancel(&options, owner_poll.as_ref())?;
6719    visit_blob_lookup_batches(blob_stat_batches, |entry_index, lookup| {
6720        poll_diff_cancel(&options, owner_poll.as_ref())?;
6721        let path_owned = crate::repo_relpath::storage_string(&index.entries[entry_index].path);
6722        let path_str_ref = path_owned.as_str();
6723        if use_fsmonitor_stat_skip {
6724            let ie_skip = &index.entries[entry_index];
6725            if crate::fsmonitor::entry_skips_worktree_stat(ie_skip, index) {
6726                return Ok(());
6727            }
6728        }
6729        match lookup {
6730            BlobDiskLookup::Missing => {
6731                if icase_collisions.as_ref().is_some_and(|map| {
6732                    map.preserve_missing_collision_loser(&index.entries[entry_index].path)
6733                }) {
6734                    return Ok(());
6735                }
6736                let ie = &index.entries[entry_index];
6737                result.push(DiffEntry {
6738                    status: DiffStatus::Deleted,
6739                    old_path: Some(path_str_ref.to_owned()),
6740                    new_path: None,
6741                    old_mode: FileMode::new(ie.mode),
6742                    new_mode: FileMode::ABSENT,
6743                    old_oid: ie.oid,
6744                    new_oid: zero_oid(),
6745                    score: None,
6746                });
6747            }
6748            BlobDiskLookup::Io(e) => {
6749                blob_disk_lookup_io_result(
6750                    options.diagnostics.as_ref(),
6751                    path_str_ref,
6752                    e,
6753                    options.for_status,
6754                    options.skip_inaccessible_paths,
6755                    materialize_dirty_blobs,
6756                )?;
6757            }
6758            BlobDiskLookup::Present(stat) => {
6759                if materialize_dirty_blobs {
6760                    staging_stat_cache.insert(path_str_ref.to_owned(), stat.fields);
6761                }
6762                if refresh_index_stat_in_pass
6763                    && worktree_diff_refreshes_entry(
6764                        &index.entries[entry_index],
6765                        &stat,
6766                        index_mtime,
6767                        stat_policy,
6768                        options.for_status,
6769                        || conversion_is_identity(entry_index, path_str_ref),
6770                    )
6771                {
6772                    let content_matches = RefreshContent::matches(
6773                        refresh_checks.get(entry_index),
6774                        &index.entries[entry_index].oid,
6775                    );
6776                    let refresh = refresh_index_blob_stat_from_scan(
6777                        &mut index.entries[entry_index],
6778                        &stat,
6779                        index_mtime,
6780                        stat_policy,
6781                        content_matches,
6782                    );
6783                    if refresh.index_changed {
6784                        index_stat_refresh_changed = true;
6785                    }
6786                    if refresh.content_verified {
6787                        refresh_content_verified[entry_index] = true;
6788                        let path = index.entries[entry_index].path.clone();
6789                        let oid = index.entries[entry_index].oid;
6790                        index.mark_content_verified(&path, oid);
6791                    }
6792                }
6793                let ie = &index.entries[entry_index];
6794                let worktree_mode =
6795                    mode_policy.index_mode_for_worktree(stat.git_mode, Some(ie.mode));
6796                // The stat refresh already hashed this file and found other content, so do not
6797                // read it again. Staging hashes again because it needs the blob stored.
6798                if !materialize_dirty_blobs
6799                    && !stat.is_dir
6800                    && stat.git_mode != crate::index::MODE_SYMLINK
6801                    && RefreshContent::differs(refresh_checks.get(entry_index), &ie.oid)
6802                {
6803                    result.push(worktree_content_change_entry(
6804                        ie,
6805                        path_str_ref,
6806                        worktree_mode,
6807                        zero_oid(),
6808                    ));
6809                    return Ok(());
6810                }
6811                // Streaming jobs read regular files. Links and directories need the shared
6812                // classifier; use the physical type so core.symlinks=false placeholders
6813                // keep the regular-file fast path even with an effective symlink mode.
6814                if parallel_blob_work && !stat.is_dir && stat.git_mode != crate::index::MODE_SYMLINK
6815                {
6816                    let stat_same = stat_matches_blob_stat(ie, &stat, stat_policy);
6817                    if stat_same
6818                        && worktree_mode == ie.mode
6819                        && index_entry_skips_worktree_content_hash_after_stat_match(
6820                            ie,
6821                            index_mtime,
6822                            stat_policy,
6823                        )
6824                    {
6825                        if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(index, entry_index) {
6826                            fsmonitor_valid_updated = true;
6827                        }
6828                        return Ok(());
6829                    }
6830                    if stat_same && worktree_mode != ie.mode {
6831                        result.push(worktree_content_change_entry(
6832                            ie,
6833                            path_str_ref,
6834                            worktree_mode,
6835                            ie.oid,
6836                        ));
6837                        return Ok(());
6838                    }
6839                    if !icase_collisions.as_ref().is_some_and(|map| {
6840                        map.diff_requires_worktree_hash(&index.entries[entry_index].path)
6841                    }) && try_push_status_modified_on_size_mismatch(
6842                        odb,
6843                        ie,
6844                        path_str_ref,
6845                        stat.worktree_size(),
6846                        worktree_mode,
6847                        stat.raw_size,
6848                        size_change_proves_modified,
6849                        || conversion_is_identity(entry_index, path_str_ref),
6850                        &mut result,
6851                    )? {
6852                        return Ok(());
6853                    }
6854                    if refresh_content_verified[entry_index] {
6855                        if worktree_mode == ie.mode {
6856                            if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(
6857                                index,
6858                                entry_index,
6859                            ) {
6860                                fsmonitor_valid_updated = true;
6861                            }
6862                        } else {
6863                            result.push(worktree_content_change_entry(
6864                                ie,
6865                                path_str_ref,
6866                                worktree_mode,
6867                                ie.oid,
6868                            ));
6869                        }
6870                    } else {
6871                        staging_hash_jobs.push(StagingHashJob::new(
6872                            entry_index,
6873                            worktree_mode,
6874                            file_attrs_for(path_str_ref),
6875                        ));
6876                    }
6877                } else {
6878                    // The scan's lstat already proves an unchanged, non-racy entry clean, and the
6879                    // stat refresh above may have just verified a racy entry by content; only
6880                    // re-stat paths that `push_index_blob_worktree_diff` has to inspect.
6881                    let same_file_type = (worktree_mode == crate::index::MODE_SYMLINK)
6882                        == (ie.mode == crate::index::MODE_SYMLINK);
6883                    if !stat.is_dir
6884                        && worktree_mode == ie.mode
6885                        && ((refresh_content_verified[entry_index] && same_file_type)
6886                            || (stat_matches_blob_stat(ie, &stat, stat_policy)
6887                                && index_entry_skips_worktree_content_hash_after_stat_match(
6888                                    ie,
6889                                    index_mtime,
6890                                    stat_policy,
6891                                )))
6892                    {
6893                        if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(index, entry_index) {
6894                            fsmonitor_valid_updated = true;
6895                        }
6896                        return Ok(());
6897                    }
6898                    let file_path = index_entry_worktree_abs_bytes(
6899                        work_tree,
6900                        &index.entries[entry_index].path,
6901                        precompose_unicode,
6902                        ignorecase,
6903                    );
6904                    let Some(meta) = symlink_metadata_worktree_diff(
6905                        &file_path,
6906                        path_str_ref,
6907                        options.diagnostics.as_ref(),
6908                        options.for_status,
6909                        options.skip_inaccessible_paths,
6910                        materialize_dirty_blobs,
6911                    )?
6912                    else {
6913                        return Ok(());
6914                    };
6915                    push_index_blob_worktree_diff(
6916                        odb,
6917                        ie,
6918                        path_str_ref,
6919                        &file_path,
6920                        meta,
6921                        index_mtime,
6922                        mode_policy,
6923                        stat_policy,
6924                        &file_attrs_for,
6925                        &conv,
6926                        materialize_dirty_blobs,
6927                        hash_blobs_without_write,
6928                        options.for_status,
6929                        size_change_proves_modified,
6930                        options.skip_inaccessible_paths,
6931                        options.diagnostics.as_ref(),
6932                        filter_fp,
6933                        icase_collisions.as_ref(),
6934                        &mut result,
6935                    )?;
6936                }
6937            }
6938        }
6939        Ok(())
6940    })?;
6941    if parallel_blob_work && !staging_hash_jobs.is_empty() {
6942        parallel_process_blob_worktree_jobs(
6943            odb,
6944            &StagingHashSource {
6945                index,
6946                work_tree,
6947                precompose_unicode,
6948                ignorecase,
6949            },
6950            &staging_hash_jobs,
6951            &thread_policy,
6952            &config,
6953            materialize_dirty_blobs,
6954            hash_blobs_without_write,
6955            options.for_status,
6956            options.skip_inaccessible_paths,
6957            options.diagnostics.as_ref(),
6958            &conv,
6959            filter_fp,
6960            icase_collisions.as_ref(),
6961            &mut result,
6962        )?;
6963    }
6964
6965    for (path, (_, base_entry)) in unmerged_base {
6966        let file_path = index_entry_worktree_abs(work_tree, &path, precompose_unicode, ignorecase);
6967        let wt_meta = symlink_metadata_worktree_diff(
6968            &file_path,
6969            &path,
6970            options.diagnostics.as_ref(),
6971            options.for_status,
6972            options.skip_inaccessible_paths,
6973            materialize_dirty_blobs,
6974        )?;
6975
6976        let new_mode = wt_meta.as_ref().map_or_else(
6977            || FileMode::ABSENT,
6978            |meta| FileMode::new(mode_from_metadata(meta)),
6979        );
6980        result.push(DiffEntry {
6981            status: DiffStatus::Unmerged,
6982            old_path: Some(path.clone()),
6983            new_path: Some(path.clone()),
6984            old_mode: FileMode::ABSENT,
6985            new_mode,
6986            old_oid: zero_oid(),
6987            new_oid: zero_oid(),
6988            score: None,
6989        });
6990
6991        if !options.for_status {
6992            if let Some(meta) = wt_meta {
6993                let file_attrs = file_attrs_for(&path);
6994                let wt_oid = worktree_file_oid(
6995                    odb,
6996                    &file_path,
6997                    &meta,
6998                    &conv,
6999                    &file_attrs,
7000                    &path,
7001                    Some(&base_entry),
7002                    materialize_dirty_blobs,
7003                    filter_fp,
7004                )?;
7005                let wt_mode = mode_from_metadata(&meta);
7006                if wt_oid != base_entry.oid || wt_mode != base_entry.mode {
7007                    result.push(DiffEntry {
7008                        status: DiffStatus::for_content_change(base_entry.mode, wt_mode),
7009                        old_path: Some(path.clone()),
7010                        new_path: Some(path),
7011                        old_mode: FileMode::new(base_entry.mode),
7012                        new_mode: FileMode::new(wt_mode),
7013                        old_oid: base_entry.oid,
7014                        new_oid: wt_oid,
7015                        score: None,
7016                    });
7017                }
7018            }
7019        }
7020    }
7021
7022    if fsmonitor_valid_updated {
7023        index.fsmonitor_validity_changed();
7024        index_stat_refresh_changed = true;
7025    }
7026
7027    Ok((result, index_stat_refresh_changed, staging_stat_cache))
7028}
7029
7030/// Per-worker cache answering "is any ancestor directory of this index path a symlink?".
7031///
7032/// It remembers the last chain of ancestors verified to be real directories, plus the first
7033/// ancestor found not to be one. Index paths arrive in sorted order, so consecutive queries
7034/// share most of their leading directories and only the differing components are probed.
7035/// Each scan worker owns its own cache: there is no locking and no per-directory allocation.
7036#[derive(Default)]
7037pub(crate) struct SymlinkDirCache {
7038    /// Leading directory (raw index bytes, no trailing `/`) whose every component is a real
7039    /// directory on disk.
7040    real_dirs: Vec<u8>,
7041    /// First ancestor found not to be a real directory, and whether it is a symlink. Every
7042    /// path under it shares that answer.
7043    blocked: Option<(Vec<u8>, bool)>,
7044    /// Single-walk check for directories several components below `real_dirs`.
7045    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
7046    chain: Option<crate::worktree_scan::RealDirChain>,
7047    /// Filesystem queries made so far.
7048    #[cfg(test)]
7049    fs_queries: usize,
7050}
7051
7052/// Whether `path` is `dir` itself or lies under it (`dir` has no trailing `/`).
7053fn path_within_dir(path: &[u8], dir: &[u8]) -> bool {
7054    path.starts_with(dir) && path.get(dir.len()).is_none_or(|&b| b == b'/')
7055}
7056
7057/// Length of the longest leading run of whole components shared by two directory paths.
7058fn common_dir_prefix_len(a: &[u8], b: &[u8]) -> usize {
7059    let same = a.iter().zip(b).take_while(|(x, y)| x == y).count();
7060    let ends_component = |p: &[u8]| p.get(same).is_none_or(|&c| c == b'/');
7061    if ends_component(a) && ends_component(b) {
7062        return same;
7063    }
7064    a[..same].iter().rposition(|&c| c == b'/').unwrap_or(0)
7065}
7066
7067pub(crate) fn index_entry_worktree_abs(
7068    work_tree: &Path,
7069    index_relpath: &str,
7070    precompose_unicode: bool,
7071    ignorecase: bool,
7072) -> PathBuf {
7073    index_entry_worktree_abs_bytes(
7074        work_tree,
7075        index_relpath.as_bytes(),
7076        precompose_unicode,
7077        ignorecase,
7078    )
7079}
7080
7081pub(crate) fn index_entry_worktree_abs_bytes(
7082    work_tree: &Path,
7083    index_relpath: &[u8],
7084    precompose_unicode: bool,
7085    ignorecase: bool,
7086) -> PathBuf {
7087    if precompose_unicode || ignorecase {
7088        if let Ok(s) = std::str::from_utf8(index_relpath) {
7089            if precompose_unicode {
7090                return crate::unicode_normalization::resolve_worktree_path_for_staging(
7091                    work_tree, s, true,
7092                )
7093                .abs;
7094            }
7095            return crate::path_icase::worktree_path_for_index_entry(work_tree, s, ignorecase);
7096        }
7097    }
7098    crate::index::worktree_path_from_index_rel(work_tree, index_relpath)
7099}
7100
7101/// Effective index-file mtime for racy-git checks.
7102///
7103/// Uses an explicit override when provided, otherwise [`Index::source_mtime`] from the last
7104/// on-disk read.
7105#[must_use]
7106pub fn index_mtime_for_diff(index: &Index, explicit: Option<(u32, u32)>) -> Option<(u32, u32)> {
7107    explicit.or(index.source_mtime)
7108}
7109
7110impl SymlinkDirCache {
7111    /// Whether any parent directory of `rel_path` (raw index path bytes) is a symlink.
7112    ///
7113    /// Assumes the worktree does not change during the scan this cache serves. A missing
7114    /// ancestor, or one that is a file, is not a symlink and hides everything below it.
7115    pub(crate) fn has_symlink_in_path_bytes(
7116        &mut self,
7117        work_tree: &Path,
7118        rel_path: &[u8],
7119        precompose_unicode: bool,
7120        ignorecase: bool,
7121    ) -> bool {
7122        use crate::worktree_scan::{ancestor_kind, AncestorKind};
7123
7124        let Some(dir_len) = rel_path.iter().rposition(|&b| b == b'/') else {
7125            return false;
7126        };
7127        let dir = &rel_path[..dir_len];
7128        if let Some((blocked, is_symlink)) = &self.blocked {
7129            if path_within_dir(dir, blocked) {
7130                return *is_symlink;
7131            }
7132        }
7133        let verified = common_dir_prefix_len(dir, &self.real_dirs);
7134        self.real_dirs.truncate(verified);
7135        #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
7136        if !precompose_unicode
7137            && !ignorecase
7138            && dir[verified..]
7139                .split(|&b| b == b'/')
7140                .filter(|component| !component.is_empty())
7141                .nth(1)
7142                .is_some()
7143        {
7144            let chain = match &mut self.chain {
7145                Some(chain) if chain.work_tree() == work_tree => chain,
7146                slot => slot.insert(crate::worktree_scan::RealDirChain::open(work_tree)),
7147            };
7148            #[cfg(test)]
7149            {
7150                self.fs_queries += 1;
7151            }
7152            if chain.confirms(dir) {
7153                self.real_dirs.clear();
7154                self.real_dirs.extend_from_slice(dir);
7155                return false;
7156            }
7157        }
7158        let mut start = verified;
7159        while start < dir.len() {
7160            if dir[start] == b'/' {
7161                start += 1;
7162                continue;
7163            }
7164            let end = dir[start..]
7165                .iter()
7166                .position(|&b| b == b'/')
7167                .map_or(dir.len(), |i| start + i);
7168            let prefix = &dir[..end];
7169            let abs =
7170                index_entry_worktree_abs_bytes(work_tree, prefix, precompose_unicode, ignorecase);
7171            #[cfg(test)]
7172            {
7173                self.fs_queries += 1;
7174            }
7175            match ancestor_kind(&abs) {
7176                AncestorKind::Directory => {
7177                    let known = self.real_dirs.len();
7178                    self.real_dirs.extend_from_slice(&prefix[known..]);
7179                }
7180                AncestorKind::Symlink => {
7181                    self.blocked = Some((prefix.to_vec(), true));
7182                    return true;
7183                }
7184                AncestorKind::Other => {
7185                    self.blocked = Some((prefix.to_vec(), false));
7186                    return false;
7187                }
7188            }
7189            start = end;
7190        }
7191        false
7192    }
7193}
7194
7195/// Whether an index entry's cached mtime is at or after the on-disk index file's mtime.
7196///
7197/// Git labels such entries racy: stat data can look clean even though the worktree changed
7198/// within the same second the index was written. Callers pass the index file mtime sampled when
7199/// the index was read (before any rewrite). When `index_mtime` is `None`, returns `false`.
7200#[must_use]
7201pub(crate) fn entry_is_racy(ie: &IndexEntry, index_mtime: Option<(u32, u32)>) -> bool {
7202    entry_is_racy_with_policy(ie, index_mtime, crate::index::StatMatchPolicy::default())
7203}
7204
7205/// Like [`entry_is_racy`], honoring `core.usenanosec` for sub-second racy detection.
7206///
7207/// Sub-second racy detection applies only when stat equality also compares the fractions
7208/// (`core.usenanosec` with `core.checkstat=default`); otherwise a same-second, same-size edit
7209/// would be neither racy nor a stat mismatch.
7210#[must_use]
7211pub(crate) fn entry_is_racy_with_policy(
7212    ie: &IndexEntry,
7213    index_mtime: Option<(u32, u32)>,
7214    policy: crate::index::StatMatchPolicy,
7215) -> bool {
7216    let Some((index_mtime_sec, index_mtime_nsec)) = index_mtime else {
7217        return false;
7218    };
7219    if index_mtime_sec == 0 {
7220        return false;
7221    }
7222    if policy.use_nanosec && policy.check_stat {
7223        index_mtime_sec < ie.mtime_sec
7224            || (index_mtime_sec == ie.mtime_sec && index_mtime_nsec <= ie.mtime_nsec)
7225    } else {
7226        index_mtime_sec <= ie.mtime_sec
7227    }
7228}
7229
7230/// Clear racily-clean cache lines on entries whose blob no longer matches the worktree.
7231///
7232/// Before Git writes the index, it walks stage-0 file and symlink entries and sets the cached
7233/// size to zero when stat data still matches the worktree but content does not match the
7234/// recorded OID and the entry timestamp is racy relative to `index_mtime`. That forces the
7235/// next diff or status pass to re-hash the path instead of trusting stat alone.
7236///
7237/// Skip-worktree, assume-unchanged, and intent-to-add entries are left unchanged, and so are
7238/// entries this process just hashed from the worktree to their current OID (Git skips its
7239/// `CE_UPTODATE` entries the same way). When `index_mtime` is `None` (no prior on-disk index),
7240/// nothing is smudged.
7241///
7242/// Content equality uses `git_dir` config and worktree attributes with [`hash_worktree_file`]
7243/// so the repository hash algorithm and clean (CRLF/filter) conversion match index OIDs.
7244///
7245/// Returns `true` when at least one entry was smudged.
7246pub(crate) fn smudge_racily_clean_entries(
7247    odb: &Odb,
7248    git_dir: &Path,
7249    index: &mut Index,
7250    work_tree: &Path,
7251    index_mtime: Option<(u32, u32)>,
7252    config: Option<&ConfigSet>,
7253    filter_process: Option<&crate::filter_process::FilterProcessState>,
7254) -> bool {
7255    use crate::crlf;
7256    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
7257
7258    let Some(index_mtime) = index_mtime else {
7259        return false;
7260    };
7261
7262    let config = config.cloned().unwrap_or_else(|| {
7263        ConfigSet::load(
7264            &crate::environment::Environment::empty(),
7265            Some(git_dir),
7266            true,
7267        )
7268        .unwrap_or_default()
7269    });
7270    let conv = crlf::ConversionConfig::from_config(&config);
7271    let attrs = crlf::load_gitattributes(work_tree);
7272    let verified = std::mem::take(&mut index.content_verified);
7273
7274    let mut changed = false;
7275    for ie in &mut index.entries {
7276        let marked_verified = ie.take_content_verified();
7277        if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
7278            continue;
7279        }
7280        if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
7281            continue;
7282        }
7283        if ie.size == 0 {
7284            continue;
7285        }
7286        if !entry_is_racy(ie, Some(index_mtime)) {
7287            continue;
7288        }
7289        if marked_verified || verified.get(&ie.path) == Some(&ie.oid) {
7290            continue;
7291        }
7292        let Ok(path) = std::str::from_utf8(&ie.path) else {
7293            continue;
7294        };
7295        let abs = work_tree.join(path);
7296        let Ok(meta) = fs::symlink_metadata(&abs) else {
7297            continue;
7298        };
7299        if !stat_matches(ie, &meta) {
7300            continue;
7301        }
7302        let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
7303        let content_matches = hash_worktree_file(
7304            odb,
7305            &abs,
7306            &meta,
7307            &conv,
7308            &file_attrs,
7309            path,
7310            Some(ie),
7311            filter_process,
7312        )
7313        .map(|oid| oid == ie.oid)
7314        .unwrap_or(false);
7315        if content_matches {
7316            continue;
7317        }
7318        ie.size = 0;
7319        changed = true;
7320    }
7321    changed
7322}
7323/// Quick stat check: does the index entry's cached stat data match the file?
7324/// Returns true when the file at `ie`'s path differs from the index entry (mode or blob).
7325///
7326/// Used by commands such as `git mv` to detect "dirty" paths under sparse checkout.
7327/// Symlinks and submodules are compared in a Git-compatible way.
7328///
7329/// `ignore_submodule_untracked` mirrors [`diff_index_to_worktree`]'s same flag for gitlinks.
7330#[allow(dead_code)]
7331fn worktree_differs_from_index_entry(
7332    odb: &Odb,
7333    repository_git_dir: &Path,
7334    work_tree: &Path,
7335    ie: &IndexEntry,
7336    ignore_submodule_untracked: bool,
7337    config: Option<&ConfigSet>,
7338    filter_process: Option<&crate::filter_process::FilterProcessState>,
7339) -> Result<bool> {
7340    WorktreeEntryComparer::new(repository_git_dir, work_tree, config).differs(
7341        odb,
7342        ie,
7343        ignore_submodule_untracked,
7344        filter_process,
7345    )
7346}
7347
7348/// Repeated [`worktree_differs_from_index_entry`] checks against one worktree.
7349///
7350/// Loads config-derived settings and the root `.gitattributes` once instead of per entry.
7351pub(crate) struct WorktreeEntryComparer<'a> {
7352    work_tree: &'a Path,
7353    config: ConfigSet,
7354    mode_policy: crate::index::WorktreeModePolicy,
7355    precompose_unicode: bool,
7356    ignorecase: bool,
7357    conv: std::cell::OnceCell<crate::crlf::ConversionConfig>,
7358    attrs: std::cell::OnceCell<Vec<crate::crlf::AttrRule>>,
7359}
7360
7361impl<'a> WorktreeEntryComparer<'a> {
7362    /// Capture settings for `work_tree`; `config` falls back to the repository config files.
7363    pub fn new(repository_git_dir: &Path, work_tree: &'a Path, config: Option<&ConfigSet>) -> Self {
7364        let config = config.cloned().unwrap_or_else(|| {
7365            ConfigSet::load(
7366                &crate::environment::Environment::empty(),
7367                Some(repository_git_dir),
7368                true,
7369            )
7370            .unwrap_or_else(|_| ConfigSet::new())
7371        });
7372        let flag = |key: &str, default: bool| config.bool_or(key, default);
7373        Self {
7374            work_tree,
7375            mode_policy: crate::index::WorktreeModePolicy::from_config(&config),
7376            precompose_unicode: flag("core.precomposeunicode", false),
7377            ignorecase: flag("core.ignorecase", false),
7378            config,
7379            conv: std::cell::OnceCell::new(),
7380            attrs: std::cell::OnceCell::new(),
7381        }
7382    }
7383
7384    /// Same contract as [`worktree_differs_from_index_entry`].
7385    pub(crate) fn differs(
7386        &self,
7387        odb: &Odb,
7388        ie: &IndexEntry,
7389        ignore_submodule_untracked: bool,
7390        filter_process: Option<&crate::filter_process::FilterProcessState>,
7391    ) -> Result<bool> {
7392        use crate::crlf;
7393
7394        let work_tree = self.work_tree;
7395        let path_stored = crate::repo_relpath::storage_string(&ie.path);
7396        let path_str_ref = crate::repo_relpath::as_utf8_for_matching(&path_stored);
7397        let file_path = index_entry_worktree_abs_bytes(
7398            work_tree,
7399            &ie.path,
7400            self.precompose_unicode,
7401            self.ignorecase,
7402        );
7403
7404        if ie.mode == 0o160000 {
7405            let sub_head_oid = read_submodule_head(&file_path);
7406            let ref_matches = match sub_head_oid {
7407                Some(oid) => oid == ie.oid,
7408                None => uninitialized_gitlink_worktree_matches_index(&file_path),
7409            };
7410            let mut flags = submodule_porcelain_flags(work_tree, path_str_ref.as_ref(), ie.oid);
7411            if ignore_submodule_untracked {
7412                flags.untracked = false;
7413            }
7414            return Ok(!ref_matches || flags.modified || flags.untracked);
7415        }
7416
7417        let meta = match fs::symlink_metadata(&file_path) {
7418            Ok(m) => m,
7419            Err(e)
7420                if e.kind() == std::io::ErrorKind::NotFound
7421                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
7422            {
7423                return Ok(true);
7424            }
7425            Err(e) => return Err(Error::Io(e)),
7426        };
7427
7428        if meta.is_dir() {
7429            return Ok(true);
7430        }
7431
7432        if !self
7433            .mode_policy
7434            .worktree_mode_matches(mode_from_metadata(&meta), ie.mode)
7435        {
7436            return Ok(true);
7437        }
7438
7439        let conv = self
7440            .conv
7441            .get_or_init(|| crlf::ConversionConfig::from_config(&self.config));
7442        let attrs = self
7443            .attrs
7444            .get_or_init(|| crlf::load_gitattributes(work_tree));
7445        let file_attrs = crlf::get_file_attrs(attrs, path_str_ref.as_ref(), false, &self.config);
7446        let worktree_oid = hash_worktree_file(
7447            odb,
7448            &file_path,
7449            &meta,
7450            conv,
7451            &file_attrs,
7452            path_str_ref.as_ref(),
7453            Some(ie),
7454            filter_process,
7455        )?;
7456
7457        let mut eff_oid = worktree_oid;
7458        if eff_oid != ie.oid {
7459            if let Ok(raw) = fs::read(&file_path) {
7460                let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
7461                if raw_oid == ie.oid {
7462                    eff_oid = ie.oid;
7463                }
7464            }
7465        }
7466
7467        Ok(eff_oid != ie.oid)
7468    }
7469}
7470
7471/// Inputs for [`classify_worktree_entry_for_add`].
7472pub struct WorktreeAddRefreshParams<'a> {
7473    /// Object database used to verify blob OIDs when stat data is inconclusive.
7474    pub odb: &'a Odb,
7475    /// Index entry being compared.
7476    pub ie: &'a IndexEntry,
7477    /// `lstat` metadata for the worktree path.
7478    pub meta: &'a fs::Metadata,
7479    /// Absolute worktree path to the file.
7480    pub abs_path: &'a Path,
7481    /// Repository-relative path string.
7482    pub rel_path: &'a str,
7483    /// CRLF / filter conversion settings.
7484    pub conv: &'a crate::crlf::ConversionConfig,
7485    /// Per-path attribute state.
7486    pub file_attrs: &'a crate::crlf::FileAttrs,
7487    /// On-disk index mtime when the index was loaded; `None` skips racy checks.
7488    pub index_mtime: Option<(u32, u32)>,
7489    /// Mode that would be written, after [`crate::index::WorktreeModePolicy`] against `ie.mode`
7490    /// and any `--chmod` hint.
7491    pub staged_mode: u32,
7492    /// Repository-scoped filter-process registry (when staging through a [`crate::repo::Repository`]).
7493    pub filter_process: Option<&'a crate::filter_process::FilterProcessState>,
7494}
7495
7496/// Outcome of comparing a tracked index entry to its worktree path for staging refresh.
7497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7498pub enum WorktreeAddRefresh {
7499    /// Index already reflects the worktree; caller may skip staging.
7500    UpToDate,
7501    /// Executable bit differs while blob content is unchanged.
7502    ModeOnly {
7503        /// Mode to store in the index.
7504        mode: u32,
7505    },
7506    /// Blob OID and mode are unchanged; only cached stat fields should be refreshed.
7507    StatOnly,
7508    /// Content, type, or mode/blob mismatch requires a full restage (read, convert, hash).
7509    NeedsRestage,
7510}
7511
7512/// Whether stat-only fast paths may skip re-hashing during staging.
7513#[must_use]
7514pub(crate) fn index_stat_cache_trustworthy(index_mtime: Option<(u32, u32)>) -> bool {
7515    match index_mtime {
7516        None => false,
7517        Some((0, _)) => false,
7518        Some(_) => true,
7519    }
7520}
7521
7522/// When true, after worktree metadata has been read and matches the index entry, blob content
7523/// hashing may be skipped (Git `diff-files` / `update-index --refresh` style).
7524///
7525/// Does not permit skipping worktree observation; fsmonitor-valid entries use a separate path.
7526/// Independent of `core.filemode`: callers compare the executable bit separately.
7527///
7528/// A zero cached size is trusted only for the empty blob; any other blob with size zero was
7529/// smudged by a racy index write and must be re-hashed (Git `ce_match_stat_basic`).
7530#[must_use]
7531pub(crate) fn index_entry_skips_worktree_content_hash_after_stat_match(
7532    ie: &IndexEntry,
7533    index_mtime: Option<(u32, u32)>,
7534    stat_policy: crate::index::StatMatchPolicy,
7535) -> bool {
7536    if ie.stat_cache_unset() || (ie.size == 0 && !ie.oid.is_empty_blob()) {
7537        return false;
7538    }
7539    if !index_stat_cache_trustworthy(index_mtime) {
7540        return false;
7541    }
7542    !entry_is_racy_with_policy(ie, index_mtime, stat_policy)
7543}
7544
7545/// Decide whether staging must re-read and re-hash a tracked path.
7546///
7547/// Mirrors the fast paths in [`diff_index_to_worktree`] and Git's `refresh_index` /
7548/// `add_file_to_index`: trust matching stat when the entry is not racy and
7549/// `index_stat_cache_trustworthy` allows it; re-hash when stat matches but the entry is racy
7550/// or when stat differs; apply CRLF-aware hashing like staging.
7551///
7552/// Returns [`WorktreeAddRefresh::UpToDate`] when the index entry already matches the worktree and
7553/// no restaging is required; other variants describe mode/stat-only updates or a full restage.
7554///
7555/// # Errors
7556///
7557/// Returns [`Error::Io`] when the worktree file cannot be read for content verification.
7558pub(crate) fn classify_worktree_entry_for_add(
7559    params: &WorktreeAddRefreshParams<'_>,
7560) -> Result<WorktreeAddRefresh> {
7561    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
7562
7563    let ie = params.ie;
7564    let meta = params.meta;
7565    let staged_mode = params.staged_mode;
7566
7567    if ie.intent_to_add() {
7568        return Ok(WorktreeAddRefresh::NeedsRestage);
7569    }
7570
7571    let racy = entry_is_racy(ie, params.index_mtime);
7572    let stat_same = stat_matches(ie, meta);
7573    let trust_stat = index_stat_cache_trustworthy(params.index_mtime);
7574
7575    if ie.mode == MODE_SYMLINK {
7576        // With `core.symlinks=false` the policy keeps a plain file holding the link target as
7577        // a symlink entry, so it is compared by content like a real link.
7578        if !meta.file_type().is_symlink() && staged_mode != MODE_SYMLINK {
7579            return Ok(WorktreeAddRefresh::NeedsRestage);
7580        }
7581        if stat_same && !racy && trust_stat && staged_mode == ie.mode {
7582            return Ok(WorktreeAddRefresh::UpToDate);
7583        }
7584        if stat_same && !racy && trust_stat && staged_mode != ie.mode {
7585            return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
7586        }
7587        return worktree_effective_oid_matches_index(params);
7588    }
7589
7590    if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE {
7591        return Ok(WorktreeAddRefresh::NeedsRestage);
7592    }
7593
7594    if meta.file_type().is_symlink() || meta.is_dir() {
7595        return Ok(WorktreeAddRefresh::NeedsRestage);
7596    }
7597
7598    if stat_same && !racy && trust_stat {
7599        if staged_mode == ie.mode {
7600            return Ok(WorktreeAddRefresh::UpToDate);
7601        }
7602        return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
7603    }
7604
7605    worktree_effective_oid_matches_index(params)
7606}
7607
7608fn worktree_effective_oid_matches_index(
7609    params: &WorktreeAddRefreshParams<'_>,
7610) -> Result<WorktreeAddRefresh> {
7611    let WorktreeAddRefreshParams {
7612        odb,
7613        ie,
7614        meta,
7615        abs_path,
7616        rel_path,
7617        conv,
7618        file_attrs,
7619        staged_mode,
7620        ..
7621    } = params;
7622    let worktree_oid = hash_worktree_file(
7623        odb,
7624        abs_path,
7625        meta,
7626        conv,
7627        file_attrs,
7628        rel_path,
7629        Some(ie),
7630        params.filter_process,
7631    )?;
7632    let mut eff_oid = worktree_oid;
7633    if eff_oid != ie.oid {
7634        if let Ok(raw) = fs::read(abs_path) {
7635            let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
7636            if raw_oid == ie.oid {
7637                eff_oid = ie.oid;
7638            }
7639        }
7640    }
7641    if eff_oid != ie.oid {
7642        return Ok(WorktreeAddRefresh::NeedsRestage);
7643    }
7644    if *staged_mode != ie.mode {
7645        return Ok(WorktreeAddRefresh::ModeOnly { mode: *staged_mode });
7646    }
7647    if stat_matches(ie, meta) {
7648        Ok(WorktreeAddRefresh::UpToDate)
7649    } else {
7650        Ok(WorktreeAddRefresh::StatOnly)
7651    }
7652}
7653
7654/// Whether worktree metadata matches cached index stat data (Git `ie_match_stat` / `match_stat_data`).
7655#[must_use]
7656pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
7657    stat_matches_with_policy(ie, meta, crate::index::StatMatchPolicy::default())
7658}
7659
7660/// Whether compact worktree stat from a directory scan matches the index row.
7661pub(crate) fn stat_matches_blob_stat(
7662    ie: &IndexEntry,
7663    stat: &crate::worktree_scan::BlobDiskStat,
7664    policy: crate::index::StatMatchPolicy,
7665) -> bool {
7666    stat.fields.matches_index_entry(ie, policy)
7667}
7668
7669/// Like [`stat_matches`], honoring `core.trustctime`, `core.checkstat`, and `core.usenanosec`.
7670#[must_use]
7671pub(crate) fn stat_matches_with_policy(
7672    ie: &IndexEntry,
7673    meta: &fs::Metadata,
7674    policy: crate::index::StatMatchPolicy,
7675) -> bool {
7676    if crate::index::munge_index_size(meta.len()) != ie.size {
7677        return false;
7678    }
7679    #[cfg(unix)]
7680    {
7681        use std::os::unix::fs::MetadataExt;
7682        if meta.mtime() as u32 != ie.mtime_sec {
7683            return false;
7684        }
7685        if policy.use_nanosec && policy.check_stat && meta.mtime_nsec() as u32 != ie.mtime_nsec {
7686            return false;
7687        }
7688        if policy.trust_ctime && policy.check_stat {
7689            if meta.ctime() as u32 != ie.ctime_sec {
7690                return false;
7691            }
7692            if policy.use_nanosec && meta.ctime_nsec() as u32 != ie.ctime_nsec {
7693                return false;
7694            }
7695        }
7696        if policy.check_stat && meta.ino() as u32 != ie.ino {
7697            return false;
7698        }
7699    }
7700    #[cfg(not(unix))]
7701    {
7702        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = crate::index::metadata_times(meta);
7703        if mtime_sec != ie.mtime_sec {
7704            return false;
7705        }
7706        if policy.use_nanosec && policy.check_stat && mtime_nsec != ie.mtime_nsec {
7707            return false;
7708        }
7709        if policy.trust_ctime && policy.check_stat {
7710            if ctime_sec != ie.ctime_sec {
7711                return false;
7712            }
7713            if policy.use_nanosec && ctime_nsec != ie.ctime_nsec {
7714                return false;
7715            }
7716        }
7717    }
7718    true
7719}
7720
7721/// Refresh cached stat data for stage-0 file/symlink entries whose worktree content still matches
7722/// the recorded OID but whose on-disk stat went stale.
7723///
7724/// This mirrors Git's `refresh_index` / `refresh_cache_ent`: an entry is only marked clean (stat
7725/// adopted from the worktree) after its content is re-verified against the index OID. A genuinely
7726/// modified entry keeps its stale stat so `diff-files` / `status` continue to report it. Operations
7727/// that rewrite the worktree (`status`, `reset --mixed`, `stash`) call this before writing the
7728/// index so a subsequent `git diff-files` sees refreshed entries as clean.
7729///
7730/// Gitlinks, sparse (`skip_worktree`), `assume_unchanged` and intent-to-add entries are skipped.
7731/// The blob comparison is a raw-content hash, so a CRLF-smudged match is conservatively missed
7732/// (the entry simply stays stat-dirty and is re-hashed next time — never the reverse).
7733///
7734/// `index_mtime` is the on-disk index file's `(mtime_sec, mtime_nsec)` (see
7735/// `entry_is_racy` / Git `is_racy_timestamp`); pass `None` when unknown — racy detection is
7736/// then skipped, which is conservative for tree-built indexes whose zeroed stat never matches.
7737///
7738/// `stat_parallel_threads` overrides parallel stat workers (`Some(1)` forces serial); when `None`,
7739/// honors `core.preloadIndex` and `PARALLEL_STAT_MIN_ENTRIES`.
7740///
7741/// Returns `Ok(true)` when at least one entry was refreshed or invalidated, so callers can write
7742/// the index opportunistically (Git only persists a refresh that changed something).
7743pub fn refresh_index_stat_content_verified(
7744    odb: &Odb,
7745    git_dir: &Path,
7746    index: &mut Index,
7747    work_tree: &Path,
7748    index_mtime: Option<(u32, u32)>,
7749    config: Option<&ConfigSet>,
7750    _stat_parallel_threads: Option<usize>,
7751) -> Result<bool> {
7752    let _ = _stat_parallel_threads;
7753    refresh_index_stat_content_verified_with_rules(
7754        odb,
7755        git_dir,
7756        index,
7757        work_tree,
7758        index_mtime,
7759        config,
7760        _stat_parallel_threads,
7761        None,
7762    )
7763}
7764
7765/// Like [`refresh_index_stat_content_verified`], resolving attributes and conversion through a
7766/// shared [`crate::worktree_rules::WorktreeRules`] when one is supplied (its config snapshot then
7767/// takes precedence over `config`).
7768///
7769/// # Errors
7770///
7771/// Propagates I/O errors from the directory-grouped worktree scan.
7772#[allow(clippy::too_many_arguments)]
7773pub(crate) fn refresh_index_stat_content_verified_with_rules(
7774    odb: &Odb,
7775    git_dir: &Path,
7776    index: &mut Index,
7777    work_tree: &Path,
7778    index_mtime: Option<(u32, u32)>,
7779    config: Option<&ConfigSet>,
7780    stat_parallel_threads: Option<usize>,
7781    worktree_rules: Option<&std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
7782) -> Result<bool> {
7783    use crate::config::ConfigSet;
7784    use crate::crlf;
7785    use crate::worktree_scan::{
7786        collect_blob_lookup_batches, group_blob_entries_by_dir, visit_blob_lookup_batches,
7787        BlobDiskLookup, BlobScanSparseMode, WorktreeBlobScanOptions,
7788    };
7789
7790    let index_mtime = index_mtime_for_diff(index, index_mtime);
7791    let rules_lock = worktree_rules
7792        .map(|shared| {
7793            shared
7794                .lock()
7795                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
7796        })
7797        .transpose()?;
7798    let config = rules_lock
7799        .as_ref()
7800        .map(|r| r.config().clone())
7801        .unwrap_or_else(|| {
7802            config.cloned().unwrap_or_else(|| {
7803                ConfigSet::load(
7804                    &crate::environment::Environment::empty(),
7805                    Some(git_dir),
7806                    true,
7807                )
7808                .unwrap_or_default()
7809            })
7810        });
7811    let preload_index = config.preload_index();
7812    let blob_scan = WorktreeBlobScanOptions {
7813        preload_index,
7814        stat_parallel_threads,
7815        ..WorktreeBlobScanOptions::default()
7816    };
7817    let conv = rules_lock
7818        .as_ref()
7819        .map(|r| r.conversion().clone())
7820        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
7821    let legacy_attrs = if rules_lock.is_none() {
7822        Some(crlf::load_gitattributes_parsed(work_tree))
7823    } else {
7824        None
7825    };
7826    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
7827        if let Some(rules) = rules_lock.as_ref() {
7828            rules.file_attrs(path, false)
7829        } else if let Some(parsed) = legacy_attrs.as_ref() {
7830            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
7831        } else {
7832            crlf::FileAttrs::default()
7833        }
7834    };
7835    let precompose_unicode = config.precompose_unicode();
7836    let ignorecase = config.ignore_case();
7837    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
7838
7839    let blob_dirs = group_blob_entries_by_dir(index, false);
7840    let work_tree_owned = work_tree.to_path_buf();
7841    let dir_abs = move |dir: &[u8]| {
7842        if dir.is_empty() {
7843            work_tree_owned.clone()
7844        } else {
7845            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
7846        }
7847    };
7848    let work_tree_for_files = work_tree.to_path_buf();
7849    let file_abs = move |rel: &[u8]| {
7850        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
7851    };
7852    let mut changed = false;
7853    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
7854    let thread_policy = crate::parallel::ThreadPolicy::default();
7855    // Symlink ancestor detection must run even when core.symlinks is false.
7856    let refresh_batches = collect_blob_lookup_batches(
7857        index,
7858        &blob_dirs,
7859        blob_scan,
7860        &thread_policy,
7861        &config,
7862        dir_abs,
7863        file_abs,
7864        || {
7865            let mut dir_symlinks = SymlinkDirCache::default();
7866            move |rel_path: &[u8]| {
7867                dir_symlinks.has_symlink_in_path_bytes(
7868                    work_tree,
7869                    rel_path,
7870                    precompose_unicode,
7871                    ignorecase,
7872                )
7873            }
7874        },
7875        BlobScanSparseMode::StatRefresh {
7876            stat_policy,
7877            index_mtime,
7878        },
7879    )?;
7880    let par_opts = refresh_hash_parallel_options(&thread_policy, &config, stat_parallel_threads);
7881    let refresh_checks = refresh_content_checks(
7882        odb,
7883        index,
7884        &refresh_batches,
7885        index_mtime,
7886        stat_policy,
7887        &|_, _, _| true,
7888        &WorktreeHashContext {
7889            work_tree,
7890            precompose_unicode,
7891            ignorecase,
7892            conv: &conv,
7893            file_attrs_for: &file_attrs_for,
7894            filter_process: filter_fp,
7895        },
7896        par_opts,
7897        None,
7898    )?;
7899    visit_blob_lookup_batches(refresh_batches, |entry_index, lookup| {
7900        let BlobDiskLookup::Present(stat) = lookup else {
7901            return Ok(());
7902        };
7903        let content_matches = RefreshContent::matches(
7904            refresh_checks.get(entry_index),
7905            &index.entries[entry_index].oid,
7906        );
7907        let refresh = refresh_index_blob_stat_from_scan(
7908            &mut index.entries[entry_index],
7909            &stat,
7910            index_mtime,
7911            stat_policy,
7912            content_matches,
7913        );
7914        if refresh.index_changed {
7915            changed = true;
7916        }
7917        if refresh.content_verified {
7918            let path = index.entries[entry_index].path.clone();
7919            let oid = index.entries[entry_index].oid;
7920            index.mark_content_verified(&path, oid);
7921        }
7922        Ok(())
7923    })?;
7924    Ok(changed)
7925}
7926
7927/// Result of refreshing one index entry's cached stat during a worktree scan.
7928struct BlobStatRefreshOutcome {
7929    /// Whether the in-memory index entry was updated (stat fields or invalidation).
7930    index_changed: bool,
7931    /// Blob content was read and matches the recorded OID (skip a second hash in the same pass).
7932    content_verified: bool,
7933}
7934
7935/// Whether the stat refresh may adopt worktree stat data for `ie` at all.
7936fn refresh_index_entry_eligible(ie: &IndexEntry) -> bool {
7937    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
7938    ie.stage() == 0
7939        && !ie.skip_worktree()
7940        && !ie.assume_unchanged()
7941        && !ie.intent_to_add()
7942        && matches!(ie.mode, MODE_REGULAR | MODE_EXECUTABLE | MODE_SYMLINK)
7943}
7944
7945/// Whether refreshing `ie` from the scanned `stat` must first prove by content that the
7946/// worktree still holds `ie.oid`: the stat data changed, or it matches but is racy.
7947fn refresh_needs_content_check(
7948    ie: &IndexEntry,
7949    stat: &crate::worktree_scan::BlobDiskStat,
7950    index_mtime: Option<(u32, u32)>,
7951    stat_policy: crate::index::StatMatchPolicy,
7952) -> bool {
7953    refresh_index_entry_eligible(ie)
7954        && !stat.is_dir
7955        && (!stat_matches_blob_stat(ie, stat, stat_policy)
7956            || entry_is_racy_with_policy(ie, index_mtime, stat_policy))
7957}
7958
7959/// Whether a worktree diff with `refresh_index_stat_in_pass` refreshes `ie` from `stat`.
7960///
7961/// Status leaves out entries whose stat data already decides the answer: clean and not racy,
7962/// or a changed size that [`status_size_change_proves_modified`] accepts (reported as modified
7963/// without reading the file). A size change under a non-identity clean conversion is refreshed:
7964/// when the cleaned content still matches, the entry adopts the new stat, so later scans trust it.
7965pub(crate) fn worktree_diff_refreshes_entry(
7966    ie: &IndexEntry,
7967    stat: &crate::worktree_scan::BlobDiskStat,
7968    index_mtime: Option<(u32, u32)>,
7969    stat_policy: crate::index::StatMatchPolicy,
7970    for_status: bool,
7971    conversion_is_identity: impl FnOnce() -> bool,
7972) -> bool {
7973    if !for_status {
7974        return true;
7975    }
7976    if stat_matches_blob_stat(ie, stat, stat_policy) {
7977        return entry_is_racy_with_policy(ie, index_mtime, stat_policy);
7978    }
7979    !status_size_change_proves_modified(
7980        ie,
7981        stat.worktree_size(),
7982        for_status,
7983        conversion_is_identity,
7984    )
7985}
7986
7987/// Pool options for the stat refresh's content checks.
7988///
7989/// `thread_policy` and config pick the worker count; `stat_parallel_threads` caps it the same
7990/// way it caps the stat scan (`Some(0)` or `Some(1)` keeps hashing on the calling thread).
7991/// There is no byte threshold: opening each file, not hashing its bytes, dominates for the
7992/// small files a checkout leaves racy.
7993fn refresh_hash_parallel_options(
7994    thread_policy: &crate::parallel::ThreadPolicy,
7995    config: &ConfigSet,
7996    stat_parallel_threads: Option<usize>,
7997) -> crate::parallel::ParallelOptions {
7998    let policy = crate::parallel::ThreadPolicy {
7999        min_items_override: thread_policy.min_items_override.or(Some(0)),
8000        min_total_bytes_override: thread_policy.min_total_bytes_override.or(Some(0)),
8001        ..*thread_policy
8002    };
8003    let mut par_opts = policy.resolve(crate::parallel::ParallelOp::IndexRefresh, config);
8004    if let Some(cap) = stat_parallel_threads {
8005        par_opts.threads = par_opts
8006            .threads
8007            .min(std::num::NonZeroUsize::new(cap).unwrap_or(std::num::NonZeroUsize::MIN));
8008    }
8009    par_opts.min_items = 0;
8010    par_opts.min_total_bytes = None;
8011    par_opts
8012}
8013
8014/// Worktree layout and attribute context for hashing index entries during a scan.
8015struct WorktreeHashContext<'a> {
8016    work_tree: &'a Path,
8017    precompose_unicode: bool,
8018    ignorecase: bool,
8019    conv: &'a crate::crlf::ConversionConfig,
8020    file_attrs_for: &'a dyn Fn(&str) -> crate::crlf::FileAttrs,
8021    filter_process: Option<&'a crate::filter_process::FilterProcessState>,
8022}
8023
8024/// Run every content check the stat refresh needs before the serial visit of `batches`.
8025///
8026/// Racily clean entries are the common case right after a checkout writes the index: every
8027/// file then shares the index's timestamp second. Hashing them here, in parallel when
8028/// `par_opts` and the total size allow it, keeps that first scan from reading files one by one.
8029///
8030/// Returns a check for each scanned entry that `include` (given the entry index) admits and
8031/// [`refresh_needs_content_check`] selects. Read and filter failures are
8032/// [`RefreshContent::Failed`], so those entries stay unverified.
8033#[allow(clippy::too_many_arguments)]
8034fn refresh_content_checks(
8035    odb: &Odb,
8036    index: &Index,
8037    batches: &[Vec<(usize, crate::worktree_scan::BlobDiskLookup)>],
8038    index_mtime: Option<(u32, u32)>,
8039    stat_policy: crate::index::StatMatchPolicy,
8040    include: &dyn Fn(usize, &IndexEntry, &crate::worktree_scan::BlobDiskStat) -> bool,
8041    ctx: &WorktreeHashContext<'_>,
8042    par_opts: crate::parallel::ParallelOptions,
8043    cancel_watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
8044) -> Result<RefreshChecks> {
8045    use crate::progress::{poll_watch, CANCEL_POLL_STRIDE};
8046    use crate::worktree_scan::BlobDiskLookup;
8047
8048    /// Attributes are resolved on the calling thread: the shared rules are not `Sync`.
8049    struct ContentCheck {
8050        entry_index: usize,
8051        worktree_mode: u32,
8052        /// Position of the entry's attributes in `attrs`.
8053        attrs_index: usize,
8054    }
8055
8056    let mut attrs = DistinctFileAttrs::default();
8057    let mut jobs = Vec::new();
8058    let mut total_bytes = 0usize;
8059    let mut cancel_stride = 0usize;
8060    for (entry_index, lookup) in batches.iter().flatten() {
8061        cancel_stride = cancel_stride.wrapping_add(1);
8062        if cancel_stride.is_multiple_of(CANCEL_POLL_STRIDE) {
8063            if let Some(w) = cancel_watch {
8064                poll_watch(w)?;
8065            }
8066        }
8067        let BlobDiskLookup::Present(stat) = lookup else {
8068            continue;
8069        };
8070        let ie = &index.entries[*entry_index];
8071        if !refresh_needs_content_check(ie, stat, index_mtime, stat_policy)
8072            || !include(*entry_index, ie, stat)
8073        {
8074            continue;
8075        }
8076        total_bytes = total_bytes.saturating_add(ie.size as usize);
8077        let file_attrs = (ctx.file_attrs_for)(&crate::repo_relpath::storage_string(&ie.path));
8078        jobs.push(ContentCheck {
8079            entry_index: *entry_index,
8080            worktree_mode: stat.git_mode,
8081            attrs_index: attrs.intern(file_attrs),
8082        });
8083    }
8084    if jobs.is_empty() {
8085        return Ok(RefreshChecks::default());
8086    }
8087    let attrs = attrs.0;
8088    let mut checks = vec![None; index.entries.len()];
8089    let (work_tree, precompose_unicode, ignorecase) =
8090        (ctx.work_tree, ctx.precompose_unicode, ctx.ignorecase);
8091    let (conv, filter_process) = (ctx.conv, ctx.filter_process);
8092    let entries = index.entries.as_slice();
8093    let watch_for_hash = cancel_watch.cloned();
8094    let hash_one = |job: &ContentCheck| -> Result<RefreshContent> {
8095        if job.entry_index.is_multiple_of(CANCEL_POLL_STRIDE) {
8096            if let Some(w) = watch_for_hash.as_ref() {
8097                poll_watch(w)?;
8098            }
8099        }
8100        let ie = &entries[job.entry_index];
8101        let file_path =
8102            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
8103        Ok(RefreshContent::from(hash_worktree_file_with_mode(
8104            odb,
8105            &file_path,
8106            job.worktree_mode,
8107            conv,
8108            &attrs[job.attrs_index],
8109            &crate::repo_relpath::storage_string(&ie.path),
8110            Some(ie),
8111            filter_process,
8112        )))
8113    };
8114    let matches = if par_opts.threads.get() > 1 && jobs.len() > 1 {
8115        crate::parallel::try_par_map_force(&jobs, par_opts, hash_one).map_err(|e| match e {
8116            crate::parallel::ParallelHashError::Task(err) => err,
8117        })?
8118    } else {
8119        let mut out = Vec::with_capacity(jobs.len());
8120        let mut serial_stride = 0usize;
8121        for job in &jobs {
8122            serial_stride = serial_stride.wrapping_add(1);
8123            if serial_stride.is_multiple_of(CANCEL_POLL_STRIDE) {
8124                if let Some(w) = cancel_watch {
8125                    poll_watch(w)?;
8126                }
8127            }
8128            out.push(hash_one(job)?);
8129        }
8130        out
8131    };
8132    for (job, content) in jobs.iter().zip(matches) {
8133        checks[job.entry_index] = Some(content);
8134    }
8135    Ok(RefreshChecks(checks))
8136}
8137
8138/// The distinct attribute sets of the entries a scan hashes, so each entry stores an index
8139/// instead of its own [`FileAttrs`](crate::crlf::FileAttrs) (a few hundred bytes).
8140///
8141/// Nearly every path of a repository resolves to the same few sets. Lookups compare against
8142/// at most [`Self::SEARCH_LIMIT`] sets, the most recent first, so a tree with many distinct
8143/// sets stores some twice instead of scanning a long list for every entry.
8144#[derive(Default)]
8145struct DistinctFileAttrs(Vec<crate::crlf::FileAttrs>);
8146
8147impl DistinctFileAttrs {
8148    const SEARCH_LIMIT: usize = 16;
8149
8150    /// Index of `attrs` in the list, appending it when no recent set is equal.
8151    fn intern(&mut self, attrs: crate::crlf::FileAttrs) -> usize {
8152        let len = self.0.len();
8153        let found = self
8154            .0
8155            .iter()
8156            .rev()
8157            .take(Self::SEARCH_LIMIT)
8158            .position(|seen| *seen == attrs);
8159        if let Some(back) = found {
8160            return len - 1 - back;
8161        }
8162        self.0.push(attrs);
8163        len
8164    }
8165}
8166
8167/// Results of [`refresh_content_checks`], by index entry position.
8168///
8169/// Empty when no entry needed a check (a clean, non-racy work tree), so the common case
8170/// allocates nothing.
8171#[derive(Default)]
8172struct RefreshChecks(Vec<Option<RefreshContent>>);
8173
8174impl RefreshChecks {
8175    /// The check that ran for the entry at `entry_index`, if any.
8176    fn get(&self, entry_index: usize) -> Option<RefreshContent> {
8177        self.0.get(entry_index).copied().flatten()
8178    }
8179}
8180
8181/// What the stat refresh learned by hashing one entry's worktree file.
8182#[derive(Clone, Copy)]
8183enum RefreshContent {
8184    /// The file could not be read or clean-converted.
8185    Failed,
8186    /// The cleaned file hashes to this blob id.
8187    Hashed(ObjectId),
8188}
8189
8190impl From<Result<ObjectId>> for RefreshContent {
8191    fn from(hashed: Result<ObjectId>) -> Self {
8192        hashed.map_or(Self::Failed, Self::Hashed)
8193    }
8194}
8195
8196impl RefreshContent {
8197    /// Whether `check` proved the worktree still holds `oid`.
8198    fn matches(check: Option<Self>, oid: &ObjectId) -> bool {
8199        matches!(check, Some(Self::Hashed(hashed)) if hashed == *oid)
8200    }
8201
8202    /// Whether `check` proved the worktree holds other content than `oid`.
8203    fn differs(check: Option<Self>, oid: &ObjectId) -> bool {
8204        matches!(check, Some(Self::Hashed(hashed)) if hashed != *oid)
8205    }
8206}
8207
8208/// Adopt the scanned `stat` for `ie` where that cannot hide a worktree change.
8209///
8210/// `content_matches` is the [`refresh_content_checks`] result for this entry; it is only
8211/// consulted when [`refresh_needs_content_check`] holds. A racy entry whose content no longer
8212/// matches has its stat cache invalidated so later scans re-hash it.
8213fn refresh_index_blob_stat_from_scan(
8214    ie: &mut IndexEntry,
8215    stat: &crate::worktree_scan::BlobDiskStat,
8216    index_mtime: Option<(u32, u32)>,
8217    stat_policy: crate::index::StatMatchPolicy,
8218    content_matches: bool,
8219) -> BlobStatRefreshOutcome {
8220    const UNCHANGED: BlobStatRefreshOutcome = BlobStatRefreshOutcome {
8221        index_changed: false,
8222        content_verified: false,
8223    };
8224    if !refresh_index_entry_eligible(ie) || stat.is_dir {
8225        return UNCHANGED;
8226    }
8227    let cached = crate::index::IndexStatFields::of_entry(ie);
8228    let stat_same = cached.equivalent_to(&stat.fields, stat_policy);
8229    let stat_matches = stat_matches_blob_stat(ie, stat, stat_policy);
8230    if !stat_matches || entry_is_racy_with_policy(ie, index_mtime, stat_policy) {
8231        if !content_matches {
8232            if stat_matches {
8233                invalidate_index_stat_cache(ie);
8234                return BlobStatRefreshOutcome {
8235                    index_changed: true,
8236                    content_verified: false,
8237                };
8238            }
8239            return UNCHANGED;
8240        }
8241        if !stat_same {
8242            stat.fields.store_in(ie);
8243        }
8244        return BlobStatRefreshOutcome {
8245            index_changed: !stat_same,
8246            content_verified: true,
8247        };
8248    }
8249    if stat_same {
8250        return BlobStatRefreshOutcome {
8251            index_changed: false,
8252            content_verified: true,
8253        };
8254    }
8255    stat.fields.store_in(ie);
8256    BlobStatRefreshOutcome {
8257        index_changed: true,
8258        content_verified: false,
8259    }
8260}
8261
8262/// Whether path-only checkout can skip writing a blob because the worktree is up to date.
8263///
8264/// Matching stat data is trusted unless the entry is racy relative to the on-disk index mtime;
8265/// then the worktree content (after clean/filter conversion) must hash to `expected_oid`
8266/// ([`hash_worktree_file`]). `config` is an optional preloaded configuration.
8267///
8268/// Returns `Ok(true)` when the caller should skip rewriting the path.
8269///
8270/// # Errors
8271///
8272/// Propagates I/O or hashing failures from [`hash_worktree_file`].
8273#[allow(clippy::too_many_arguments)]
8274pub(crate) fn path_checkout_skip_blob_write_when_up_to_date_with_config(
8275    odb: &Odb,
8276    git_dir: &Path,
8277    work_tree: &Path,
8278    index: &Index,
8279    entry: &IndexEntry,
8280    expected_oid: &ObjectId,
8281    expected_mode: u32,
8282    rel_path: &str,
8283    filter_process: Option<&crate::filter_process::FilterProcessState>,
8284    preloaded_config: Option<&crate::config::ConfigSet>,
8285) -> Result<bool> {
8286    use crate::config::ConfigSet;
8287    use crate::crlf;
8288    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR};
8289
8290    if entry.oid != *expected_oid || entry.mode != expected_mode {
8291        return Ok(false);
8292    }
8293    if expected_mode != MODE_REGULAR && expected_mode != MODE_EXECUTABLE {
8294        return Ok(false);
8295    }
8296    let abs_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
8297    let meta = match fs::symlink_metadata(&abs_path) {
8298        Ok(m) => m,
8299        Err(_) => return Ok(false),
8300    };
8301    if !meta.is_file() || !stat_matches(entry, &meta) {
8302        return Ok(false);
8303    }
8304    let owned_config;
8305    let config = match preloaded_config {
8306        Some(cfg) => cfg,
8307        None => {
8308            owned_config = ConfigSet::load(
8309                &crate::environment::Environment::empty(),
8310                Some(git_dir),
8311                true,
8312            )
8313            .unwrap_or_default();
8314            &owned_config
8315        }
8316    };
8317    if !crate::index::WorktreeModePolicy::from_config(config)
8318        .worktree_mode_matches(mode_from_metadata(&meta), expected_mode)
8319    {
8320        return Ok(false);
8321    }
8322    let index_mtime = index_mtime_for_diff(index, None);
8323    if !entry_is_racy(entry, index_mtime) {
8324        return Ok(true);
8325    }
8326    let conv = crlf::ConversionConfig::from_config(config);
8327    let attrs = crlf::load_gitattributes(work_tree);
8328    let file_attrs = crlf::get_file_attrs(&attrs, rel_path, false, config);
8329    let wt_oid = hash_worktree_file(
8330        odb,
8331        &abs_path,
8332        &meta,
8333        &conv,
8334        &file_attrs,
8335        rel_path,
8336        Some(entry),
8337        filter_process,
8338    )?;
8339    Ok(wt_oid == *expected_oid)
8340}
8341
8342/// Clear cached stat fields so the next diff/status pass re-reads the work tree.
8343fn invalidate_index_stat_cache(ie: &mut IndexEntry) {
8344    ie.ctime_sec = 0;
8345    ie.ctime_nsec = 0;
8346    ie.mtime_sec = 0;
8347    ie.mtime_nsec = 0;
8348    ie.dev = 0;
8349    ie.ino = 0;
8350    ie.size = 0;
8351}
8352
8353/// Read worktree content for diffing, honoring Git tree modes.
8354///
8355/// For [`MODE_SYMLINK`] entries backed by a symlink on disk this returns the link target path
8356/// bytes (via [`read_link`](std::fs::read_link)), not the contents of the followed file. A
8357/// symlink entry checked out as a plain file (`core.symlinks=false`) already holds the target,
8358/// so it is read like other modes, with a plain [`read`](std::fs::read) of the path.
8359///
8360/// # Errors
8361///
8362/// Returns [`std::io::Error`] when the path cannot be read.
8363pub(crate) fn read_worktree_bytes_for_mode(path: &Path, mode: u32) -> std::io::Result<Vec<u8>> {
8364    if mode == MODE_SYMLINK && fs::symlink_metadata(path)?.file_type().is_symlink() {
8365        let target = fs::read_link(path)?;
8366        return Ok(symlink_target_bytes(&target));
8367    }
8368    fs::read(path)
8369}
8370
8371/// Read worktree file bytes in Git (repository) form for line diffs against index or tree blobs.
8372///
8373/// Applies the same `.gitattributes`, filter, and line-ending rules as
8374/// [`diff_index_to_worktree`]. Pass the stage-0 [`IndexEntry`] when available so
8375/// `autocrlf` index-guard normalization matches status and raw diff collection.
8376///
8377/// # Errors
8378///
8379/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
8380pub fn read_worktree_side_bytes_for_diff(
8381    odb: &Odb,
8382    work_tree: &Path,
8383    rel_path: &str,
8384    mode: u32,
8385    config: &crate::config::ConfigSet,
8386    index_entry: Option<&IndexEntry>,
8387    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8388) -> Result<Vec<u8>> {
8389    Ok(load_worktree_side_for_diff(
8390        odb,
8391        work_tree,
8392        rel_path,
8393        mode,
8394        config,
8395        index_entry,
8396        worktree_rules,
8397        false,
8398    )?
8399    .0
8400    .data)
8401}
8402
8403/// Load worktree bytes for diffing, optionally skipping reads above `core.bigFileThreshold`.
8404///
8405/// Returns empty [`super::structured::SideBytes`] with `binary = true` when the file is not read.
8406/// When `force_read_content` is true, large files are read and binary payload bytes are retained
8407/// (for `GIT binary patch` / `git apply`).
8408///
8409/// # Errors
8410///
8411/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
8412#[allow(clippy::too_many_arguments)]
8413pub(crate) fn load_worktree_side_for_diff(
8414    odb: &Odb,
8415    work_tree: &Path,
8416    rel_path: &str,
8417    mode: u32,
8418    config: &crate::config::ConfigSet,
8419    index_entry: Option<&IndexEntry>,
8420    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8421    force_read_content: bool,
8422) -> Result<(super::structured::SideBytes, bool)> {
8423    use super::tree_content::{side_bytes_after_blob_read, treat_side_as_binary_without_reading};
8424    let file_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
8425    let rel_match = crate::repo_relpath::as_utf8_for_matching(rel_path);
8426    let meta = fs::symlink_metadata(&file_path).map_err(|source| {
8427        crate::worktree::map_worktree_io(
8428            rel_match.as_ref(),
8429            crate::worktree::WorktreePathOperation::Metadata,
8430            source,
8431        )
8432    })?;
8433    if mode == MODE_SYMLINK && meta.file_type().is_symlink() {
8434        let target = fs::read_link(&file_path).map_err(|source| {
8435            crate::worktree::map_worktree_io(
8436                rel_match.as_ref(),
8437                crate::worktree::WorktreePathOperation::Read,
8438                source,
8439            )
8440        })?;
8441        return Ok((
8442            super::structured::SideBytes::from_bytes(symlink_target_bytes(&target)),
8443            false,
8444        ));
8445    }
8446    if !force_read_content
8447        && treat_side_as_binary_without_reading(odb, meta.len(), Some(rel_match.as_ref()), false)?
8448    {
8449        return Ok((
8450            super::structured::SideBytes::empty_with_size(meta.len()),
8451            true,
8452        ));
8453    }
8454    let data = if let Some(rules) = worktree_rules {
8455        worktree_blob_bytes(
8456            odb,
8457            &file_path,
8458            &meta,
8459            rules.conversion(),
8460            &rules.file_attrs(rel_match.as_ref(), meta.is_dir()),
8461            rel_match.as_ref(),
8462            index_entry,
8463            Some(rules.filter_process()),
8464        )?
8465    } else {
8466        let conv = crate::crlf::ConversionConfig::from_config(config);
8467        let attrs = crate::crlf::load_gitattributes(work_tree);
8468        let file_attrs =
8469            crate::crlf::get_file_attrs(&attrs, rel_match.as_ref(), meta.is_dir(), config);
8470        worktree_blob_bytes(
8471            odb,
8472            &file_path,
8473            &meta,
8474            &conv,
8475            &file_attrs,
8476            rel_match.as_ref(),
8477            index_entry,
8478            None,
8479        )?
8480    };
8481    Ok(side_bytes_after_blob_read(data, force_read_content))
8482}
8483
8484/// Symlink target as the byte string Git hashes for the blob OID.
8485///
8486/// On Unix the raw `OsStr` bytes are used verbatim. On Windows `OsStr` is WTF-8
8487/// and has no stable byte view, so the lossy UTF-8 form is used (symlink targets
8488/// in Git trees are UTF-8 in practice).
8489pub(crate) fn symlink_target_bytes(target: &Path) -> Vec<u8> {
8490    #[cfg(unix)]
8491    {
8492        use std::os::unix::ffi::OsStrExt as _;
8493        target.as_os_str().as_bytes().to_vec()
8494    }
8495    #[cfg(not(unix))]
8496    {
8497        target.as_os_str().to_string_lossy().as_bytes().to_vec()
8498    }
8499}
8500
8501/// A worktree file's blob content: normalized bytes, or an open file that is hashed or
8502/// stored as a stream.
8503///
8504/// Regular files of at least the caller's size threshold whose bytes can be stored without clean
8505/// conversion ([`crate::crlf::worktree_allows_raw_byte_stream`], including binary files under
8506/// `core.autocrlf`) are streamed so memory use does not depend on the file size.
8507pub(crate) enum WorktreeBlobContent {
8508    /// Content after the clean conversion (filters, encoding, line endings).
8509    Bytes(Vec<u8>),
8510    /// Unconverted big file, positioned at its start.
8511    Stream {
8512        /// Open handle on the worktree file.
8513        file: fs::File,
8514        /// File size when it was opened.
8515        size: u64,
8516    },
8517}
8518
8519impl WorktreeBlobContent {
8520    /// Object id of the content, without storing it.
8521    pub fn hash(self, odb: &Odb) -> Result<ObjectId> {
8522        match self {
8523            Self::Bytes(data) => odb.hash(ObjectKind::Blob, &data),
8524            Self::Stream { mut file, size } => odb.hash_stream(ObjectKind::Blob, size, &mut file),
8525        }
8526    }
8527
8528    /// Store the content as a blob and return its id.
8529    pub fn store(self, odb: &Odb, options: crate::odb::WriteOptions) -> Result<ObjectId> {
8530        match self {
8531            Self::Bytes(data) => odb.write_with_options(ObjectKind::Blob, &data, options),
8532            Self::Stream { mut file, size } => {
8533                odb.write_stream(ObjectKind::Blob, size, &mut file, options)
8534            }
8535        }
8536    }
8537
8538    /// [`Self::store`] with `options` when given, otherwise [`Self::hash`].
8539    fn object_id(self, odb: &Odb, store: Option<crate::odb::WriteOptions>) -> Result<ObjectId> {
8540        match store {
8541            Some(options) => self.store(odb, options),
8542            None => self.hash(odb),
8543        }
8544    }
8545}
8546
8547/// Read and clean-convert an open regular worktree file of `size` bytes.
8548#[allow(clippy::too_many_arguments)]
8549fn read_regular_worktree_bytes(
8550    odb: &Odb,
8551    mut file: fs::File,
8552    size: u64,
8553    conv: &crate::crlf::ConversionConfig,
8554    file_attrs: &crate::crlf::FileAttrs,
8555    rel_path: &str,
8556    index_entry: Option<&IndexEntry>,
8557    filter_process: Option<&crate::filter_process::FilterProcessState>,
8558) -> Result<Vec<u8>> {
8559    let needs_index_blob =
8560        index_entry.is_some_and(|_| crate::crlf::clean_uses_autocrlf_index_guard(file_attrs, conv));
8561    let prior_blob: Option<Vec<u8>> = if needs_index_blob {
8562        index_entry
8563            .filter(|e| e.oid != zero_oid())
8564            .and_then(|e| odb.read(&e.oid).ok().map(|o| o.data))
8565    } else {
8566        None
8567    };
8568    let mut raw = Vec::with_capacity(usize::try_from(size).unwrap_or_default());
8569    std::io::Read::read_to_end(&mut file, &mut raw).map_err(|source| {
8570        crate::worktree::map_worktree_io(
8571            rel_path,
8572            crate::worktree::WorktreePathOperation::Read,
8573            source,
8574        )
8575    })?;
8576    #[cfg(test)]
8577    odb.hot_path_metrics().record_blob_content_read();
8578    let opts = crate::crlf::ConvertToGitOpts {
8579        index_blob: prior_blob.as_deref(),
8580        renormalize: false,
8581        check_safecrlf: false,
8582        filter_process,
8583        command_runner: None,
8584    };
8585    Ok(crate::crlf::convert_to_git_owned(
8586        raw, rel_path, conv, file_attrs, opts,
8587    )?)
8588}
8589
8590fn open_with_size(path: &Path, rel_path: &str) -> Result<(fs::File, u64)> {
8591    let file = fs::File::open(path).map_err(|source| {
8592        crate::worktree::map_worktree_io(
8593            rel_path,
8594            crate::worktree::WorktreePathOperation::Read,
8595            source,
8596        )
8597    })?;
8598    let size = file
8599        .metadata()
8600        .map_err(|source| {
8601            crate::worktree::map_worktree_io(
8602                rel_path,
8603                crate::worktree::WorktreePathOperation::Metadata,
8604                source,
8605            )
8606        })?
8607        .len();
8608    Ok((file, size))
8609}
8610
8611/// Open a regular worktree file and clean-convert it, or hand it out as a stream when it is
8612/// at least `stream_from` bytes and its clean conversion is the identity.
8613#[allow(clippy::too_many_arguments)]
8614fn read_regular_worktree_blob(
8615    odb: &Odb,
8616    path: &Path,
8617    conv: &crate::crlf::ConversionConfig,
8618    file_attrs: &crate::crlf::FileAttrs,
8619    rel_path: &str,
8620    index_entry: Option<&IndexEntry>,
8621    filter_process: Option<&crate::filter_process::FilterProcessState>,
8622    stream_from: u64,
8623) -> Result<WorktreeBlobContent> {
8624    let (mut file, size) = open_with_size(path, rel_path)?;
8625    if size >= stream_from
8626        && crate::crlf::worktree_allows_raw_byte_stream(
8627            conv,
8628            file_attrs,
8629            index_entry,
8630            &mut file,
8631            size,
8632        )?
8633    {
8634        #[cfg(test)]
8635        odb.hot_path_metrics().record_blob_content_read();
8636        return Ok(WorktreeBlobContent::Stream { file, size });
8637    }
8638    read_regular_worktree_bytes(
8639        odb,
8640        file,
8641        size,
8642        conv,
8643        file_attrs,
8644        rel_path,
8645        index_entry,
8646        filter_process,
8647    )
8648    .map(WorktreeBlobContent::Bytes)
8649}
8650
8651/// Blob content for a worktree path: symlink target, empty for a directory, or the
8652/// clean-converted (or streamed, see [`WorktreeBlobContent`]) file content.
8653#[allow(clippy::too_many_arguments)]
8654pub(crate) fn worktree_blob_content(
8655    odb: &Odb,
8656    path: &Path,
8657    meta: &fs::Metadata,
8658    conv: &crate::crlf::ConversionConfig,
8659    file_attrs: &crate::crlf::FileAttrs,
8660    rel_path: &str,
8661    index_entry: Option<&IndexEntry>,
8662    filter_process: Option<&crate::filter_process::FilterProcessState>,
8663) -> Result<WorktreeBlobContent> {
8664    worktree_blob_content_from(
8665        odb,
8666        path,
8667        meta,
8668        conv,
8669        file_attrs,
8670        rel_path,
8671        index_entry,
8672        filter_process,
8673        hash_stream_threshold(odb)?,
8674    )
8675}
8676
8677/// [`worktree_blob_content`], streaming unconverted regular files from `stream_from` bytes.
8678#[allow(clippy::too_many_arguments)]
8679fn worktree_blob_content_from(
8680    odb: &Odb,
8681    path: &Path,
8682    meta: &fs::Metadata,
8683    conv: &crate::crlf::ConversionConfig,
8684    file_attrs: &crate::crlf::FileAttrs,
8685    rel_path: &str,
8686    index_entry: Option<&IndexEntry>,
8687    filter_process: Option<&crate::filter_process::FilterProcessState>,
8688    stream_from: u64,
8689) -> Result<WorktreeBlobContent> {
8690    if meta.file_type().is_symlink() {
8691        let target = fs::read_link(path).map_err(|source| {
8692            crate::worktree::map_worktree_io(
8693                rel_path,
8694                crate::worktree::WorktreePathOperation::Read,
8695                source,
8696            )
8697        })?;
8698        return Ok(WorktreeBlobContent::Bytes(symlink_target_bytes(&target)));
8699    }
8700    if meta.is_dir() {
8701        return Ok(WorktreeBlobContent::Bytes(Vec::new()));
8702    }
8703    read_regular_worktree_blob(
8704        odb,
8705        path,
8706        conv,
8707        file_attrs,
8708        rel_path,
8709        index_entry,
8710        filter_process,
8711        stream_from,
8712    )
8713}
8714
8715/// Like [`worktree_blob_content`], always returning the bytes (for callers that need the
8716/// content itself, such as line diffs).
8717#[allow(clippy::too_many_arguments)]
8718pub(crate) fn worktree_blob_bytes(
8719    odb: &Odb,
8720    path: &Path,
8721    meta: &fs::Metadata,
8722    conv: &crate::crlf::ConversionConfig,
8723    file_attrs: &crate::crlf::FileAttrs,
8724    rel_path: &str,
8725    index_entry: Option<&IndexEntry>,
8726    filter_process: Option<&crate::filter_process::FilterProcessState>,
8727) -> Result<Vec<u8>> {
8728    if meta.file_type().is_symlink() {
8729        let target = fs::read_link(path).map_err(|source| {
8730            crate::worktree::map_worktree_io(
8731                rel_path,
8732                crate::worktree::WorktreePathOperation::Read,
8733                source,
8734            )
8735        })?;
8736        return Ok(symlink_target_bytes(&target));
8737    }
8738    if meta.is_dir() {
8739        return Ok(Vec::new());
8740    }
8741    let (file, size) = open_with_size(path, rel_path)?;
8742    read_regular_worktree_bytes(
8743        odb,
8744        file,
8745        size,
8746        conv,
8747        file_attrs,
8748        rel_path,
8749        index_entry,
8750        filter_process,
8751    )
8752}
8753
8754#[allow(clippy::too_many_arguments)]
8755fn worktree_file_oid(
8756    odb: &Odb,
8757    path: &Path,
8758    meta: &fs::Metadata,
8759    conv: &crate::crlf::ConversionConfig,
8760    file_attrs: &crate::crlf::FileAttrs,
8761    rel_path: &str,
8762    index_entry: Option<&IndexEntry>,
8763    materialize: bool,
8764    filter_process: Option<&crate::filter_process::FilterProcessState>,
8765) -> Result<ObjectId> {
8766    let stream_from = hash_stream_threshold(odb)?;
8767    worktree_blob_content_from(
8768        odb,
8769        path,
8770        meta,
8771        conv,
8772        file_attrs,
8773        rel_path,
8774        index_entry,
8775        filter_process,
8776        stream_from,
8777    )?
8778    .object_id(odb, materialize.then(|| odb.bulk_stage_write_options()))
8779}
8780
8781/// Hash normalized worktree bytes for a path (does not write to the object database).
8782///
8783/// Files that need no clean conversion are hashed as a stream, without reading them into
8784/// memory, from 1 MiB (or `core.bigFileThreshold` when that is smaller).
8785///
8786/// # Errors
8787///
8788/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
8789/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
8790/// stored or hashed unfiltered in that case.
8791#[allow(clippy::too_many_arguments)]
8792pub fn hash_worktree_file(
8793    odb: &Odb,
8794    path: &Path,
8795    meta: &fs::Metadata,
8796    conv: &crate::crlf::ConversionConfig,
8797    file_attrs: &crate::crlf::FileAttrs,
8798    rel_path: &str,
8799    index_entry: Option<&IndexEntry>,
8800    filter_process: Option<&crate::filter_process::FilterProcessState>,
8801) -> Result<ObjectId> {
8802    worktree_file_oid(
8803        odb,
8804        path,
8805        meta,
8806        conv,
8807        file_attrs,
8808        rel_path,
8809        index_entry,
8810        false,
8811        filter_process,
8812    )
8813}
8814
8815/// Work-tree files at least this large are hashed as a stream (when their content goes into
8816/// Git unchanged) even below `core.bigFileThreshold`, which only governs how blobs are stored.
8817const STREAMED_HASH_MIN_SIZE: u64 = 1024 * 1024;
8818
8819/// Size from which an unconverted work-tree file is hashed as a stream rather than read whole.
8820fn hash_stream_threshold(odb: &Odb) -> Result<u64> {
8821    Ok(odb.big_file_threshold()?.min(STREAMED_HASH_MIN_SIZE))
8822}
8823
8824/// Like [`hash_worktree_file`], for callers that already know the worktree file type from a
8825/// directory scan (`worktree_mode` is the Git mode derived from that stat), so no metadata
8826/// probe is needed.
8827#[allow(clippy::too_many_arguments)]
8828pub(crate) fn hash_worktree_file_with_mode(
8829    odb: &Odb,
8830    path: &Path,
8831    worktree_mode: u32,
8832    conv: &crate::crlf::ConversionConfig,
8833    file_attrs: &crate::crlf::FileAttrs,
8834    rel_path: &str,
8835    index_entry: Option<&IndexEntry>,
8836    filter_process: Option<&crate::filter_process::FilterProcessState>,
8837) -> Result<ObjectId> {
8838    if worktree_mode == MODE_SYMLINK {
8839        let data = read_worktree_bytes_for_mode(path, worktree_mode)?;
8840        return odb.hash(ObjectKind::Blob, &data);
8841    }
8842    read_regular_worktree_blob(
8843        odb,
8844        path,
8845        conv,
8846        file_attrs,
8847        rel_path,
8848        index_entry,
8849        filter_process,
8850        hash_stream_threshold(odb)?,
8851    )?
8852    .hash(odb)
8853}
8854
8855/// Read, normalize (CRLF/attributes), and store a worktree blob once.
8856///
8857/// # Errors
8858///
8859/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
8860/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
8861/// stored or hashed unfiltered in that case.
8862#[allow(clippy::too_many_arguments)]
8863pub(crate) fn materialize_worktree_blob(
8864    odb: &Odb,
8865    path: &Path,
8866    meta: &fs::Metadata,
8867    conv: &crate::crlf::ConversionConfig,
8868    file_attrs: &crate::crlf::FileAttrs,
8869    rel_path: &str,
8870    index_entry: Option<&IndexEntry>,
8871    filter_process: Option<&crate::filter_process::FilterProcessState>,
8872) -> Result<ObjectId> {
8873    worktree_file_oid(
8874        odb,
8875        path,
8876        meta,
8877        conv,
8878        file_attrs,
8879        rel_path,
8880        index_entry,
8881        true,
8882        filter_process,
8883    )
8884}
8885
8886/// Derive a Git file mode from filesystem metadata.
8887pub fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
8888    if meta.file_type().is_symlink() {
8889        0o120000
8890    } else {
8891        #[cfg(unix)]
8892        {
8893            if meta.mode() & 0o111 != 0 {
8894                return 0o100755;
8895            }
8896        }
8897        0o100644
8898    }
8899}
8900
8901/// Compare a tree against the working tree.
8902///
8903/// Shows changes from `tree_oid` to the current working directory state.
8904/// Files tracked in the index but not in the tree are shown as Added.
8905/// Files in the tree but missing from the working tree are shown as Deleted.
8906///
8907/// # Parameters
8908///
8909/// - `odb` — object database.
8910/// - `tree_oid` — the tree to compare against (`None` for empty tree).
8911/// - `work_tree` — path to the working tree root.
8912/// - `index` — current index (used to discover new tracked files not in tree).
8913///
8914/// # Errors
8915///
8916/// Returns errors from ODB reads or I/O.
8917pub fn diff_tree_to_worktree(
8918    odb: &Odb,
8919    tree_oid: Option<&ObjectId>,
8920    work_tree: &Path,
8921    index: &Index,
8922) -> Result<Vec<DiffEntry>> {
8923    diff_tree_to_worktree_with_git_dir(
8924        odb,
8925        tree_oid,
8926        work_tree,
8927        &work_tree.join(".git"),
8928        index,
8929        None,
8930    )
8931}
8932
8933/// Like [`diff_tree_to_worktree`], but loads config from `repository_git_dir` (linked worktrees / gitfiles).
8934///
8935/// Pass `config` when the caller already holds a repository config snapshot.
8936pub fn diff_tree_to_worktree_with_git_dir(
8937    odb: &Odb,
8938    tree_oid: Option<&ObjectId>,
8939    work_tree: &Path,
8940    repository_git_dir: &Path,
8941    index: &Index,
8942    config: Option<&ConfigSet>,
8943) -> Result<Vec<DiffEntry>> {
8944    diff_tree_to_worktree_with_git_dir_and_rules(
8945        odb,
8946        tree_oid,
8947        work_tree,
8948        repository_git_dir,
8949        index,
8950        config,
8951        None,
8952    )
8953}
8954
8955/// Like [`diff_tree_to_worktree_with_git_dir`], with optional shared [`crate::worktree_rules::WorktreeRules`].
8956pub fn diff_tree_to_worktree_with_git_dir_and_rules(
8957    odb: &Odb,
8958    tree_oid: Option<&ObjectId>,
8959    work_tree: &Path,
8960    repository_git_dir: &Path,
8961    index: &Index,
8962    config: Option<&ConfigSet>,
8963    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8964) -> Result<Vec<DiffEntry>> {
8965    diff_tree_to_worktree_with_git_dir_and_rules_extras(
8966        odb,
8967        tree_oid,
8968        work_tree,
8969        repository_git_dir,
8970        index,
8971        config,
8972        worktree_rules,
8973        DiffTreeToWorktreeExtras::default(),
8974    )
8975}
8976
8977/// Like [`diff_tree_to_worktree_with_git_dir_and_rules`], with worktree access policy for the slow path.
8978#[allow(clippy::too_many_arguments)]
8979pub fn diff_tree_to_worktree_with_git_dir_and_rules_extras(
8980    odb: &Odb,
8981    tree_oid: Option<&ObjectId>,
8982    work_tree: &Path,
8983    repository_git_dir: &Path,
8984    index: &Index,
8985    config: Option<&ConfigSet>,
8986    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8987    extras: DiffTreeToWorktreeExtras,
8988) -> Result<Vec<DiffEntry>> {
8989    use crate::config::ConfigSet;
8990    use crate::crlf;
8991
8992    if worktree_rules.is_none() {
8993        if let Some(tree_oid) = tree_oid {
8994            if index.cache_tree_root.as_ref() == Some(tree_oid)
8995                && crate::write_tree::cache_tree_fully_valid(odb, index.cache_tree.as_ref())
8996            {
8997                let mut idx = index.clone();
8998                let options = DiffIndexToWorktreeOptions {
8999                    repository_git_dir: Some(repository_git_dir.to_path_buf()),
9000                    refresh_index_stat_in_pass: false,
9001                    // Tree↔worktree raw lines use a null worktree OID when content differs; the
9002                    // status fast path (`for_status`) uses the same null OID for size-only drift
9003                    // without hashing (see `status_size_change_proves_modified`).
9004                    for_status: false,
9005                    config: config.cloned().map(std::sync::Arc::new),
9006                    ..DiffIndexToWorktreeOptions::default()
9007                };
9008                let (entries, _, _) = diff_index_to_worktree_inner(
9009                    odb,
9010                    &mut idx,
9011                    work_tree,
9012                    repository_git_dir,
9013                    options,
9014                    false,
9015                    None,
9016                )?;
9017                return Ok(entries);
9018            }
9019        }
9020    }
9021
9022    let config = worktree_rules
9023        .map(|r| r.config().clone())
9024        .unwrap_or_else(|| {
9025            config.cloned().unwrap_or_else(|| {
9026                ConfigSet::load(
9027                    &crate::environment::Environment::empty(),
9028                    Some(repository_git_dir),
9029                    true,
9030                )
9031                .unwrap_or_else(|_| ConfigSet::new())
9032            })
9033        });
9034    let conv = worktree_rules
9035        .map(|r| r.conversion().clone())
9036        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
9037    let legacy_attrs = if worktree_rules.is_none() {
9038        Some(crlf::load_gitattributes_parsed(work_tree))
9039    } else {
9040        None
9041    };
9042    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
9043        if let Some(rules) = worktree_rules {
9044            rules.file_attrs(path, false)
9045        } else if let Some(parsed) = legacy_attrs.as_ref() {
9046            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
9047        } else {
9048            crlf::FileAttrs::default()
9049        }
9050    };
9051    let filter_fp = worktree_rules.map(|r| r.filter_process());
9052
9053    let sparse_index = index.has_sparse_directory_placeholders();
9054    // Flatten the tree into a BTreeMap keyed by path
9055    let tree_flat = match tree_oid {
9056        Some(oid) if sparse_index => flatten_tree_with_index(odb, oid, "", Some(index))?,
9057        Some(oid) => flatten_tree(odb, oid, "")?,
9058        None => Vec::new(),
9059    };
9060    let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
9061        tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
9062
9063    // Build index lookup: path → &IndexEntry (stage 0 only)
9064    let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
9065        std::collections::BTreeMap::new();
9066    let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
9067    let mut stage0_paths: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
9068    for ie in &index.entries {
9069        if ie.stage() != 0 {
9070            continue;
9071        }
9072        let path = String::from_utf8_lossy(&ie.path).to_string();
9073        index_entries.insert(&ie.path, ie);
9074        index_paths.insert(path);
9075        stage0_paths.insert(ie.path.clone());
9076    }
9077
9078    // Paths with only unmerged stages (1–3) and no stage 0 — `git diff <rev>` must still list them
9079    // so combined `diff --cc` conflict hunks can be emitted (`t4108-apply-threeway`).
9080    let mut unmerged_only_paths: std::collections::BTreeSet<String> =
9081        std::collections::BTreeSet::new();
9082    for ie in &index.entries {
9083        if !(1..=3).contains(&ie.stage()) {
9084            continue;
9085        }
9086        if stage0_paths.contains(&ie.path) {
9087            continue;
9088        }
9089        unmerged_only_paths.insert(String::from_utf8_lossy(&ie.path).into_owned());
9090    }
9091
9092    // Union of tree paths + index paths
9093    let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
9094    all_paths.extend(tree_map.keys().cloned());
9095    all_paths.extend(index_paths.iter().cloned());
9096    all_paths.extend(unmerged_only_paths.iter().cloned());
9097
9098    let index_mtime = index_mtime_for_diff(index, None);
9099    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
9100    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
9101    let mut result = Vec::new();
9102
9103    for path in &all_paths {
9104        if extras
9105            .restrict_to_paths
9106            .as_ref()
9107            .is_some_and(|limit| !limit.contains(path))
9108        {
9109            continue;
9110        }
9111        if index_entries
9112            .get(path.as_bytes())
9113            .is_some_and(|ie| ie.skip_worktree())
9114        {
9115            // Sparse checkout: `git diff <rev>` does not report tree↔worktree drift for
9116            // skip-worktree paths (they are outside the sparse cone). Matches t7012 stash flow.
9117            continue;
9118        }
9119        if index_entries
9120            .get(path.as_bytes())
9121            .is_some_and(|ie| ie.assume_unchanged())
9122        {
9123            continue;
9124        }
9125
9126        let tree_entry = tree_map.get(path.as_str());
9127
9128        // Gitlink entries (submodules) — compare HEAD commit, not file content.
9129        let is_gitlink = tree_entry.is_some_and(|te| te.mode == 0o160000)
9130            || index_entries
9131                .get(path.as_bytes())
9132                .is_some_and(|ie| ie.mode == 0o160000);
9133        if is_gitlink {
9134            let sub_dir = work_tree.join(path);
9135            let index_gitlink_oid = index_entries
9136                .get(path.as_bytes())
9137                .filter(|ie| ie.mode == 0o160000)
9138                .map(|ie| ie.oid);
9139            match (tree_entry, index_gitlink_oid) {
9140                (Some(te), _) => {
9141                    let sub_head = read_submodule_head_oid(&sub_dir);
9142                    // A gitlink whose worktree directory no longer exists is a deleted submodule:
9143                    // Git's `diff-lib.c` reports it as a deletion (status `D`, new mode 000000) and
9144                    // `--submodule` renders the `(submodule deleted)` summary (t4041 #46).
9145                    if sub_head.is_none() && !sub_dir.exists() {
9146                        result.push(DiffEntry {
9147                            status: DiffStatus::Deleted,
9148                            old_path: Some(path.clone()),
9149                            new_path: Some(path.clone()),
9150                            old_mode: FileMode::new(te.mode),
9151                            new_mode: FileMode::ABSENT,
9152                            old_oid: te.oid,
9153                            new_oid: zero_oid(),
9154                            score: None,
9155                        });
9156                        continue;
9157                    }
9158                    let ref_matches = if let Some(head) = sub_head {
9159                        head == te.oid
9160                    } else {
9161                        uninitialized_gitlink_worktree_matches_index(&sub_dir)
9162                    };
9163                    let index_matches_tree = index_gitlink_oid.is_some_and(|oid| oid == te.oid);
9164                    // Default tree↔worktree diff matches Git: untracked-only paths inside a
9165                    // checked-out submodule do not make the gitlink differ (see `git diff <tree>`).
9166                    let sub_flags = submodule_porcelain_flags(work_tree, path, te.oid);
9167                    let ignore = crate::submodule_ignore::submodule_ignore_for_path(
9168                        &config, work_tree, path,
9169                    );
9170                    if index_matches_tree
9171                        && crate::submodule_ignore::gitlink_worktree_change_suppressed(
9172                            ignore,
9173                            ref_matches,
9174                            sub_flags,
9175                        )
9176                    {
9177                        continue;
9178                    }
9179                    let dirty_while_aligned =
9180                        index_matches_tree && ref_matches && sub_flags.modified;
9181                    if !ref_matches || dirty_while_aligned {
9182                        // Raw `git diff <tree>` lines use a null OID on the worktree side when the
9183                        // checked-out submodule HEAD differs from the tree's gitlink; patch output
9184                        // still resolves the real commit from the submodule directory.
9185                        let new_oid = if !ref_matches {
9186                            sub_head.unwrap_or_else(zero_oid)
9187                        } else {
9188                            zero_oid()
9189                        };
9190                        result.push(DiffEntry {
9191                            status: DiffStatus::Modified,
9192                            old_path: Some(path.clone()),
9193                            new_path: Some(path.clone()),
9194                            old_mode: FileMode::new(te.mode),
9195                            new_mode: FileMode::new(te.mode),
9196                            old_oid: te.oid,
9197                            new_oid,
9198                            score: None,
9199                        });
9200                    }
9201                }
9202                (None, Some(idx_oid)) => {
9203                    // Gitlink staged in the index but absent from the tree: a new submodule. Git
9204                    // reports it as an addition (status `A`) with the index gitlink on the new side,
9205                    // so `--submodule` renders `(new submodule)` (t4041 #46). The patch renderer
9206                    // resolves the real commit from the index OID / submodule HEAD.
9207                    let new_oid = read_submodule_head_oid(&sub_dir).unwrap_or(idx_oid);
9208                    result.push(DiffEntry {
9209                        status: DiffStatus::Added,
9210                        old_path: Some(path.clone()),
9211                        new_path: Some(path.clone()),
9212                        old_mode: FileMode::ABSENT,
9213                        new_mode: FileMode::new(0o160000),
9214                        old_oid: zero_oid(),
9215                        new_oid,
9216                        score: None,
9217                    });
9218                }
9219                (None, None) => {}
9220            }
9221            continue;
9222        }
9223
9224        let file_path = work_tree.join(path);
9225
9226        let wt_meta = match probe_worktree_symlink_metadata(
9227            &file_path,
9228            path,
9229            extras.diagnostics.as_ref(),
9230            false,
9231            extras.skip_inaccessible_paths,
9232            false,
9233        )? {
9234            WorktreeSymlinkMetadata::Found(m) => Some(m),
9235            WorktreeSymlinkMetadata::Inaccessible => continue,
9236            WorktreeSymlinkMetadata::Absent => None,
9237        };
9238
9239        if unmerged_only_paths.contains(path) {
9240            if let (Some(te), Some(meta)) = (tree_entry, wt_meta.as_ref()) {
9241                let tree_mode = crate::index::canon_mode(te.mode);
9242                let file_attrs = file_attrs_for(path);
9243                let wt_oid = hash_worktree_file(
9244                    odb,
9245                    &file_path,
9246                    meta,
9247                    &conv,
9248                    &file_attrs,
9249                    path,
9250                    None,
9251                    filter_fp,
9252                )?;
9253                let wt_mode =
9254                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(tree_mode));
9255                if wt_oid != te.oid || wt_mode != tree_mode {
9256                    result.push(DiffEntry {
9257                        status: DiffStatus::for_content_change(tree_mode, wt_mode),
9258                        old_path: Some(path.clone()),
9259                        new_path: Some(path.clone()),
9260                        old_mode: FileMode::new(tree_mode),
9261                        new_mode: FileMode::new(wt_mode),
9262                        old_oid: te.oid,
9263                        new_oid: wt_oid,
9264                        score: None,
9265                    });
9266                }
9267            }
9268            continue;
9269        }
9270
9271        match (tree_entry, wt_meta) {
9272            (Some(te), Some(ref meta)) => {
9273                let tree_mode = crate::index::canon_mode(te.mode);
9274                let Some(ie) = index_entries.get(path.as_bytes()) else {
9275                    continue;
9276                };
9277                let wt_mode =
9278                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(ie.mode));
9279
9280                let index_matches_tree = ie.oid == te.oid && ie.mode == tree_mode;
9281
9282                // Fully clean: index matches `HEAD`, worktree matches index, stat cache fresh.
9283                let stat_trusted = stat_matches_with_policy(ie, meta, stat_policy)
9284                    && !entry_is_racy_with_policy(ie, index_mtime, stat_policy);
9285                if index_matches_tree && stat_trusted && wt_mode == tree_mode {
9286                    continue;
9287                }
9288
9289                let file_attrs = file_attrs_for(path);
9290                let idx_ent = index_entries.get(path.as_bytes()).copied();
9291
9292                // Staged mode (same blob as `HEAD`, different mode recorded in the index) with
9293                // the worktree still matching the index. `core.filemode=false` does not discard
9294                // an explicit index chmod vs HEAD. Once the worktree drifts from the index, the
9295                // diff must describe the worktree side, so fall through.
9296                if ie.oid == te.oid && ie.mode != tree_mode && stat_trusted && wt_mode == ie.mode {
9297                    result.push(DiffEntry {
9298                        status: DiffStatus::for_content_change(tree_mode, ie.mode),
9299                        old_path: Some(path.clone()),
9300                        new_path: Some(path.clone()),
9301                        old_mode: FileMode::new(tree_mode),
9302                        new_mode: FileMode::new(ie.mode),
9303                        old_oid: te.oid,
9304                        new_oid: te.oid,
9305                        score: None,
9306                    });
9307                    continue;
9308                }
9309
9310                // Index still matches `HEAD`: only unstaged worktree drift (content and/or
9311                // worktree-only exec bit when `update-index` was not run — t4049 harness).
9312                if index_matches_tree {
9313                    let wt_oid = hash_worktree_file(
9314                        odb,
9315                        &file_path,
9316                        meta,
9317                        &conv,
9318                        &file_attrs,
9319                        path,
9320                        None,
9321                        filter_fp,
9322                    )?;
9323                    if wt_oid != te.oid {
9324                        result.push(DiffEntry {
9325                            status: DiffStatus::for_content_change(tree_mode, wt_mode),
9326                            old_path: Some(path.clone()),
9327                            new_path: Some(path.clone()),
9328                            old_mode: FileMode::new(tree_mode),
9329                            new_mode: FileMode::new(wt_mode),
9330                            old_oid: te.oid,
9331                            // Raw `git diff <tree>` uses a null OID on the worktree side (patch
9332                            // output resolves content from the working tree).
9333                            new_oid: zero_oid(),
9334                            score: None,
9335                        });
9336                    } else if wt_mode != tree_mode {
9337                        result.push(DiffEntry {
9338                            status: DiffStatus::for_content_change(tree_mode, wt_mode),
9339                            old_path: Some(path.clone()),
9340                            new_path: Some(path.clone()),
9341                            old_mode: FileMode::new(tree_mode),
9342                            new_mode: FileMode::new(wt_mode),
9343                            old_oid: te.oid,
9344                            new_oid: te.oid,
9345                            score: None,
9346                        });
9347                    }
9348                    continue;
9349                }
9350
9351                // Staged content (and possibly mode): `git diff <rev>` is tree vs working tree.
9352                let wt_oid = hash_worktree_file(
9353                    odb,
9354                    &file_path,
9355                    meta,
9356                    &conv,
9357                    &file_attrs,
9358                    path,
9359                    idx_ent,
9360                    filter_fp,
9361                )?;
9362                if wt_oid != te.oid || wt_mode != tree_mode {
9363                    let new_oid = if wt_oid != te.oid { zero_oid() } else { te.oid };
9364                    result.push(DiffEntry {
9365                        status: DiffStatus::for_content_change(tree_mode, wt_mode),
9366                        old_path: Some(path.clone()),
9367                        new_path: Some(path.clone()),
9368                        old_mode: FileMode::new(tree_mode),
9369                        new_mode: FileMode::new(wt_mode),
9370                        old_oid: te.oid,
9371                        new_oid,
9372                        score: None,
9373                    });
9374                }
9375            }
9376            (Some(te), None) => {
9377                let tree_mode = crate::index::canon_mode(te.mode);
9378                // In tree but missing from worktree
9379                result.push(DiffEntry {
9380                    status: DiffStatus::Deleted,
9381                    old_path: Some(path.clone()),
9382                    new_path: None,
9383                    old_mode: FileMode::new(tree_mode),
9384                    new_mode: FileMode::ABSENT,
9385                    old_oid: te.oid,
9386                    new_oid: zero_oid(),
9387                    score: None,
9388                });
9389            }
9390            (None, Some(ref meta)) => {
9391                // In index but not in tree, and exists in worktree
9392                let file_attrs = file_attrs_for(path);
9393                let idx_ent = index_entries.get(path.as_bytes()).copied();
9394                let wt_oid = materialize_worktree_blob(
9395                    odb,
9396                    &file_path,
9397                    meta,
9398                    &conv,
9399                    &file_attrs,
9400                    path,
9401                    idx_ent,
9402                    filter_fp,
9403                )?;
9404                let wt_mode = mode_from_metadata(meta);
9405                result.push(DiffEntry {
9406                    status: DiffStatus::Added,
9407                    old_path: None,
9408                    new_path: Some(path.clone()),
9409                    old_mode: FileMode::ABSENT,
9410                    new_mode: FileMode::new(wt_mode),
9411                    old_oid: zero_oid(),
9412                    new_oid: wt_oid,
9413                    score: None,
9414                });
9415            }
9416            (None, None) => {
9417                // Tracked in index but neither in tree nor worktree — skip
9418            }
9419        }
9420    }
9421
9422    sort_entries_by_path(&mut result);
9423    Ok(result)
9424}
9425
9426// ── Rename detection ────────────────────────────────────────────────
9427
9428/// Emitted when unmatched rename candidates exceed [`diff.renameLimit`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-diffrenameLimit) after exact and basename pairing.
9429#[derive(Debug, Clone, PartialEq, Eq)]
9430pub struct InexactRenameDetectionSkipped {
9431    /// Unmatched delete-side candidates still eligible for inexact pairing.
9432    pub unmatched_sources: usize,
9433    /// Unmatched add-side candidates still eligible for inexact pairing.
9434    pub unmatched_destinations: usize,
9435    /// Configured `diff.renameLimit`, if any (`None` means unlimited).
9436    pub configured_limit: Option<usize>,
9437    /// Smallest limit that would allow the remaining matrix (`max(sources, destinations)`).
9438    pub suggested_limit: usize,
9439}
9440
9441/// Tree or commit diff entries after rename detection, with an optional limit notice.
9442#[derive(Debug, Clone, Default, PartialEq, Eq)]
9443pub struct DiffEntriesResult {
9444    /// Flat diff entries in path order.
9445    pub entries: Vec<DiffEntry>,
9446    /// Set when inexact rename detection was skipped due to `diff.renameLimit`.
9447    pub inexact_rename_skipped: Option<InexactRenameDetectionSkipped>,
9448}
9449
9450/// Result of [`detect_renames_with_policy`].
9451#[derive(Debug, Clone, PartialEq, Eq)]
9452pub struct RenameDetectionResult {
9453    /// Diff entries after rename pairing.
9454    pub entries: Vec<DiffEntry>,
9455    /// Set when inexact rename detection was skipped due to `diff.renameLimit`.
9456    pub inexact_skipped: Option<InexactRenameDetectionSkipped>,
9457}
9458
9459/// Options for [`detect_renames_with_policy`].
9460#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9461pub struct DetectRenamesOptions {
9462    /// Skip Git's basename pre-pass (break-rewrite and copy detection paths).
9463    pub skip_basename_heuristic: bool,
9464}
9465
9466fn read_added_entry_bytes(
9467    odb: &Odb,
9468    entry: &DiffEntry,
9469    work_root: Option<&Path>,
9470) -> Option<Vec<u8>> {
9471    if entry.new_oid != zero_oid() {
9472        if let Ok(obj) = odb.read(&entry.new_oid) {
9473            return Some(obj.data);
9474        }
9475    }
9476    let path = entry.new_path.as_deref()?;
9477    let root = work_root?;
9478    fs::read(root.join(path)).ok()
9479}
9480
9481/// Detect renames by pairing Deleted and Added entries with similar content.
9482///
9483/// `threshold` is the minimum similarity percentage (0–100) for a pair to
9484/// be considered a rename (Git's default is 50%).  The function reads blob
9485/// content from the ODB to compute a line-level similarity score.
9486///
9487/// Exact-OID matches are always 100% similar regardless of content.
9488///
9489/// When `work_root` is set, added entries whose `new_oid` is the zero placeholder (as in
9490/// uncached `diff-index` when the work tree diverged from the index) load content from disk
9491/// under that root instead of the object database.
9492#[allow(clippy::expect_used)]
9493pub(crate) fn detect_renames(
9494    odb: &Odb,
9495    work_root: Option<&Path>,
9496    entries: Vec<DiffEntry>,
9497    threshold: u32,
9498) -> Vec<DiffEntry> {
9499    let config = ConfigSet::new();
9500    let policy = ThreadPolicy::default();
9501    detect_renames_with_policy(
9502        odb,
9503        work_root,
9504        entries,
9505        threshold,
9506        &config,
9507        &policy,
9508        None,
9509        DetectRenamesOptions::default(),
9510    )
9511    .expect("rename detection")
9512    .entries
9513}
9514
9515#[inline]
9516fn poll_rename_cancel(
9517    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
9518) -> crate::error::Result<()> {
9519    if let Some(watch) = cancel_watch {
9520        crate::progress::poll_watch(watch)?;
9521    }
9522    Ok(())
9523}
9524
9525/// Like `detect_renames`, but honors `index.threads` / `--threads` via `thread_policy`.
9526///
9527/// A list without both an added and a deleted entry has nothing to pair. When its paths
9528/// are also unique and in [`sort_entries_by_path`] order, as tree diffs return them, the
9529/// list comes back unchanged without reading blobs or allocating.
9530///
9531/// # Errors
9532///
9533/// Returns object database errors from reading candidate blobs, and the cancellation
9534/// error when `cancel_watch` is set.
9535#[allow(clippy::too_many_arguments)]
9536pub fn detect_renames_with_policy(
9537    odb: &Odb,
9538    work_root: Option<&Path>,
9539    entries: Vec<DiffEntry>,
9540    threshold: u32,
9541    config: &ConfigSet,
9542    thread_policy: &ThreadPolicy,
9543    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
9544    detect_opts: DetectRenamesOptions,
9545) -> Result<RenameDetectionResult> {
9546    poll_rename_cancel(cancel_watch)?;
9547    let has_deleted = entries.iter().any(|e| e.status == DiffStatus::Deleted);
9548    let has_added = entries.iter().any(|e| e.status == DiffStatus::Added);
9549    if (!has_deleted || !has_added) && entries_strictly_sorted_by_path(&entries) {
9550        // With unique sorted paths, regrouping and re-sorting would rebuild the same list.
9551        return Ok(RenameDetectionResult {
9552            entries,
9553            inexact_skipped: None,
9554        });
9555    }
9556    // Split entries into deleted, added, and others.
9557    let mut deleted: Vec<DiffEntry> = Vec::new();
9558    let mut added: Vec<DiffEntry> = Vec::new();
9559    let mut others: Vec<DiffEntry> = Vec::new();
9560
9561    for entry in entries {
9562        match entry.status {
9563            DiffStatus::Deleted => deleted.push(entry),
9564            DiffStatus::Added => added.push(entry),
9565            _ => others.push(entry),
9566        }
9567    }
9568
9569    if deleted.is_empty() || added.is_empty() {
9570        // Nothing to pair — return original order.
9571        let mut result = others;
9572        result.extend(deleted);
9573        result.extend(added);
9574        sort_entries_by_path(&mut result);
9575        return Ok(RenameDetectionResult {
9576            entries: result,
9577            inexact_skipped: None,
9578        });
9579    }
9580
9581    fn is_regularish_mode(mode: FileMode) -> bool {
9582        mode == FileMode::REGULAR || mode == FileMode::EXECUTABLE
9583    }
9584
9585    fn same_path_same_blob(del: &DiffEntry, add: &DiffEntry) -> bool {
9586        del.old_path == add.new_path && del.old_oid == add.new_oid && del.old_mode == add.new_mode
9587    }
9588
9589    fn sort_rename_scores(
9590        scores: &mut [(u32, usize, usize)],
9591        deleted: &[DiffEntry],
9592        added: &[DiffEntry],
9593    ) {
9594        scores.sort_by(|a, b| {
9595            let a_noop = same_path_same_blob(&deleted[a.1], &added[a.2]);
9596            let b_noop = same_path_same_blob(&deleted[b.1], &added[b.2]);
9597            let a_same = same_basename(&deleted[a.1], &added[a.2]);
9598            let b_same = same_basename(&deleted[b.1], &added[b.2]);
9599            a_noop
9600                .cmp(&b_noop)
9601                .then_with(|| b_same.cmp(&a_same))
9602                .then_with(|| b.0.cmp(&a.0))
9603        });
9604    }
9605
9606    fn apply_rename_scores(
9607        scores: &[(u32, usize, usize)],
9608        deleted: &[DiffEntry],
9609        added: &[DiffEntry],
9610        used_deleted: &mut [bool],
9611        used_added: &mut [bool],
9612        renames: &mut Vec<DiffEntry>,
9613    ) {
9614        for (score, di, ai) in scores {
9615            if used_deleted[*di] || used_added[*ai] {
9616                continue;
9617            }
9618            used_deleted[*di] = true;
9619            used_added[*ai] = true;
9620
9621            let del = &deleted[*di];
9622            let add = &added[*ai];
9623            if same_path_same_blob(del, add) {
9624                continue;
9625            }
9626
9627            renames.push(DiffEntry {
9628                status: DiffStatus::Renamed,
9629                old_path: del.old_path.clone(),
9630                new_path: add.new_path.clone(),
9631                old_mode: del.old_mode,
9632                new_mode: add.new_mode,
9633                old_oid: del.old_oid,
9634                new_oid: add.new_oid,
9635                score: Some(*score),
9636            });
9637        }
9638    }
9639
9640    let mut used_deleted = vec![false; deleted.len()];
9641    let mut used_added = vec![false; added.len()];
9642    let mut renames: Vec<DiffEntry> = Vec::new();
9643
9644    // Git always runs exact rename detection, regardless of renameLimit.
9645    let mut added_by_oid: std::collections::HashMap<ObjectId, Vec<usize>> =
9646        std::collections::HashMap::new();
9647    for (ai, add) in added.iter().enumerate() {
9648        added_by_oid.entry(add.new_oid).or_default().push(ai);
9649    }
9650    let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
9651    for (di, del) in deleted.iter().enumerate() {
9652        poll_rename_cancel(cancel_watch)?;
9653        if let Some(ais) = added_by_oid.get(&del.old_oid) {
9654            for &ai in ais {
9655                exact_scores.push((100, di, ai));
9656            }
9657        }
9658    }
9659    sort_rename_scores(&mut exact_scores, &deleted, &added);
9660    apply_rename_scores(
9661        &exact_scores,
9662        &deleted,
9663        &added,
9664        &mut used_deleted,
9665        &mut used_added,
9666        &mut renames,
9667    );
9668
9669    let mut blob_cache = None;
9670    if !detect_opts.skip_basename_heuristic
9671        && super::basename_rename::basename_needs_similarity_scoring(
9672            &deleted,
9673            &added,
9674            &used_deleted,
9675            &used_added,
9676        )
9677    {
9678        let mut cache = super::rename_blob_cache::RenameBlobCache::new(deleted.len(), added.len());
9679        let par_opts = diff_rename_parallel_options(config, thread_policy);
9680        super::basename_rename::apply_basename_rename_pairs(
9681            odb,
9682            work_root,
9683            &deleted,
9684            &added,
9685            &mut used_deleted,
9686            &mut used_added,
9687            &mut renames,
9688            threshold,
9689            cancel_watch,
9690            &mut cache,
9691            par_opts,
9692        )?;
9693        blob_cache = Some(cache);
9694    }
9695
9696    let remaining_deleted: Vec<usize> = used_deleted
9697        .iter()
9698        .enumerate()
9699        .filter_map(|(i, used)| (!*used).then_some(i))
9700        .collect();
9701    let remaining_added: Vec<usize> = used_added
9702        .iter()
9703        .enumerate()
9704        .filter_map(|(i, used)| (!*used).then_some(i))
9705        .collect();
9706
9707    let rename_limit = parse_rename_limit(config);
9708    let mut inexact_skipped = None;
9709    if !remaining_deleted.is_empty()
9710        && !remaining_added.is_empty()
9711        && inexact_matrix_exceeds_limit(
9712            remaining_added.len(),
9713            remaining_deleted.len(),
9714            rename_limit,
9715        )
9716    {
9717        let suggested = remaining_deleted.len().max(remaining_added.len());
9718        inexact_skipped = Some(InexactRenameDetectionSkipped {
9719            unmatched_sources: remaining_deleted.len(),
9720            unmatched_destinations: remaining_added.len(),
9721            configured_limit: rename_limit.configured_max(),
9722            suggested_limit: suggested,
9723        });
9724    } else if !remaining_deleted.is_empty() && !remaining_added.is_empty() {
9725        let par_opts = diff_rename_parallel_options(config, thread_policy);
9726
9727        let mut prefetch_entries: Vec<DiffEntry> =
9728            Vec::with_capacity(remaining_deleted.len() + remaining_added.len());
9729        for &di in &remaining_deleted {
9730            prefetch_entries.push(deleted[di].clone());
9731        }
9732        for &ai in &remaining_added {
9733            prefetch_entries.push(added[ai].clone());
9734        }
9735        super::tree_content::prefetch_tree_diff_blobs(odb, &prefetch_entries)?;
9736
9737        let deleted_sizes: Vec<Option<usize>> = remaining_deleted
9738            .iter()
9739            .map(|&di| blob_payload_size(odb, &deleted[di].old_oid))
9740            .collect();
9741        let added_sizes: Vec<Option<usize>> = remaining_added
9742            .iter()
9743            .map(|&ai| added_entry_payload_size(odb, &added[ai], work_root))
9744            .collect();
9745
9746        let mut load_deleted = vec![false; remaining_deleted.len()];
9747        let mut load_added = vec![false; remaining_added.len()];
9748        let mut candidate_pairs: Vec<(usize, usize)> = Vec::new();
9749        for (row, &di) in remaining_deleted.iter().enumerate() {
9750            poll_rename_cancel(cancel_watch)?;
9751            let del = &deleted[di];
9752            if !is_regularish_mode(del.old_mode) {
9753                continue;
9754            }
9755            let Some(ds) = deleted_sizes[row] else {
9756                continue;
9757            };
9758            for (col, &ai) in remaining_added.iter().enumerate() {
9759                let add = &added[ai];
9760                if !is_regularish_mode(add.new_mode) {
9761                    continue;
9762                }
9763                let Some(asz) = added_sizes[col] else {
9764                    continue;
9765                };
9766                if rename_pair_size_reachable(ds, asz, threshold) {
9767                    load_deleted[row] = true;
9768                    load_added[col] = true;
9769                    candidate_pairs.push((row, col));
9770                }
9771            }
9772        }
9773
9774        if !candidate_pairs.is_empty() {
9775            let loads_needed = load_deleted.iter().filter(|&&b| b).count()
9776                + load_added.iter().filter(|&&b| b).count();
9777            let (deleted_contents, added_contents) = if let Some(cache) = blob_cache.as_mut() {
9778                let mut deleted_staging: Vec<Option<Vec<u8>>> = vec![None; remaining_deleted.len()];
9779                for (row, &need) in load_deleted.iter().enumerate() {
9780                    if !need {
9781                        continue;
9782                    }
9783                    let di = remaining_deleted[row];
9784                    deleted_staging[row] = cache
9785                        .take_deleted_blob(di)
9786                        .or_else(|| odb.read(&deleted[di].old_oid).ok().map(|obj| obj.data));
9787                }
9788                let mut added_staging: Vec<Option<Vec<u8>>> = vec![None; remaining_added.len()];
9789                for (col, &need) in load_added.iter().enumerate() {
9790                    if !need {
9791                        continue;
9792                    }
9793                    let ai = remaining_added[col];
9794                    added_staging[col] = cache
9795                        .take_added_blob(ai)
9796                        .or_else(|| read_added_entry_bytes(odb, &added[ai], work_root));
9797                }
9798                let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
9799                    if !load_deleted[i] {
9800                        return None;
9801                    }
9802                    deleted_staging[i].clone()
9803                };
9804                let load_added_blob = |i: usize| -> Option<Vec<u8>> {
9805                    if !load_added[i] {
9806                        return None;
9807                    }
9808                    added_staging[i].clone()
9809                };
9810                if loads_needed <= 32 {
9811                    (
9812                        (0..remaining_deleted.len())
9813                            .map(load_deleted_blob)
9814                            .collect(),
9815                        (0..remaining_added.len()).map(load_added_blob).collect(),
9816                    )
9817                } else {
9818                    (
9819                        parallel_load_optional_blobs(
9820                            odb,
9821                            remaining_deleted.len(),
9822                            par_opts,
9823                            0,
9824                            load_deleted_blob,
9825                        ),
9826                        parallel_load_optional_blobs(
9827                            odb,
9828                            remaining_added.len(),
9829                            par_opts,
9830                            0,
9831                            load_added_blob,
9832                        ),
9833                    )
9834                }
9835            } else {
9836                let mut deleted_staging: Vec<Option<Vec<u8>>> = vec![None; remaining_deleted.len()];
9837                for (row, &need) in load_deleted.iter().enumerate() {
9838                    if !need {
9839                        continue;
9840                    }
9841                    let di = remaining_deleted[row];
9842                    deleted_staging[row] = odb.read(&deleted[di].old_oid).ok().map(|obj| obj.data);
9843                }
9844                let mut added_staging: Vec<Option<Vec<u8>>> = vec![None; remaining_added.len()];
9845                for (col, &need) in load_added.iter().enumerate() {
9846                    if !need {
9847                        continue;
9848                    }
9849                    let ai = remaining_added[col];
9850                    added_staging[col] = read_added_entry_bytes(odb, &added[ai], work_root);
9851                }
9852                let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
9853                    if !load_deleted[i] {
9854                        return None;
9855                    }
9856                    deleted_staging[i].clone()
9857                };
9858                let load_added_blob = |i: usize| -> Option<Vec<u8>> {
9859                    if !load_added[i] {
9860                        return None;
9861                    }
9862                    added_staging[i].clone()
9863                };
9864                if loads_needed <= 32 {
9865                    (
9866                        (0..remaining_deleted.len())
9867                            .map(load_deleted_blob)
9868                            .collect(),
9869                        (0..remaining_added.len()).map(load_added_blob).collect(),
9870                    )
9871                } else {
9872                    (
9873                        parallel_load_optional_blobs(
9874                            odb,
9875                            remaining_deleted.len(),
9876                            par_opts,
9877                            0,
9878                            load_deleted_blob,
9879                        ),
9880                        parallel_load_optional_blobs(
9881                            odb,
9882                            remaining_added.len(),
9883                            par_opts,
9884                            0,
9885                            load_added_blob,
9886                        ),
9887                    )
9888                }
9889            };
9890
9891            let est_table_bytes: usize = deleted_contents
9892                .iter()
9893                .chain(added_contents.iter())
9894                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
9895                .sum();
9896            let build_deleted_table = |row: usize| -> std::result::Result<
9897                Option<RenameLineTable>,
9898                std::convert::Infallible,
9899            > {
9900                Ok(deleted_contents[row]
9901                    .as_ref()
9902                    .map(|d| RenameLineTable::from_blob(d)))
9903            };
9904            let build_added_table = |col: usize| -> std::result::Result<
9905                Option<RenameLineTable>,
9906                std::convert::Infallible,
9907            > {
9908                Ok(added_contents[col]
9909                    .as_ref()
9910                    .map(|d| RenameLineTable::from_blob(d)))
9911            };
9912            let deleted_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
9913                (0..remaining_deleted.len())
9914                    .map(|row| build_deleted_table(row).unwrap())
9915                    .collect()
9916            } else {
9917                parallel_map_indices(
9918                    odb,
9919                    remaining_deleted.len(),
9920                    par_opts,
9921                    est_table_bytes,
9922                    build_deleted_table,
9923                )
9924                .expect("deleted rename line tables")
9925            };
9926            let added_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
9927                (0..remaining_added.len())
9928                    .map(|col| build_added_table(col).unwrap())
9929                    .collect()
9930            } else {
9931                parallel_map_indices(
9932                    odb,
9933                    remaining_added.len(),
9934                    par_opts,
9935                    est_table_bytes,
9936                    build_added_table,
9937                )
9938                .expect("added rename line tables")
9939            };
9940
9941            let mut inexact_scores: Vec<(u32, usize, usize)> = Vec::new();
9942            for (row, col) in candidate_pairs {
9943                poll_rename_cancel(cancel_watch)?;
9944                let di = remaining_deleted[row];
9945                let ai = remaining_added[col];
9946                let (Some(old_table), Some(new_table)) = (&deleted_tables[row], &added_tables[col])
9947                else {
9948                    continue;
9949                };
9950                let score = RenameLineTable::similarity_score(old_table, new_table);
9951                if score >= threshold {
9952                    inexact_scores.push((score, di, ai));
9953                }
9954            }
9955            sort_rename_scores(&mut inexact_scores, &deleted, &added);
9956            apply_rename_scores(
9957                &inexact_scores,
9958                &deleted,
9959                &added,
9960                &mut used_deleted,
9961                &mut used_added,
9962                &mut renames,
9963            );
9964        }
9965    }
9966
9967    // Collect unmatched entries.
9968    let mut result = others;
9969    result.extend(renames);
9970    for (i, entry) in deleted.into_iter().enumerate() {
9971        if !used_deleted[i] {
9972            result.push(entry);
9973        }
9974    }
9975    for (i, entry) in added.into_iter().enumerate() {
9976        if !used_added[i] {
9977            result.push(entry);
9978        }
9979    }
9980
9981    result = prune_break_deletions_obviated_by_renames(result);
9982    sort_entries_by_path(&mut result);
9983    Ok(RenameDetectionResult {
9984        entries: result,
9985        inexact_skipped,
9986    })
9987}
9988
9989/// Detect copies among diff entries.
9990///
9991/// This first runs rename detection (pairing Deleted+Added), then for any
9992/// remaining Added entries, looks for copy sources.
9993///
9994/// - `find_copies_harder` = false: only Modified entries are copy source candidates.
9995/// - `find_copies_harder` = true: also examine unmodified files from `source_tree_entries`.
9996///
9997/// `source_tree_entries` should be a list of (path, mode, oid) from the source tree;
9998/// used when `find_copies_harder` is true to consider unmodified files as copy sources.
9999/// # Errors
10000///
10001/// Returns [`Error::HashCollision`] when hashing worktree blob content for copy detection
10002/// would produce a known-bad SHA-1 object id.
10003#[allow(dead_code)]
10004fn detect_copies(
10005    odb: &Odb,
10006    work_root: Option<&Path>,
10007    entries: Vec<DiffEntry>,
10008    threshold: u32,
10009    find_copies_harder: bool,
10010    source_tree_entries: &[(String, FileMode, ObjectId)],
10011) -> Result<Vec<DiffEntry>> {
10012    let config = ConfigSet::new();
10013    let policy = ThreadPolicy::default();
10014    detect_copies_with_policy(
10015        odb,
10016        work_root,
10017        entries,
10018        threshold,
10019        find_copies_harder,
10020        source_tree_entries,
10021        &config,
10022        &policy,
10023    )
10024}
10025
10026/// Like `detect_copies`, but honors parallel thread policy and rename limits.
10027///
10028/// # Errors
10029///
10030/// Same as `detect_copies`.
10031#[expect(clippy::too_many_arguments)]
10032pub fn detect_copies_with_policy(
10033    odb: &Odb,
10034    work_root: Option<&Path>,
10035    entries: Vec<DiffEntry>,
10036    threshold: u32,
10037    find_copies_harder: bool,
10038    source_tree_entries: &[(String, FileMode, ObjectId)],
10039    config: &ConfigSet,
10040    thread_policy: &ThreadPolicy,
10041) -> Result<Vec<DiffEntry>> {
10042    use std::collections::{HashMap, HashSet};
10043
10044    // Separate entries by status.
10045    let mut deleted: Vec<DiffEntry> = Vec::new();
10046    let mut added: Vec<DiffEntry> = Vec::new();
10047    let mut others: Vec<DiffEntry> = Vec::new();
10048
10049    for entry in entries {
10050        match entry.status {
10051            DiffStatus::Deleted => deleted.push(entry),
10052            DiffStatus::Added => added.push(entry),
10053            _ => others.push(entry),
10054        }
10055    }
10056
10057    // Build source candidates: deleted files, modified files, and optionally tree entries.
10058    // Track which sources are from deleted files (can become renames).
10059    let mut sources: Vec<(String, ObjectId, bool)> = Vec::new(); // (path, oid, is_deleted)
10060    let mut deleted_source_idx: HashMap<String, usize> = HashMap::new();
10061
10062    for entry in &deleted {
10063        if let Some(ref path) = entry.old_path {
10064            deleted_source_idx.insert(path.clone(), sources.len());
10065            sources.push((path.clone(), entry.old_oid, true));
10066        }
10067    }
10068
10069    // Modified and type-changed files are candidates for `-C` (e.g. symlink rewrite leaves the
10070    // old blob available as a copy source for another path; see `t4008-diff-break-rewrite`).
10071    for entry in &others {
10072        if matches!(entry.status, DiffStatus::Modified | DiffStatus::TypeChanged) {
10073            if let Some(ref old_path) = entry.old_path {
10074                if !sources.iter().any(|(p, _, _)| p == old_path) {
10075                    sources.push((old_path.clone(), entry.old_oid, false));
10076                }
10077            }
10078        }
10079    }
10080
10081    // With find_copies_harder, add all source tree entries.
10082    if find_copies_harder {
10083        for (path, _mode, oid) in source_tree_entries {
10084            if !sources.iter().any(|(p, _, _)| p == path) {
10085                sources.push((path.clone(), *oid, false));
10086            }
10087        }
10088    }
10089
10090    // Modes of every source, for the old side of copy and rename entries: the deleted and
10091    // modified entries carry their own, and `source_tree_entries` the rest.
10092    let mut source_modes: Vec<(String, FileMode, ObjectId)> = source_tree_entries.to_vec();
10093    for entry in deleted.iter().chain(&others) {
10094        if let Some(ref path) = entry.old_path {
10095            if !source_modes.iter().any(|(p, _, _)| p == path) {
10096                source_modes.push((path.clone(), entry.old_mode, entry.old_oid));
10097            }
10098        }
10099    }
10100
10101    if sources.is_empty() {
10102        let mut result = others;
10103        result.extend(deleted);
10104        result.extend(added);
10105        sort_entries_by_path(&mut result);
10106        return Ok(result);
10107    }
10108
10109    let par_opts = diff_rename_parallel_options(config, thread_policy);
10110    let rename_limit = parse_rename_limit(config);
10111
10112    fn apply_copy_score_assignments(
10113        scores: &[(u32, usize, usize)],
10114        sources: &[(String, ObjectId, bool)],
10115        added: &[DiffEntry],
10116        source_tree_entries: &[(String, FileMode, ObjectId)],
10117        used_added2: &mut [bool],
10118        renamed_deleted: &mut HashSet<usize>,
10119        result_entries: &mut Vec<DiffEntry>,
10120    ) {
10121        let mut used_added = vec![false; added.len()];
10122        let mut source_to_added: HashMap<usize, Vec<(usize, u32)>> = HashMap::new();
10123        for &(score, si, ai) in scores {
10124            if used_added[ai] {
10125                continue;
10126            }
10127            used_added[ai] = true;
10128            source_to_added.entry(si).or_default().push((ai, score));
10129        }
10130
10131        for (&si, assignments_for_src) in &source_to_added {
10132            let (_, _, is_deleted) = &sources[si];
10133            if *is_deleted && !assignments_for_src.is_empty() {
10134                let rename_ai = assignments_for_src
10135                    .iter()
10136                    .max_by_key(|(ai, _score)| added[*ai].path().to_string())
10137                    .map(|(ai, _)| *ai);
10138
10139                for &(ai, score) in assignments_for_src {
10140                    let (ref src_path, _, _) = sources[si];
10141                    let add = &added[ai];
10142                    let src_mode = source_tree_entries
10143                        .iter()
10144                        .find(|(p, _, _)| p == src_path)
10145                        .map_or(add.old_mode, |(_, m, _)| *m);
10146
10147                    let is_rename = Some(ai) == rename_ai;
10148                    result_entries.push(DiffEntry {
10149                        status: if is_rename {
10150                            DiffStatus::Renamed
10151                        } else {
10152                            DiffStatus::Copied
10153                        },
10154                        old_path: Some(src_path.clone()),
10155                        new_path: add.new_path.clone(),
10156                        old_mode: src_mode,
10157                        new_mode: add.new_mode,
10158                        old_oid: sources[si].1,
10159                        new_oid: add.new_oid,
10160                        score: Some(score),
10161                    });
10162                    used_added2[ai] = true;
10163                }
10164                renamed_deleted.insert(si);
10165            } else {
10166                for &(ai, score) in assignments_for_src {
10167                    let (ref src_path, _, _) = sources[si];
10168                    let add = &added[ai];
10169                    let src_mode = source_tree_entries
10170                        .iter()
10171                        .find(|(p, _, _)| p == src_path)
10172                        .map_or(add.old_mode, |(_, m, _)| *m);
10173
10174                    result_entries.push(DiffEntry {
10175                        status: DiffStatus::Copied,
10176                        old_path: Some(src_path.clone()),
10177                        new_path: add.new_path.clone(),
10178                        old_mode: src_mode,
10179                        new_mode: add.new_mode,
10180                        old_oid: sources[si].1,
10181                        new_oid: add.new_oid,
10182                        score: Some(score),
10183                    });
10184                    used_added2[ai] = true;
10185                }
10186            }
10187        }
10188    }
10189
10190    fn added_entry_oid(
10191        odb: &Odb,
10192        add: &DiffEntry,
10193        added_contents: &[Option<Vec<u8>>],
10194        ai: usize,
10195    ) -> Result<ObjectId> {
10196        if add.new_oid != zero_oid() {
10197            Ok(add.new_oid)
10198        } else if let Some(ref data) = added_contents[ai] {
10199            odb.hash(ObjectKind::Blob, data)
10200        } else {
10201            Ok(zero_oid())
10202        }
10203    }
10204
10205    let mut result_entries: Vec<DiffEntry> = Vec::new();
10206    let mut renamed_deleted: HashSet<usize> = HashSet::new();
10207    let mut used_added2 = vec![false; added.len()];
10208    let mut source_contents = vec![None; sources.len()];
10209
10210    if !added.is_empty() {
10211        let added_contents = parallel_load_optional_blobs(odb, added.len(), par_opts, 0, |ai| {
10212            read_added_entry_bytes(odb, &added[ai], work_root)
10213        });
10214
10215        // Git always scores exact OID copy/rename pairs before applying renameLimit.
10216        let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
10217        for (si, (src_path, src_oid, _)) in sources.iter().enumerate() {
10218            for (ai, add) in added.iter().enumerate() {
10219                if add.new_path.as_deref() == Some(src_path.as_str()) {
10220                    continue;
10221                }
10222                let add_oid = added_entry_oid(odb, add, &added_contents, ai)?;
10223                if *src_oid == add_oid && add_oid != zero_oid() {
10224                    exact_scores.push((100, si, ai));
10225                }
10226            }
10227        }
10228        exact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
10229        apply_copy_score_assignments(
10230            &exact_scores,
10231            &sources,
10232            &added,
10233            &source_modes,
10234            &mut used_added2,
10235            &mut renamed_deleted,
10236            &mut result_entries,
10237        );
10238
10239        let remaining_added: Vec<usize> = used_added2
10240            .iter()
10241            .enumerate()
10242            .filter_map(|(i, used)| (!*used).then_some(i))
10243            .collect();
10244        let remaining_source_count = sources
10245            .iter()
10246            .enumerate()
10247            .filter(|(si, (_, _, is_deleted))| !*is_deleted || !renamed_deleted.contains(si))
10248            .count();
10249
10250        let skip_inexact = remaining_added.is_empty()
10251            || inexact_matrix_exceeds_limit(
10252                remaining_added.len(),
10253                remaining_source_count,
10254                rename_limit,
10255            );
10256
10257        if !skip_inexact {
10258            source_contents = parallel_load_optional_blobs(odb, sources.len(), par_opts, 0, |si| {
10259                odb.read(&sources[si].1).ok().map(|obj| obj.data)
10260            });
10261
10262            let est_bytes: usize = source_contents
10263                .iter()
10264                .chain(added_contents.iter())
10265                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
10266                .sum();
10267
10268            let source_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
10269                odb,
10270                sources.len(),
10271                par_opts,
10272                est_bytes,
10273                |si| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
10274                    Ok(source_contents[si]
10275                        .as_ref()
10276                        .map(|d| RenameLineTable::from_blob(d)))
10277                },
10278            )
10279            .expect("copy source line tables");
10280            let added_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
10281                odb,
10282                added.len(),
10283                par_opts,
10284                est_bytes,
10285                |ai| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
10286                    Ok(added_contents[ai]
10287                        .as_ref()
10288                        .map(|d| RenameLineTable::from_blob(d)))
10289                },
10290            )
10291            .expect("copy added line tables");
10292
10293            let score_rows: Vec<Vec<(u32, usize, usize)>> = parallel_map_indices(
10294                odb,
10295                sources.len(),
10296                par_opts,
10297                est_bytes,
10298                |si| -> std::result::Result<Vec<(u32, usize, usize)>, std::convert::Infallible> {
10299                    if renamed_deleted.contains(&si) {
10300                        return Ok(Vec::new());
10301                    }
10302                    let (src_path, _, _) = &sources[si];
10303                    let mut row: Vec<(u32, usize, usize)> = Vec::new();
10304                    for &ai in &remaining_added {
10305                        let add = &added[ai];
10306                        if add.new_path.as_deref() == Some(src_path.as_str()) {
10307                            continue;
10308                        }
10309                        let score = match (&source_tables[si], &added_tables[ai]) {
10310                            (Some(old_table), Some(new_table)) => {
10311                                RenameLineTable::similarity_score(old_table, new_table)
10312                            }
10313                            _ => 0,
10314                        };
10315                        if score >= threshold {
10316                            row.push((score, si, ai));
10317                        }
10318                    }
10319                    Ok(row)
10320                },
10321            )
10322            .expect("copy score rows");
10323            let mut inexact_scores: Vec<(u32, usize, usize)> =
10324                score_rows.into_iter().flatten().collect();
10325            inexact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
10326            apply_copy_score_assignments(
10327                &inexact_scores,
10328                &sources,
10329                &added,
10330                &source_modes,
10331                &mut used_added2,
10332                &mut renamed_deleted,
10333                &mut result_entries,
10334            );
10335        }
10336    }
10337
10338    // Keep deleted entries that weren't consumed by a rename.
10339    for entry in deleted.into_iter() {
10340        if let Some(ref path) = entry.old_path {
10341            if let Some(&si) = deleted_source_idx.get(path) {
10342                if renamed_deleted.contains(&si) {
10343                    // This deletion was consumed by a rename; skip it.
10344                    continue;
10345                }
10346            }
10347        }
10348        result_entries.push(entry);
10349    }
10350
10351    let mut result = others;
10352    result.extend(result_entries);
10353    // Keep unmatched added entries.
10354    for (i, entry) in added.into_iter().enumerate() {
10355        if !used_added2[i] {
10356            result.push(entry);
10357        }
10358    }
10359
10360    // Like `git diff -C`, only created paths become copies: a modification is a copy
10361    // destination only after `-B` splits it into a deletion and an addition, which
10362    // `postprocess_diff_entries` does before calling this.
10363    let mut final_result = result;
10364    sort_entries_by_path(&mut final_result);
10365    Ok(final_result)
10366}
10367
10368/// Options for Git-style rename, copy, and break-rewrite post-processing on tree diffs.
10369#[derive(Debug, Clone, Copy)]
10370pub struct DiffPostprocessOptions {
10371    /// When set, `--break-rewrites` threshold on Git's internal 0–the internal max score (60_000) scale
10372    /// (`None` = off).
10373    pub break_score: Option<u64>,
10374    /// `-M` similarity threshold (0–100); `100` disables rename detection.
10375    pub rename_threshold: u32,
10376    /// `-C` copy threshold (0–100); `None` skips copy detection.
10377    pub copy_threshold: Option<u32>,
10378    /// `-C --find-copies-harder`: treat unmodified source-tree paths as copy sources.
10379    pub find_copies_harder: bool,
10380    /// When break-rewrite runs on tree↔worktree diffs, fill `new_oid` from worktree bytes for
10381    /// `--raw` output (matches `git diff <tree> -B`; leave false for index↔worktree / diff-files).
10382    pub materialize_break_worktree_new_oid: bool,
10383}
10384
10385impl Default for DiffPostprocessOptions {
10386    fn default() -> Self {
10387        Self {
10388            break_score: None,
10389            rename_threshold: 100,
10390            copy_threshold: None,
10391            find_copies_harder: false,
10392            materialize_break_worktree_new_oid: false,
10393        }
10394    }
10395}
10396
10397/// Index and git directory used to read canonical (clean) worktree bytes during break-rewrite
10398/// post-processing on tree↔worktree diffs.
10399#[derive(Debug, Clone, Copy)]
10400pub struct DiffPostprocessWorktree<'a> {
10401    /// Git directory for config and attributes (linked worktrees / gitfiles).
10402    pub repository_git_dir: &'a Path,
10403    /// Current index; stage-0 entries supply autocrlf index-guard blobs when needed.
10404    pub index: &'a Index,
10405}
10406
10407fn blob_bytes(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
10408    Ok(odb.read(oid)?.data)
10409}
10410
10411/// Bytes on the "new" side of a diff entry (ODB blob, or worktree file when `new_oid` is null).
10412///
10413/// When `worktree` is set, worktree paths are normalized with the same attribute/filter rules as
10414/// [`diff_tree_to_worktree`]; otherwise raw filesystem bytes are read.
10415fn read_new_side_bytes(
10416    odb: &Odb,
10417    entry: &DiffEntry,
10418    work_root: Option<&Path>,
10419    worktree: Option<&DiffPostprocessWorktree<'_>>,
10420    config: &ConfigSet,
10421) -> Result<Vec<u8>> {
10422    if entry.new_oid != zero_oid() {
10423        return blob_bytes(odb, &entry.new_oid);
10424    }
10425    let path = entry.new_path.as_deref().ok_or_else(|| {
10426        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
10427    })?;
10428    let root = work_root.ok_or_else(|| {
10429        Error::ObjectNotFound(format!(
10430            "worktree bytes required for {} but no work_root was provided",
10431            path
10432        ))
10433    })?;
10434    if let Some(wctx) = worktree {
10435        let mode = entry.new_mode.bits();
10436        let index_entry = wctx.index.get(path.as_bytes(), 0);
10437        return read_worktree_side_bytes_for_diff(odb, root, path, mode, config, index_entry, None);
10438    }
10439    fs::read(root.join(path)).map_err(Error::Io)
10440}
10441
10442/// True when clean-smudged worktree bytes differ from the raw on-disk file (e.g. `eol=lf` + CRLF).
10443fn worktree_side_differs_after_normalize(
10444    odb: &Odb,
10445    entry: &DiffEntry,
10446    work_root: &Path,
10447    worktree: &DiffPostprocessWorktree<'_>,
10448    config: &ConfigSet,
10449) -> Result<bool> {
10450    let path = entry.new_path.as_deref().ok_or_else(|| {
10451        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
10452    })?;
10453    let raw = fs::read(work_root.join(path)).map_err(Error::Io)?;
10454    let canonical = read_new_side_bytes(odb, entry, Some(work_root), Some(worktree), config)?;
10455    Ok(raw != canonical)
10456}
10457
10458fn split_broken_modifications(
10459    odb: &Odb,
10460    work_root: Option<&Path>,
10461    entries: Vec<DiffEntry>,
10462    break_score: u64,
10463    worktree: Option<&DiffPostprocessWorktree<'_>>,
10464    config: &ConfigSet,
10465) -> Result<Vec<DiffEntry>> {
10466    let mut out = Vec::with_capacity(entries.len());
10467    for e in entries {
10468        if e.status != DiffStatus::Modified {
10469            out.push(e);
10470            continue;
10471        }
10472        let old = blob_bytes(odb, &e.old_oid)?;
10473        let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
10474        if should_break_rewrite_pair(&old, &new, break_score) {
10475            let path = e.path().to_owned();
10476            out.push(DiffEntry {
10477                status: DiffStatus::Deleted,
10478                old_path: Some(path.clone()),
10479                new_path: None,
10480                old_mode: e.old_mode,
10481                new_mode: FileMode::ABSENT,
10482                old_oid: e.old_oid,
10483                new_oid: zero_oid(),
10484                score: None,
10485            });
10486            out.push(DiffEntry {
10487                status: DiffStatus::Added,
10488                old_path: None,
10489                new_path: Some(path),
10490                old_mode: FileMode::ABSENT,
10491                new_mode: e.new_mode,
10492                old_oid: zero_oid(),
10493                new_oid: e.new_oid,
10494                score: None,
10495            });
10496        } else {
10497            out.push(e);
10498        }
10499    }
10500    Ok(out)
10501}
10502
10503fn annotate_broken_modifications(
10504    odb: &Odb,
10505    work_root: Option<&Path>,
10506    entries: Vec<DiffEntry>,
10507    break_score: u64,
10508    worktree: Option<&DiffPostprocessWorktree<'_>>,
10509    config: &ConfigSet,
10510    materialize_worktree_oid: bool,
10511) -> Result<Vec<DiffEntry>> {
10512    let mut out = Vec::with_capacity(entries.len());
10513    for mut e in entries {
10514        if e.status == DiffStatus::Modified {
10515            let old = blob_bytes(odb, &e.old_oid)?;
10516            let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
10517            if should_break_rewrite_pair(&old, &new, break_score) {
10518                // `git diff-files -B` keeps plain `M` when the worktree side is still null.
10519                // `git diff <tree> -B` materializes the clean OID; `M###` appears only when
10520                // attributes/filters changed the bytes used for break scoring (e.g. `eol=lf`).
10521                let show_score = if e.new_oid != zero_oid() {
10522                    true
10523                } else if materialize_worktree_oid {
10524                    match (work_root, worktree) {
10525                        (Some(root), Some(wctx)) => {
10526                            worktree_side_differs_after_normalize(odb, &e, root, wctx, config)?
10527                        }
10528                        _ => false,
10529                    }
10530                } else {
10531                    false
10532                };
10533                if show_score {
10534                    e.score = rewrite_dissimilarity_index_percent(&old, &new);
10535                }
10536            }
10537        }
10538        out.push(e);
10539    }
10540    Ok(out)
10541}
10542
10543fn materialize_break_worktree_new_oids(
10544    odb: &Odb,
10545    work_root: Option<&Path>,
10546    entries: &mut [DiffEntry],
10547    worktree: Option<&DiffPostprocessWorktree<'_>>,
10548    config: &ConfigSet,
10549) -> Result<()> {
10550    for e in entries {
10551        if e.new_oid != zero_oid() {
10552            continue;
10553        }
10554        if !matches!(e.status, DiffStatus::Modified | DiffStatus::Added) {
10555            continue;
10556        }
10557        let bytes = read_new_side_bytes(odb, e, work_root, worktree, config)?;
10558        e.new_oid = odb.hash(ObjectKind::Blob, &bytes)?;
10559    }
10560    Ok(())
10561}
10562
10563/// After break-rewrite splits a modified path into delete+add, rename detection may pair the add
10564/// with another deletion (`g` → `f`). Git drops the unused delete at the rename target path.
10565fn prune_break_deletions_obviated_by_renames(entries: Vec<DiffEntry>) -> Vec<DiffEntry> {
10566    use std::collections::HashSet;
10567    let rename_targets: HashSet<String> = entries
10568        .iter()
10569        .filter(|e| e.status == DiffStatus::Renamed)
10570        .filter_map(|e| e.new_path.clone())
10571        .collect();
10572    if rename_targets.is_empty() {
10573        return entries;
10574    }
10575    entries
10576        .into_iter()
10577        .filter(|e| {
10578            if e.status != DiffStatus::Deleted {
10579                return true;
10580            }
10581            e.old_path
10582                .as_ref()
10583                .is_none_or(|p| !rename_targets.contains(p))
10584        })
10585        .collect()
10586}
10587
10588/// Apply break-rewrite, rename, and copy detection to a flat tree or index diff.
10589///
10590/// `source_tree_oid` supplies `--find-copies-harder` candidates; `work_root` is used when added
10591/// entries carry a zero OID (index↔worktree). Pass `worktree` when break-rewrite runs on a
10592/// tree↔worktree diff so worktree-side bytes honor attributes and filters.
10593///
10594/// # Errors
10595///
10596/// Propagates object read failures while evaluating break scores.
10597#[allow(clippy::too_many_arguments)]
10598pub fn postprocess_diff_entries(
10599    odb: &Odb,
10600    entries: Vec<DiffEntry>,
10601    source_tree_oid: Option<&ObjectId>,
10602    work_root: Option<&Path>,
10603    worktree: Option<&DiffPostprocessWorktree<'_>>,
10604    config: &ConfigSet,
10605    thread_policy: &ThreadPolicy,
10606    opts: &DiffPostprocessOptions,
10607    inexact_notice: Option<&mut Option<InexactRenameDetectionSkipped>>,
10608) -> Result<Vec<DiffEntry>> {
10609    let mut entries = entries;
10610    if let Some(break_score) = opts.break_score {
10611        let rename_or_copy = opts.rename_threshold < 100 || opts.copy_threshold.is_some();
10612        entries = if rename_or_copy {
10613            split_broken_modifications(odb, work_root, entries, break_score, worktree, config)?
10614        } else {
10615            annotate_broken_modifications(
10616                odb,
10617                work_root,
10618                entries,
10619                break_score,
10620                worktree,
10621                config,
10622                opts.materialize_break_worktree_new_oid,
10623            )?
10624        };
10625        if opts.materialize_break_worktree_new_oid {
10626            materialize_break_worktree_new_oids(odb, work_root, &mut entries, worktree, config)?;
10627        }
10628    }
10629
10630    let source_tree_entries = source_tree_oid
10631        .map(|t| flatten_tree_entries(odb, t))
10632        .transpose()?
10633        .unwrap_or_default();
10634
10635    if opts.copy_threshold.is_some() || opts.find_copies_harder {
10636        let threshold = opts.copy_threshold.unwrap_or(opts.rename_threshold.min(99));
10637        detect_copies_with_policy(
10638            odb,
10639            work_root,
10640            entries,
10641            threshold,
10642            opts.find_copies_harder,
10643            &source_tree_entries,
10644            config,
10645            thread_policy,
10646        )
10647    } else if opts.rename_threshold < 100 {
10648        let skip_basename = opts.break_score.is_some();
10649        let out = detect_renames_with_policy(
10650            odb,
10651            work_root,
10652            entries,
10653            opts.rename_threshold,
10654            config,
10655            thread_policy,
10656            None,
10657            DetectRenamesOptions {
10658                skip_basename_heuristic: skip_basename,
10659            },
10660        )?;
10661        if let Some(slot) = inexact_notice {
10662            if out.inexact_skipped.is_some() {
10663                *slot = out.inexact_skipped;
10664            }
10665        }
10666        Ok(out.entries)
10667    } else {
10668        Ok(entries)
10669    }
10670}
10671
10672/// Depth-first list of `(path, mode, oid)` blob entries in a tree (for copy detection).
10673pub(crate) fn flatten_tree_entries(
10674    odb: &Odb,
10675    tree_oid: &ObjectId,
10676) -> Result<Vec<(String, FileMode, ObjectId)>> {
10677    fn walk(
10678        odb: &Odb,
10679        tree_oid: &ObjectId,
10680        prefix: &str,
10681        out: &mut Vec<(String, FileMode, ObjectId)>,
10682    ) -> Result<()> {
10683        let obj = odb.read(tree_oid)?;
10684        let oid_len = odb.hash_algo().len();
10685        for e in TreeRefIter::with_oid_len(&obj.data, oid_len) {
10686            let e = e?;
10687            let name = String::from_utf8_lossy(e.name);
10688            let path = if prefix.is_empty() {
10689                name.to_string()
10690            } else {
10691                format!("{prefix}/{name}")
10692            };
10693            if e.mode == 0o040000 {
10694                walk(odb, &e.oid, &path, out)?;
10695            } else {
10696                out.push((path, FileMode::new(e.mode), e.oid));
10697            }
10698        }
10699        Ok(())
10700    }
10701    let mut out = Vec::new();
10702    walk(odb, tree_oid, "", &mut out)?;
10703    Ok(out)
10704}
10705
10706/// Apply Git-style rename and optional copy detection for index↔worktree diffs.
10707///
10708/// When `copies` is true (Git `diff.renames` / `status.renames` set to `copy`/`copies`),
10709/// runs copy detection, which also pairs deleted sources with one rename and any additional
10710/// destinations as copies.
10711///
10712/// # Errors
10713///
10714/// Propagates errors from reading the `head_tree` object from `odb`.
10715#[allow(clippy::too_many_arguments)]
10716pub(crate) fn status_apply_rename_copy_detection(
10717    odb: &Odb,
10718    unstaged_raw: Vec<DiffEntry>,
10719    threshold: u32,
10720    copies: bool,
10721    head_tree: Option<&ObjectId>,
10722    config: &ConfigSet,
10723    thread_policy: &ThreadPolicy,
10724    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
10725) -> Result<Vec<DiffEntry>> {
10726    if !copies {
10727        return detect_renames_with_policy(
10728            odb,
10729            None,
10730            unstaged_raw,
10731            threshold,
10732            config,
10733            thread_policy,
10734            cancel_watch,
10735            DetectRenamesOptions::default(),
10736        )
10737        .map(|r| r.entries);
10738    }
10739    let source_tree_entries: Vec<(String, FileMode, ObjectId)> = match head_tree {
10740        Some(oid) => flatten_tree(odb, oid, "")?
10741            .into_iter()
10742            .map(|e| (e.path, FileMode::new(e.mode), e.oid))
10743            .collect(),
10744        None => Vec::new(),
10745    };
10746    detect_copies_with_policy(
10747        odb,
10748        None,
10749        unstaged_raw,
10750        threshold,
10751        false,
10752        &source_tree_entries,
10753        config,
10754        thread_policy,
10755    )
10756}
10757
10758/// Check if two entries share the same filename (basename).
10759fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
10760    let old = del.old_path.as_deref().unwrap_or("");
10761    let new = add.new_path.as_deref().unwrap_or("");
10762    let old_base = old.rsplit('/').next().unwrap_or(old);
10763    let new_base = new.rsplit('/').next().unwrap_or(new);
10764    old_base == new_base && !old_base.is_empty()
10765}
10766
10767const RENAME_SCORE_MAX: u32 = 100;
10768
10769/// Uncompressed blob size without inflating object bodies (for rename pre-filtering).
10770fn blob_payload_size(odb: &Odb, oid: &ObjectId) -> Option<usize> {
10771    if oid.is_zero() {
10772        return None;
10773    }
10774    odb.read_info(oid).ok().map(|info| info.size as usize)
10775}
10776
10777/// Payload size for an added diff side (ODB blob or worktree bytes when not stored yet).
10778fn added_entry_payload_size(odb: &Odb, add: &DiffEntry, work_root: Option<&Path>) -> Option<usize> {
10779    if !add.new_oid.is_zero() {
10780        if let Some(size) = blob_payload_size(odb, &add.new_oid) {
10781            return Some(size);
10782        }
10783    }
10784    read_added_entry_bytes(odb, add, work_root).map(|data| data.len())
10785}
10786
10787/// Git `diffcore-delta` size gate: skip pairs that cannot reach `minimum_score`.
10788fn rename_pair_size_reachable(src_size: usize, dst_size: usize, minimum_score: u32) -> bool {
10789    let max_size = src_size.max(dst_size);
10790    let min_size = src_size.min(dst_size);
10791    if max_size == 0 {
10792        return minimum_score <= RENAME_SCORE_MAX;
10793    }
10794    (max_size - min_size).saturating_mul(RENAME_SCORE_MAX as usize)
10795        <= max_size.saturating_mul((RENAME_SCORE_MAX - minimum_score) as usize)
10796}
10797
10798/// Compute a similarity percentage (0–100) between two byte slices.
10799///
10800/// Matches Git rename detection: span-hash intersection (`hash_chars` in diffcore-delta),
10801/// `score = copied_bytes * 100 / max(src_size, dst_size)` on raw blob sizes.
10802fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
10803    RenameLineTable::similarity_score(
10804        &RenameLineTable::from_blob(old),
10805        &RenameLineTable::from_blob(new),
10806    )
10807}
10808
10809/// Compute rename/copy similarity percentage (0–100) between two byte slices.
10810///
10811/// Uses Git `diffcore-delta` span hashing (`hash_chars`): 64-byte or newline-bounded
10812/// chunks, CR skipped before LF in text blobs, `score = copied * 100 / max(len)`.
10813#[must_use]
10814pub fn rename_similarity_score(old: &[u8], new: &[u8]) -> u32 {
10815    compute_similarity(old, new)
10816}
10817
10818// ── Output formatting ───────────────────────────────────────────────
10819
10820/// Format a diff entry in Git's raw diff format.
10821///
10822/// Example: `:100644 100644 abc1234... def5678... M\tfile.txt`
10823pub fn format_raw(entry: &DiffEntry) -> String {
10824    let path = match entry.status {
10825        DiffStatus::Renamed | DiffStatus::Copied => {
10826            format!(
10827                "{}\t{}",
10828                entry.old_path.as_deref().unwrap_or(""),
10829                entry.new_path.as_deref().unwrap_or("")
10830            )
10831        }
10832        _ => entry.path().to_owned(),
10833    };
10834
10835    let status_str = match (entry.status, entry.score) {
10836        (DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
10837        (DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
10838        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
10839        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
10840        _ => entry.status.letter().to_string(),
10841    };
10842
10843    let (old_hex, new_hex) = raw_oid_hex_pair(&entry.old_oid, &entry.new_oid);
10844    format!(
10845        ":{} {} {} {} {}\t{}",
10846        entry.old_mode, entry.new_mode, old_hex, new_hex, status_str, path
10847    )
10848}
10849
10850/// Render a diff entry's `(old, new)` OIDs as full hex for `--raw` output,
10851/// widening any null OID to the repository's hash width.
10852///
10853/// Diff entries store null OIDs at SHA-1 width regardless of the repository
10854/// algorithm, but `git diff --raw` prints them at the real hash width (64
10855/// zeros in a SHA-256 repo). A diff entry never has both sides null, so the
10856/// width is taken from whichever side carries a real object.
10857fn raw_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
10858    let width = if !old.is_zero() {
10859        old.algo().hex_len()
10860    } else if !new.is_zero() {
10861        new.algo().hex_len()
10862    } else {
10863        old.algo().hex_len()
10864    };
10865    let render = |oid: &ObjectId| {
10866        if oid.is_zero() {
10867            "0".repeat(width)
10868        } else {
10869            oid.to_hex()
10870        }
10871    };
10872    (render(old), render(new))
10873}
10874
10875/// Format a diff entry with abbreviated OIDs.
10876pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
10877    let old_hex = format!("{}", entry.old_oid);
10878    let new_hex = format!("{}", entry.new_oid);
10879    let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
10880    let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
10881
10882    // Renames/copies carry a similarity score and a `<old>\t<new>` path pair.
10883    let path = match entry.status {
10884        DiffStatus::Renamed | DiffStatus::Copied => format!(
10885            "{}\t{}",
10886            entry.old_path.as_deref().unwrap_or(""),
10887            entry.new_path.as_deref().unwrap_or("")
10888        ),
10889        _ => entry.path().to_owned(),
10890    };
10891    let status_str = match (entry.status, entry.score) {
10892        (DiffStatus::Renamed, Some(s)) => format!("R{s:03}"),
10893        (DiffStatus::Copied, Some(s)) => format!("C{s:03}"),
10894        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
10895        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
10896        _ => entry.status.letter().to_string(),
10897    };
10898
10899    format!(
10900        ":{} {} {} {} {}\t{}",
10901        entry.old_mode, entry.new_mode, old_abbrev, new_abbrev, status_str, path
10902    )
10903}
10904
10905/// Generate a unified diff patch for two blobs.
10906///
10907/// # Parameters
10908///
10909/// - `old_content` — the old file content (empty for added files).
10910/// - `new_content` — the new file content (empty for deleted files).
10911/// - `old_path` — display path for the old side.
10912/// - `new_path` — display path for the new side.
10913/// - `context_lines` — number of context lines around changes (default: 3).
10914/// - Inter-hunk context defaults to `0` (see [`unified_diff_with_prefix`]).
10915///
10916/// # Returns
10917///
10918/// The unified diff as a string.
10919pub(crate) fn unified_diff(
10920    old_content: &str,
10921    new_content: &str,
10922    old_path: &str,
10923    new_path: &str,
10924    context_lines: usize,
10925    indent_heuristic: bool,
10926    quote_path_fully: bool,
10927) -> String {
10928    unified_diff_with_prefix(
10929        old_content,
10930        new_content,
10931        old_path,
10932        new_path,
10933        context_lines,
10934        0,
10935        "a/",
10936        "b/",
10937        indent_heuristic,
10938        quote_path_fully,
10939    )
10940}
10941
10942/// Same as `unified_diff` but with configurable source/destination prefixes.
10943///
10944/// `inter_hunk_context` is Git's `--inter-hunk-context`: adjacent hunks merge when
10945/// the unchanged gap between them is at most `2 * context_lines + inter_hunk_context` lines.
10946#[allow(clippy::too_many_arguments)] // Mirrors Git-style unified diff parameters.
10947pub(crate) fn unified_diff_with_prefix(
10948    old_content: &str,
10949    new_content: &str,
10950    old_path: &str,
10951    new_path: &str,
10952    context_lines: usize,
10953    inter_hunk_context: usize,
10954    src_prefix: &str,
10955    dst_prefix: &str,
10956    indent_heuristic: bool,
10957    quote_path_fully: bool,
10958) -> String {
10959    unified_diff_with_prefix_and_funcname(
10960        old_content,
10961        new_content,
10962        old_path,
10963        new_path,
10964        context_lines,
10965        inter_hunk_context,
10966        src_prefix,
10967        dst_prefix,
10968        None,
10969        indent_heuristic,
10970        quote_path_fully,
10971    )
10972}
10973
10974/// Same as `unified_diff_with_prefix` with optional custom hunk-header
10975/// function-name matching.
10976#[allow(clippy::too_many_arguments)]
10977pub fn unified_diff_with_prefix_and_funcname(
10978    old_content: &str,
10979    new_content: &str,
10980    old_path: &str,
10981    new_path: &str,
10982    context_lines: usize,
10983    inter_hunk_context: usize,
10984    src_prefix: &str,
10985    dst_prefix: &str,
10986    funcname_matcher: Option<&FuncnameMatcher>,
10987    indent_heuristic: bool,
10988    quote_path_fully: bool,
10989) -> String {
10990    unified_diff_with_prefix_and_funcname_and_algorithm(
10991        old_content,
10992        new_content,
10993        old_path,
10994        new_path,
10995        context_lines,
10996        inter_hunk_context,
10997        src_prefix,
10998        dst_prefix,
10999        funcname_matcher,
11000        similar::Algorithm::Myers,
11001        false,
11002        false,
11003        indent_heuristic,
11004        quote_path_fully,
11005    )
11006}
11007
11008/// Same as [`unified_diff_with_prefix_and_funcname_and_algorithm`], streaming to `out`.
11009///
11010/// # Errors
11011///
11012/// Returns I/O errors from `out`.
11013#[allow(clippy::too_many_arguments)]
11014pub(crate) fn write_unified_diff_with_prefix_and_funcname_and_algorithm<W: std::io::Write>(
11015    out: &mut W,
11016    old_content: &str,
11017    new_content: &str,
11018    old_path: &str,
11019    new_path: &str,
11020    context_lines: usize,
11021    inter_hunk_context: usize,
11022    src_prefix: &str,
11023    dst_prefix: &str,
11024    funcname_matcher: Option<&FuncnameMatcher>,
11025    algorithm: similar::Algorithm,
11026    function_context: bool,
11027    use_git_histogram: bool,
11028    indent_heuristic: bool,
11029    quote_path_fully: bool,
11030) -> Result<()> {
11031    if function_context {
11032        let patch = unified_diff_with_function_context(
11033            old_content,
11034            new_content,
11035            old_path,
11036            new_path,
11037            context_lines,
11038            inter_hunk_context,
11039            src_prefix,
11040            dst_prefix,
11041            funcname_matcher,
11042            algorithm,
11043            indent_heuristic,
11044            quote_path_fully,
11045        );
11046        out.write_all(patch.as_bytes())?;
11047        return Ok(());
11048    }
11049
11050    if use_git_histogram {
11051        let patch = unified_diff_histogram_with_prefix_and_funcname_indent(
11052            old_content,
11053            new_content,
11054            old_path,
11055            new_path,
11056            context_lines,
11057            inter_hunk_context,
11058            src_prefix,
11059            dst_prefix,
11060            funcname_matcher,
11061            indent_heuristic,
11062            quote_path_fully,
11063        );
11064        out.write_all(patch.as_bytes())?;
11065        return Ok(());
11066    }
11067
11068    if algorithm == similar::Algorithm::Myers && !function_context {
11069        let patch = unified_diff_git_myers_with_prefix_and_funcname(
11070            old_content,
11071            new_content,
11072            old_path,
11073            new_path,
11074            context_lines,
11075            inter_hunk_context,
11076            src_prefix,
11077            dst_prefix,
11078            funcname_matcher,
11079            indent_heuristic,
11080            quote_path_fully,
11081        );
11082        out.write_all(patch.as_bytes())?;
11083        return Ok(());
11084    }
11085
11086    if algorithm == similar::Algorithm::Lcs && !function_context {
11087        let patch = unified_diff_git_minimal_with_prefix_and_funcname(
11088            old_content,
11089            new_content,
11090            old_path,
11091            new_path,
11092            context_lines,
11093            inter_hunk_context,
11094            src_prefix,
11095            dst_prefix,
11096            funcname_matcher,
11097            indent_heuristic,
11098            quote_path_fully,
11099        );
11100        out.write_all(patch.as_bytes())?;
11101        return Ok(());
11102    }
11103
11104    if algorithm == similar::Algorithm::Patience && !function_context {
11105        let patch = unified_diff_git_patience_with_prefix_and_funcname(
11106            old_content,
11107            new_content,
11108            old_path,
11109            new_path,
11110            context_lines,
11111            inter_hunk_context,
11112            src_prefix,
11113            dst_prefix,
11114            funcname_matcher,
11115            indent_heuristic,
11116            quote_path_fully,
11117        );
11118        out.write_all(patch.as_bytes())?;
11119        return Ok(());
11120    }
11121
11122    use crate::quote_path::format_diff_path_with_prefix;
11123    use similar::{udiff::UnifiedDiffHunk, TextDiff};
11124
11125    let diff = TextDiff::configure()
11126        .algorithm(algorithm)
11127        .diff_lines(old_content, new_content);
11128    let compacted_ops = diff_indent_heuristic::diff_lines_ops_compacted(
11129        old_content,
11130        new_content,
11131        algorithm,
11132        indent_heuristic,
11133    );
11134
11135    if old_path == "/dev/null" {
11136        out.write_all(b"--- /dev/null\n")?;
11137    } else if src_prefix.is_empty() {
11138        writeln!(out, "--- {old_path}")?;
11139    } else {
11140        out.write_all(b"--- ")?;
11141        out.write_all(
11142            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
11143        )?;
11144        out.write_all(b"\n")?;
11145    }
11146    if new_path == "/dev/null" {
11147        out.write_all(b"+++ /dev/null\n")?;
11148    } else if dst_prefix.is_empty() {
11149        writeln!(out, "+++ {new_path}")?;
11150    } else {
11151        out.write_all(b"+++ ")?;
11152        out.write_all(
11153            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
11154        )?;
11155        out.write_all(b"\n")?;
11156    }
11157
11158    let old_lines: Vec<&str> = old_content.lines().collect();
11159    let max_common_gap = context_lines
11160        .saturating_mul(2)
11161        .saturating_add(inter_hunk_context);
11162    let op_groups = group_diff_ops_gap(compacted_ops, context_lines, max_common_gap);
11163
11164    let mut hunk_buf = String::new();
11165    let mut eof_annot = UnifiedDiffNoNewlineAnnotator::new(old_content, new_content);
11166    for ops in op_groups {
11167        if ops.is_empty() {
11168            continue;
11169        }
11170        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
11171        hunk_buf.clear();
11172        use std::fmt::Write as FmtWrite;
11173        let _ = FmtWrite::write_fmt(&mut hunk_buf, format_args!("{hunk}"));
11174        let mut fragment = hunk_buf.clone();
11175        if let Some(first_newline) = hunk_buf.find('\n') {
11176            let header_line = &hunk_buf[..first_newline];
11177            let rest = &hunk_buf[first_newline..];
11178            if let Some(func_ctx) =
11179                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
11180            {
11181                fragment.clear();
11182                fragment.push_str(header_line);
11183                fragment.push(' ');
11184                fragment.push_str(&func_ctx);
11185                fragment.push_str(rest);
11186            }
11187        }
11188        eof_annot.write_hunk(out, &fragment)?;
11189    }
11190    Ok(())
11191}
11192
11193/// Same as [`unified_diff_with_prefix_and_funcname`] but allows callers to
11194/// choose the line diff algorithm used for hunk generation.
11195///
11196/// When `function_context` is true (`git diff -W`), hunks are expanded to
11197/// whole logical functions using the same rules as Git's `XDL_EMIT_FUNCCONTEXT`.
11198#[allow(clippy::too_many_arguments)]
11199pub fn unified_diff_with_prefix_and_funcname_and_algorithm(
11200    old_content: &str,
11201    new_content: &str,
11202    old_path: &str,
11203    new_path: &str,
11204    context_lines: usize,
11205    inter_hunk_context: usize,
11206    src_prefix: &str,
11207    dst_prefix: &str,
11208    funcname_matcher: Option<&FuncnameMatcher>,
11209    algorithm: similar::Algorithm,
11210    function_context: bool,
11211    use_git_histogram: bool,
11212    indent_heuristic: bool,
11213    quote_path_fully: bool,
11214) -> String {
11215    let mut output = Vec::new();
11216    if write_unified_diff_with_prefix_and_funcname_and_algorithm(
11217        &mut output,
11218        old_content,
11219        new_content,
11220        old_path,
11221        new_path,
11222        context_lines,
11223        inter_hunk_context,
11224        src_prefix,
11225        dst_prefix,
11226        funcname_matcher,
11227        algorithm,
11228        function_context,
11229        use_git_histogram,
11230        indent_heuristic,
11231        quote_path_fully,
11232    )
11233    .is_err()
11234    {
11235        output.clear();
11236    }
11237    String::from_utf8_lossy(&output).into_owned()
11238}
11239
11240/// Group diff ops into hunks like Git's `xdl_get_hunk`: two changes merge into one hunk while
11241/// the run of unchanged lines between them is at most `max_common_gap`
11242/// (`2 * context + inter_hunk_context`), and each hunk keeps at most `context` unchanged lines
11243/// at its edges. Unlike `similar::group_diff_ops`, the split threshold is decoupled from the
11244/// edge context so odd gap limits group exactly like Git.
11245pub(crate) fn group_diff_ops_gap(
11246    mut ops: Vec<similar::DiffOp>,
11247    context: usize,
11248    max_common_gap: usize,
11249) -> Vec<Vec<similar::DiffOp>> {
11250    use similar::DiffOp;
11251    if ops.is_empty() {
11252        return vec![];
11253    }
11254
11255    let mut pending_group = Vec::new();
11256    let mut rv = Vec::new();
11257
11258    if let Some(DiffOp::Equal {
11259        old_index,
11260        new_index,
11261        len,
11262    }) = ops.first_mut()
11263    {
11264        let offset = (*len).saturating_sub(context);
11265        *old_index += offset;
11266        *new_index += offset;
11267        *len -= offset;
11268    }
11269
11270    if let Some(DiffOp::Equal { len, .. }) = ops.last_mut() {
11271        *len -= (*len).saturating_sub(context);
11272    }
11273
11274    for op in ops.into_iter() {
11275        if let DiffOp::Equal {
11276            old_index,
11277            new_index,
11278            len,
11279        } = op
11280        {
11281            // End the current group and start a new one whenever the unchanged
11282            // run is too long to fuse the surrounding changes.
11283            if len > max_common_gap {
11284                pending_group.push(DiffOp::Equal {
11285                    old_index,
11286                    new_index,
11287                    len: context,
11288                });
11289                rv.push(pending_group);
11290                let offset = len.saturating_sub(context);
11291                pending_group = vec![DiffOp::Equal {
11292                    old_index: old_index + offset,
11293                    new_index: new_index + offset,
11294                    len: len - offset,
11295                }];
11296                continue;
11297            }
11298        }
11299        pending_group.push(op);
11300    }
11301
11302    match &pending_group[..] {
11303        &[] | &[similar::DiffOp::Equal { .. }] => {}
11304        _ => rv.push(pending_group),
11305    }
11306
11307    rv
11308}
11309#[expect(clippy::too_many_arguments)]
11310/// `git diff -W`: expand each hunk to include full function bodies (see Git `xemit.c`).
11311fn unified_diff_with_function_context(
11312    old_content: &str,
11313    new_content: &str,
11314    old_path: &str,
11315    new_path: &str,
11316    context_lines: usize,
11317    inter_hunk_context: usize,
11318    src_prefix: &str,
11319    dst_prefix: &str,
11320    funcname_matcher: Option<&FuncnameMatcher>,
11321    algorithm: similar::Algorithm,
11322    indent_heuristic: bool,
11323    quote_path_fully: bool,
11324) -> String {
11325    use crate::quote_path::format_diff_path_with_prefix;
11326    use similar::{udiff::UnifiedDiffHunk, TextDiff};
11327
11328    let diff = TextDiff::configure()
11329        .algorithm(algorithm)
11330        .diff_lines(old_content, new_content);
11331
11332    let old_lines: Vec<&str> = old_content.lines().collect();
11333    let new_lines: Vec<&str> = new_content.lines().collect();
11334    let n_old = old_lines.len();
11335    let n_new = new_lines.len();
11336
11337    // Group changes the way Git's xdl_get_hunk does: merge while the unchanged
11338    // gap is at most `2*context + inter_hunk_context`. `similar::group_diff_ops`
11339    // splits only at gap > `2*radius`, which over-merges changes in *different*
11340    // functions (e.g. a leading insertion and a body change) into one hunk and
11341    // then over-expands the function context (t4015 #136). Use the gap-correct
11342    // grouping (same as the non-function-context path).
11343    let max_common_gap = context_lines
11344        .saturating_mul(2)
11345        .saturating_add(inter_hunk_context);
11346    let all_ops = diff.ops().to_vec();
11347    let op_groups = group_diff_ops_gap(all_ops.clone(), context_lines, max_common_gap);
11348
11349    let mut ranges: Vec<(usize, usize, usize, usize)> = Vec::new();
11350
11351    for ops in op_groups {
11352        if ops.is_empty() {
11353            continue;
11354        }
11355        let i1_anchor = func_context_old_anchor(&ops, n_old);
11356        let i1_end = hunk_old_change_end_exclusive(&ops);
11357        let skip_preimage_pull =
11358            append_with_whole_function_added(&ops, n_old, n_new, &new_lines, funcname_matcher);
11359        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
11360        let hunk_str = format!("{hunk}");
11361        let header_line = hunk_str
11362            .lines()
11363            .next()
11364            .unwrap_or("")
11365            .trim_end_matches(['\r', '\n']);
11366        let Some((base_s1, _base_e1, _base_s2, _base_e2)) =
11367            parse_unified_hunk_header_ranges(header_line)
11368        else {
11369            continue;
11370        };
11371
11372        let ctx = context_lines;
11373        let (s1, e1, s2, e2) = if skip_preimage_pull {
11374            let s = n_old.saturating_sub(ctx);
11375            let s2 = map_old_line_to_new(&all_ops, s, n_new).min(n_new);
11376            (s, n_old, s2, n_new)
11377        } else {
11378            let mut s1 = base_s1.saturating_sub(ctx);
11379            let mut s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
11380
11381            let base_pre_s1 = i1_anchor.saturating_sub(ctx);
11382            if base_pre_s1 < s1 {
11383                s1 = base_pre_s1;
11384                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
11385            }
11386
11387            let fs1 = expand_func_pre_start(s1, i1_anchor, n_old, &old_lines, funcname_matcher);
11388            if fs1 < s1 {
11389                s1 = fs1;
11390                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
11391            }
11392
11393            // `i1_end` is the exclusive end of the changed region; its post-image
11394            // context is `ctx` lines (Git's `xche->i1 + xche->chg1 + lctx`). The
11395            // hunk's `base_e1` already includes the group's trailing context, so
11396            // use the change end + ctx directly to avoid double-counting context
11397            // (which over-extended the hunk and merged separate functions — t4015 #136).
11398            let mut e1 = (i1_end + ctx).min(n_old);
11399            let mut e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
11400            let fe1 = expand_func_post_end(e1, i1_end, n_old, &old_lines, funcname_matcher);
11401            if fe1 > e1 {
11402                e1 = fe1;
11403                e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
11404            }
11405            (s1, e1, s2, e2)
11406        };
11407
11408        ranges.push((s1, e1, s2, e2));
11409    }
11410
11411    // Merge ranges whose function-context expansion made them overlap on the old
11412    // side (Git emits a single hunk in that case).
11413    ranges.sort_by_key(|r| (r.0, r.2));
11414    let mut merged: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(ranges.len());
11415    for (s1, e1, s2, e2) in ranges {
11416        if let Some(last) = merged.last_mut() {
11417            if s1 < last.1 {
11418                last.1 = last.1.max(e1);
11419                last.3 = last.3.max(e2);
11420                continue;
11421            }
11422        }
11423        merged.push((s1, e1, s2, e2));
11424    }
11425    let ranges = merged;
11426
11427    let mut output = String::new();
11428    if old_path == "/dev/null" {
11429        output.push_str("--- /dev/null\n");
11430    } else if src_prefix.is_empty() {
11431        output.push_str(&format!("--- {old_path}\n"));
11432    } else {
11433        output.push_str("--- ");
11434        output.push_str(&format_diff_path_with_prefix(
11435            src_prefix,
11436            old_path,
11437            quote_path_fully,
11438        ));
11439        output.push('\n');
11440    }
11441    if new_path == "/dev/null" {
11442        output.push_str("+++ /dev/null\n");
11443    } else if dst_prefix.is_empty() {
11444        output.push_str(&format!("+++ {new_path}\n"));
11445    } else {
11446        output.push_str("+++ ");
11447        output.push_str(&format_diff_path_with_prefix(
11448            dst_prefix,
11449            new_path,
11450            quote_path_fully,
11451        ));
11452        output.push('\n');
11453    }
11454
11455    for (s1, e1, s2, e2) in ranges {
11456        if s1 >= e1 && s2 >= e2 {
11457            continue;
11458        }
11459        let old_seg =
11460            line_slice_for_diff_with_eof_nl(&old_lines, s1, e1, old_content.ends_with('\n'));
11461        let new_seg =
11462            line_slice_for_diff_with_eof_nl(&new_lines, s2, e2, new_content.ends_with('\n'));
11463        let inner_ctx = old_seg.lines().count().max(new_seg.lines().count()).max(1);
11464        let piece = unified_diff_with_prefix_and_funcname_and_algorithm(
11465            &old_seg,
11466            &new_seg,
11467            old_path,
11468            new_path,
11469            inner_ctx,
11470            0,
11471            src_prefix,
11472            dst_prefix,
11473            funcname_matcher,
11474            algorithm,
11475            false,
11476            false,
11477            indent_heuristic,
11478            quote_path_fully,
11479        );
11480        let shifted = shift_unified_hunk_headers_to_full_file(&piece, s1, s2);
11481        let with_func =
11482            enrich_unified_hunk_headers_funcname(&shifted, &old_lines, funcname_matcher);
11483        for line in with_func.lines() {
11484            if line.starts_with("--- ") || line.starts_with("+++ ") {
11485                continue;
11486            }
11487            output.push_str(line);
11488            output.push('\n');
11489        }
11490    }
11491
11492    output
11493}
11494
11495/// `piece` is a unified diff for a slice of the file; hunk headers use 1-based
11496/// coordinates relative to that slice. Shift them by `delta_old` / `delta_new`
11497/// (0-based offsets of the slice in the full file) so the combined patch applies
11498/// to the whole file.
11499fn shift_unified_hunk_headers_to_full_file(
11500    patch: &str,
11501    delta_old: usize,
11502    delta_new: usize,
11503) -> String {
11504    if delta_old == 0 && delta_new == 0 {
11505        return patch.to_owned();
11506    }
11507    let mut out = String::with_capacity(patch.len());
11508    for line in patch.lines() {
11509        if let Some(shifted) = shift_one_unified_hunk_header(line, delta_old, delta_new) {
11510            out.push_str(&shifted);
11511        } else {
11512            out.push_str(line);
11513        }
11514        out.push('\n');
11515    }
11516    out
11517}
11518
11519fn shift_one_unified_hunk_header(line: &str, delta_old: usize, delta_new: usize) -> Option<String> {
11520    let rest = line.strip_prefix("@@ ")?;
11521    let (old_chunk, after_plus) = rest.split_once(" +")?;
11522    let old_spec = old_chunk.strip_prefix('-')?;
11523    let (new_spec, suffix) = after_plus.split_once(" @@")?;
11524    let shifted_old = shift_unified_range_spec(old_spec, delta_old)?;
11525    let shifted_new = shift_unified_range_spec(new_spec, delta_new)?;
11526    Some(format!("@@ -{shifted_old} +{shifted_new} @@{suffix}"))
11527}
11528
11529fn shift_unified_range_spec(spec: &str, delta: usize) -> Option<String> {
11530    let spec = spec.trim();
11531    if let Some((start_s, count_s)) = spec.split_once(',') {
11532        let start: usize = start_s.parse().ok()?;
11533        let count: usize = count_s.parse().ok()?;
11534        Some(format!("{},{}", start.saturating_add(delta), count))
11535    } else {
11536        let start: usize = spec.parse().ok()?;
11537        Some(format!("{}", start.saturating_add(delta)))
11538    }
11539}
11540
11541/// Re-attach `@@ ... @@ <funcname>` using full-file line indices (inner diffs use slices).
11542fn enrich_unified_hunk_headers_funcname(
11543    patch: &str,
11544    full_old_lines: &[&str],
11545    funcname_matcher: Option<&FuncnameMatcher>,
11546) -> String {
11547    let mut out = String::with_capacity(patch.len());
11548    for line in patch.lines() {
11549        if let Some(fixed) = enrich_one_hunk_header_funcname(line, full_old_lines, funcname_matcher)
11550        {
11551            out.push_str(&fixed);
11552        } else {
11553            out.push_str(line);
11554        }
11555        out.push('\n');
11556    }
11557    out
11558}
11559
11560fn enrich_one_hunk_header_funcname(
11561    line: &str,
11562    full_old_lines: &[&str],
11563    funcname_matcher: Option<&FuncnameMatcher>,
11564) -> Option<String> {
11565    let after_at = line.strip_prefix("@@ ")?;
11566    let idx = after_at.find(" @@")?;
11567    let mid = after_at[..idx].trim();
11568    let tail = after_at[idx + 3..].trim_start();
11569    let header_for_parse = format!("@@ {mid} @@");
11570    let func = extract_function_context(&header_for_parse, full_old_lines, funcname_matcher, None);
11571    Some(if let Some(f) = func {
11572        format!("@@ {mid} @@ {f}")
11573    } else if !tail.is_empty() {
11574        format!("@@ {mid} @@ {tail}")
11575    } else {
11576        format!("@@ {mid} @@")
11577    })
11578}
11579
11580fn line_slice_for_diff_with_eof_nl(
11581    lines: &[&str],
11582    start: usize,
11583    end: usize,
11584    full_file_ends_with_newline: bool,
11585) -> String {
11586    if start >= end {
11587        return String::new();
11588    }
11589    let mut s = lines[start..end].join("\n");
11590    let slice_is_suffix_of_file = end == lines.len();
11591    let need_trailing_nl = if slice_is_suffix_of_file {
11592        full_file_ends_with_newline
11593    } else {
11594        true
11595    };
11596    if need_trailing_nl && !s.ends_with('\n') {
11597        s.push('\n');
11598    }
11599    s
11600}
11601
11602/// Map a 0-based old line index to the corresponding 0-based new line index using the full-file
11603/// diff ops (Git aligns context across deletions/insertions).
11604fn map_old_line_to_new(ops: &[similar::DiffOp], old_line: usize, n_new: usize) -> usize {
11605    use similar::DiffOp;
11606    let mut n = 0usize;
11607    for op in ops {
11608        match *op {
11609            DiffOp::Equal {
11610                old_index,
11611                new_index,
11612                len,
11613            } => {
11614                if old_index + len <= old_line {
11615                    n = new_index + len;
11616                    continue;
11617                }
11618                if old_index < old_line {
11619                    let take = old_line - old_index;
11620                    return (new_index + take).min(n_new);
11621                }
11622                return new_index.min(n_new);
11623            }
11624            DiffOp::Delete {
11625                old_index,
11626                old_len,
11627                new_index,
11628            } => {
11629                if old_index + old_len <= old_line {
11630                    n = new_index;
11631                    continue;
11632                }
11633                if old_index < old_line {
11634                    return new_index.min(n_new);
11635                }
11636            }
11637            DiffOp::Insert {
11638                old_index,
11639                new_index,
11640                new_len,
11641            } => {
11642                if old_index < old_line {
11643                    n = new_index + new_len;
11644                    continue;
11645                }
11646                if old_index == old_line {
11647                    // `old_line` is an exclusive end or insertion point aligned with this insert
11648                    // (e.g. EOF append maps to after the inserted block).
11649                    return (new_index + new_len).min(n_new);
11650                }
11651                return new_index.min(n_new);
11652            }
11653            DiffOp::Replace {
11654                old_index,
11655                old_len,
11656                new_index,
11657                new_len,
11658            } => {
11659                if old_index + old_len <= old_line {
11660                    n = new_index + new_len;
11661                    continue;
11662                }
11663                if old_index < old_line {
11664                    let into_old = old_line - old_index;
11665                    let mapped = new_index + into_old.min(new_len);
11666                    return mapped.min(n_new);
11667                }
11668                return new_index.min(n_new);
11669            }
11670        }
11671    }
11672    n.min(n_new)
11673}
11674
11675/// Parse `@@ -old +new @@` into 0-based half-open ranges in each file.
11676fn parse_unified_hunk_header_ranges(header: &str) -> Option<(usize, usize, usize, usize)> {
11677    let rest = header.strip_prefix("@@ ")?;
11678    let (old_tok, rest2) = rest.split_once(" +")?;
11679    let old_tok = old_tok.strip_prefix('-')?;
11680    let new_tok = rest2.split_once(" @@").map(|(a, _)| a)?;
11681
11682    fn parse_side(spec: &str) -> Option<(usize, usize)> {
11683        let spec = spec.trim();
11684        let (start_one_based, count) = if let Some((a, b)) = spec.split_once(',') {
11685            (a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)
11686        } else {
11687            let s = spec.parse::<usize>().ok()?;
11688            (s, 1usize)
11689        };
11690        let s0 = start_one_based.saturating_sub(1);
11691        let e0 = s0.saturating_add(count);
11692        Some((s0, e0))
11693    }
11694
11695    let (os, oe) = parse_side(old_tok)?;
11696    let (ns, ne) = parse_side(new_tok)?;
11697    Some((os, oe, ns, ne))
11698}
11699
11700/// Git `xemit.c`: when a hunk only inserts at EOF (first inserted line is `new_index == n_old`)
11701/// and the added text already contains a funcname line, do not pull extra context from the preimage.
11702fn append_with_whole_function_added(
11703    ops: &[similar::DiffOp],
11704    n_old: usize,
11705    n_new: usize,
11706    new_lines: &[&str],
11707    matcher: Option<&FuncnameMatcher>,
11708) -> bool {
11709    use similar::DiffOp;
11710    if n_old == 0 {
11711        return false;
11712    }
11713    let mut only_ins_or_eq = true;
11714    let mut min_new_ins = usize::MAX;
11715    for op in ops {
11716        match *op {
11717            DiffOp::Equal { .. } => {}
11718            DiffOp::Insert {
11719                new_index, new_len, ..
11720            } => {
11721                min_new_ins = min_new_ins.min(new_index);
11722                if new_len == 0 {
11723                    only_ins_or_eq = false;
11724                }
11725            }
11726            DiffOp::Delete { .. } | DiffOp::Replace { .. } => {
11727                only_ins_or_eq = false;
11728            }
11729        }
11730    }
11731    let mut insert_at_eof = false;
11732    for op in ops {
11733        if let DiffOp::Insert { old_index, .. } = *op {
11734            if old_index == n_old {
11735                insert_at_eof = true;
11736                break;
11737            }
11738        }
11739    }
11740    let append_at_eof = min_new_ins == n_old || insert_at_eof;
11741    if !only_ins_or_eq || !append_at_eof || min_new_ins == usize::MAX {
11742        return false;
11743    }
11744    // Git only skips preimage pull when the inserted block is clearly a new logical
11745    // function (see `xemit.c` walking `xdf2` for `is_func_rec`). A loose "any line
11746    // looks like a function" check would match `return` / `printf` and break `-W`
11747    // hunks that still need preimage context (t4051 `extended`).
11748    let mut j = min_new_ins;
11749    while j < n_new {
11750        let line = new_lines[j];
11751        if line.trim().is_empty() {
11752            j += 1;
11753            continue;
11754        }
11755        if let Some(m) = matcher {
11756            if m.match_line(line).is_some() {
11757                return true;
11758            }
11759        } else if inserted_block_starts_with_c_like_function_definition(line) {
11760            return true;
11761        }
11762        j += 1;
11763    }
11764    false
11765}
11766
11767fn inserted_block_starts_with_c_like_function_definition(line: &str) -> bool {
11768    let t = line.trim_start();
11769    let Some(open_paren) = t.find('(') else {
11770        return false;
11771    };
11772    let head = &t[..open_paren];
11773    let tokens: Vec<&str> = head.split_whitespace().collect();
11774    if tokens.len() < 2 {
11775        // `printf(...)`, `return (`, etc. — not `return_type name(`.
11776        return false;
11777    }
11778    let nameish = tokens.last().copied().unwrap_or("");
11779    let name = nameish.trim_end_matches(['*', '&']);
11780    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
11781        return false;
11782    }
11783    let type_or_modifier = |tok: &str| {
11784        matches!(
11785            tok,
11786            "static"
11787                | "extern"
11788                | "inline"
11789                | "void"
11790                | "int"
11791                | "char"
11792                | "short"
11793                | "long"
11794                | "float"
11795                | "double"
11796                | "unsigned"
11797                | "signed"
11798                | "struct"
11799                | "enum"
11800                | "union"
11801                | "const"
11802                | "volatile"
11803                | "typedef"
11804        )
11805    };
11806    tokens[..tokens.len() - 1]
11807        .iter()
11808        .any(|tok| type_or_modifier(tok))
11809}
11810
11811fn hunk_old_change_end_exclusive(ops: &[similar::DiffOp]) -> usize {
11812    use similar::DiffOp;
11813    let mut max_o = 0usize;
11814    for op in ops {
11815        match *op {
11816            DiffOp::Delete {
11817                old_index, old_len, ..
11818            } => {
11819                max_o = max_o.max(old_index + old_len);
11820            }
11821            DiffOp::Replace {
11822                old_index, old_len, ..
11823            } => {
11824                max_o = max_o.max(old_index + old_len);
11825            }
11826            DiffOp::Insert { old_index, .. } => {
11827                // Pure insertions do not consume old lines; Git's post-context anchor is the
11828                // insertion point (`old_index`), not 0 (t4051 `extended`).
11829                max_o = max_o.max(old_index);
11830            }
11831            DiffOp::Equal { .. } => {}
11832        }
11833    }
11834    max_o
11835}
11836
11837fn func_context_old_anchor(ops: &[similar::DiffOp], n_old: usize) -> usize {
11838    use similar::DiffOp;
11839    let mut has_delete_or_replace = false;
11840    let mut min_del = usize::MAX;
11841    let mut min_ins_old = usize::MAX;
11842
11843    for op in ops {
11844        match *op {
11845            DiffOp::Delete {
11846                old_index, old_len, ..
11847            } => {
11848                has_delete_or_replace = true;
11849                min_del = min_del.min(old_index);
11850                min_del = min_del.min(old_index + old_len.saturating_sub(1));
11851            }
11852            DiffOp::Replace {
11853                old_index, old_len, ..
11854            } => {
11855                has_delete_or_replace = true;
11856                min_del = min_del.min(old_index);
11857                min_del = min_del.min(old_index + old_len.saturating_sub(1));
11858            }
11859            DiffOp::Insert { old_index, .. } => {
11860                min_ins_old = min_ins_old.min(old_index);
11861            }
11862            DiffOp::Equal { .. } => {}
11863        }
11864    }
11865
11866    let mut i1 = if has_delete_or_replace {
11867        min_del
11868    } else if min_ins_old != usize::MAX {
11869        min_ins_old
11870    } else {
11871        0
11872    };
11873
11874    let pure_insert = ops
11875        .iter()
11876        .all(|op| matches!(op, DiffOp::Insert { .. } | DiffOp::Equal { .. }))
11877        && ops.iter().any(|op| matches!(op, DiffOp::Insert { .. }));
11878
11879    if pure_insert && i1 >= n_old && n_old > 0 {
11880        i1 = n_old - 1;
11881    }
11882
11883    i1.min(n_old.saturating_sub(1))
11884}
11885
11886fn expand_func_pre_start(
11887    s1: usize,
11888    i1: usize,
11889    n_old: usize,
11890    old_lines: &[&str],
11891    matcher: Option<&FuncnameMatcher>,
11892) -> usize {
11893    if n_old == 0 {
11894        return s1;
11895    }
11896    let i1 = i1.min(n_old.saturating_sub(1));
11897    let mut fs1 = get_func_line_backward(old_lines, i1, matcher).unwrap_or(i1);
11898    while fs1 > 0
11899        && !is_line_empty_for_func_context(old_lines[fs1 - 1])
11900        && !is_func_line(old_lines[fs1 - 1], matcher)
11901    {
11902        fs1 -= 1;
11903    }
11904    s1.min(fs1)
11905}
11906
11907fn expand_func_post_end(
11908    e1: usize,
11909    i1_end: usize,
11910    n_old: usize,
11911    old_lines: &[&str],
11912    matcher: Option<&FuncnameMatcher>,
11913) -> usize {
11914    let from = i1_end.min(n_old);
11915    let fe1 = get_func_line_forward(old_lines, from, matcher).unwrap_or(n_old);
11916    let mut fe1_adj = fe1;
11917    while fe1_adj > 0 && is_line_empty_for_func_context(old_lines[fe1_adj - 1]) {
11918        fe1_adj -= 1;
11919    }
11920    e1.max(fe1_adj).min(n_old)
11921}
11922
11923fn is_line_empty_for_func_context(line: &str) -> bool {
11924    line.chars().all(|c| c.is_whitespace())
11925}
11926
11927pub(crate) fn is_func_line(line: &str, matcher: Option<&FuncnameMatcher>) -> bool {
11928    if let Some(m) = matcher {
11929        return m.match_line(line).is_some();
11930    }
11931    let t = line.trim_end_matches(['\n', '\r']);
11932    if t.is_empty() {
11933        return false;
11934    }
11935    let b = t.as_bytes()[0];
11936    if !(b.is_ascii_alphabetic() || b == b'_' || b == b'$') {
11937        return false;
11938    }
11939    true
11940}
11941
11942fn get_func_line_backward(
11943    old_lines: &[&str],
11944    start: usize,
11945    matcher: Option<&FuncnameMatcher>,
11946) -> Option<usize> {
11947    let mut l = start.min(old_lines.len().saturating_sub(1));
11948    if old_lines.is_empty() {
11949        return None;
11950    }
11951    loop {
11952        if is_func_line(old_lines[l], matcher) {
11953            return Some(l);
11954        }
11955        if l == 0 {
11956            break;
11957        }
11958        l -= 1;
11959    }
11960    None
11961}
11962
11963fn get_func_line_forward(
11964    old_lines: &[&str],
11965    start: usize,
11966    matcher: Option<&FuncnameMatcher>,
11967) -> Option<usize> {
11968    let mut l = start;
11969    while l < old_lines.len() {
11970        if is_func_line(old_lines[l], matcher) {
11971            return Some(l);
11972        }
11973        l += 1;
11974    }
11975    None
11976}
11977#[expect(clippy::too_many_arguments)]
11978/// Compute a unified diff with anchored lines.
11979///
11980/// Anchored lines that appear exactly once in both old and new content are
11981/// forced to match, splitting the diff into segments around those anchor points.
11982/// This produces diffs where the anchored text stays as context and surrounding
11983/// lines are shown as additions/removals.
11984///
11985/// Segment diffs use `algorithm`. When `use_git_histogram` is true, histogram uses the
11986/// Git-compatible histogram engine; otherwise `algorithm` is passed to `similar`.
11987pub fn anchored_unified_diff(
11988    old_content: &str,
11989    new_content: &str,
11990    old_path: &str,
11991    new_path: &str,
11992    context_lines: usize,
11993    anchors: &[String],
11994    algorithm: similar::Algorithm,
11995    use_git_histogram: bool,
11996    indent_heuristic: bool,
11997    quote_path_fully: bool,
11998) -> String {
11999    use crate::quote_path::format_diff_path_with_prefix;
12000    use similar::TextDiff;
12001
12002    let old_lines: Vec<&str> = old_content.lines().collect();
12003    let new_lines: Vec<&str> = new_content.lines().collect();
12004
12005    // Find anchored lines that appear exactly once in both old and new
12006    let mut anchor_pairs: Vec<(usize, usize)> = Vec::new(); // (old_idx, new_idx)
12007
12008    for anchor in anchors {
12009        let anchor_str = anchor.as_str();
12010
12011        // Count occurrences in old
12012        let old_positions: Vec<usize> = old_lines
12013            .iter()
12014            .enumerate()
12015            .filter(|(_, l)| l.trim_end() == anchor_str)
12016            .map(|(i, _)| i)
12017            .collect();
12018
12019        // Count occurrences in new
12020        let new_positions: Vec<usize> = new_lines
12021            .iter()
12022            .enumerate()
12023            .filter(|(_, l)| l.trim_end() == anchor_str)
12024            .map(|(i, _)| i)
12025            .collect();
12026
12027        // Only anchor if unique in both
12028        if old_positions.len() == 1 && new_positions.len() == 1 {
12029            anchor_pairs.push((old_positions[0], new_positions[0]));
12030        }
12031    }
12032
12033    // If no valid anchors, fall back to normal diff
12034    if anchor_pairs.is_empty() {
12035        return unified_diff_with_prefix_and_funcname_and_algorithm(
12036            old_content,
12037            new_content,
12038            old_path,
12039            new_path,
12040            context_lines,
12041            0,
12042            "a/",
12043            "b/",
12044            None,
12045            algorithm,
12046            false,
12047            use_git_histogram,
12048            indent_heuristic,
12049            quote_path_fully,
12050        );
12051    }
12052
12053    // Sort anchor pairs by their position in the old file
12054    anchor_pairs.sort_by_key(|&(old_idx, _)| old_idx);
12055
12056    // Filter to only keep pairs where new positions are also increasing
12057    // (longest increasing subsequence of new positions)
12058    let mut filtered: Vec<(usize, usize)> = Vec::new();
12059    for &pair in &anchor_pairs {
12060        if filtered.is_empty() || filtered.last().is_some_and(|last| pair.1 > last.1) {
12061            filtered.push(pair);
12062        }
12063    }
12064    let anchor_pairs = filtered;
12065
12066    // Build a modified version of old/new where we diff segments between anchors.
12067    // We'll construct the diff by processing segments:
12068    // - Before first anchor
12069    // - Between consecutive anchors
12070    // - After last anchor
12071    // Each anchor line itself is a fixed context match.
12072
12073    // Collect all diff operations
12074    struct LineDiffOp {
12075        tag: char, // ' ', '+', '-'
12076        line: String,
12077    }
12078
12079    let append_segment_diff =
12080        |ops: &mut Vec<LineDiffOp>, old_seg_input: &str, new_seg_input: &str| {
12081            use similar::ChangeTag;
12082            let old_ls: Vec<&str> = old_seg_input.lines().collect();
12083            let new_ls: Vec<&str> = new_seg_input.lines().collect();
12084            if old_ls.is_empty() && new_ls.is_empty() {
12085                return;
12086            }
12087            let seg_diff = TextDiff::configure()
12088                .algorithm(algorithm)
12089                .diff_slices(&old_ls, &new_ls);
12090            let raw = seg_diff.ops().to_vec();
12091            let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
12092                &raw,
12093                &old_ls,
12094                &new_ls,
12095                indent_heuristic,
12096            );
12097            for op in &compacted {
12098                for ch in op.iter_changes(&old_ls, &new_ls) {
12099                    let t = match ch.tag() {
12100                        ChangeTag::Equal => ' ',
12101                        ChangeTag::Delete => '-',
12102                        ChangeTag::Insert => '+',
12103                    };
12104                    ops.push(LineDiffOp {
12105                        tag: t,
12106                        line: ch.value().to_string(),
12107                    });
12108                }
12109            }
12110        };
12111
12112    let mut ops: Vec<LineDiffOp> = Vec::new();
12113    let mut old_pos = 0usize;
12114    let mut new_pos = 0usize;
12115
12116    for &(old_anchor, new_anchor) in &anchor_pairs {
12117        // Diff the segment before this anchor
12118        let old_segment: Vec<&str> = old_lines[old_pos..old_anchor].to_vec();
12119        let new_segment: Vec<&str> = new_lines[new_pos..new_anchor].to_vec();
12120
12121        let old_seg_text = old_segment.join("\n");
12122        let new_seg_text = new_segment.join("\n");
12123
12124        if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
12125            let old_seg_input = if old_seg_text.is_empty() {
12126                String::new()
12127            } else {
12128                format!("{}\n", old_seg_text)
12129            };
12130            let new_seg_input = if new_seg_text.is_empty() {
12131                String::new()
12132            } else {
12133                format!("{}\n", new_seg_text)
12134            };
12135            append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
12136        }
12137
12138        // The anchor line itself is always context
12139        ops.push(LineDiffOp {
12140            tag: ' ',
12141            line: old_lines[old_anchor].to_string(),
12142        });
12143
12144        old_pos = old_anchor + 1;
12145        new_pos = new_anchor + 1;
12146    }
12147
12148    // Diff the remaining segment after the last anchor
12149    let old_segment: Vec<&str> = old_lines[old_pos..].to_vec();
12150    let new_segment: Vec<&str> = new_lines[new_pos..].to_vec();
12151    let old_seg_text = old_segment.join("\n");
12152    let new_seg_text = new_segment.join("\n");
12153
12154    if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
12155        let old_seg_input = if old_seg_text.is_empty() {
12156            String::new()
12157        } else {
12158            format!("{}\n", old_seg_text)
12159        };
12160        let new_seg_input = if new_seg_text.is_empty() {
12161            String::new()
12162        } else {
12163            format!("{}\n", new_seg_text)
12164        };
12165        append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
12166    }
12167
12168    // Now format as unified diff with hunks
12169    let mut output = String::new();
12170    if old_path == "/dev/null" {
12171        output.push_str("--- /dev/null\n");
12172    } else {
12173        output.push_str("--- ");
12174        output.push_str(&format_diff_path_with_prefix(
12175            "a/",
12176            old_path,
12177            quote_path_fully,
12178        ));
12179        output.push('\n');
12180    }
12181    if new_path == "/dev/null" {
12182        output.push_str("+++ /dev/null\n");
12183    } else {
12184        output.push_str("+++ ");
12185        output.push_str(&format_diff_path_with_prefix(
12186            "b/",
12187            new_path,
12188            quote_path_fully,
12189        ));
12190        output.push('\n');
12191    }
12192
12193    // Group ops into hunks with context
12194    let total_ops = ops.len();
12195    if total_ops == 0 {
12196        return output;
12197    }
12198
12199    // Find ranges of changes
12200    let mut hunks: Vec<(usize, usize)> = Vec::new(); // (start, end) indices into ops
12201    let mut i = 0;
12202    while i < total_ops {
12203        if ops[i].tag != ' ' {
12204            let start = i.saturating_sub(context_lines);
12205            let mut end = i;
12206            // Extend to include consecutive changes and their context
12207            while end < total_ops {
12208                if ops[end].tag != ' ' {
12209                    end += 1;
12210                    continue;
12211                }
12212                // Check if there's another change within context_lines
12213                let mut next_change = end;
12214                while next_change < total_ops && ops[next_change].tag == ' ' {
12215                    next_change += 1;
12216                }
12217                if next_change < total_ops && next_change - end <= context_lines * 2 {
12218                    end = next_change + 1;
12219                } else {
12220                    end = (end + context_lines).min(total_ops);
12221                    break;
12222                }
12223            }
12224            // Merge with previous hunk if overlapping
12225            if let Some(last) = hunks.last_mut() {
12226                if start <= last.1 {
12227                    last.1 = end;
12228                } else {
12229                    hunks.push((start, end));
12230                }
12231            } else {
12232                hunks.push((start, end));
12233            }
12234            i = end;
12235        } else {
12236            i += 1;
12237        }
12238    }
12239
12240    // Output each hunk
12241    for (start, end) in hunks {
12242        // Count old/new lines in this hunk
12243        let mut old_start = 1usize;
12244        let mut new_start = 1usize;
12245        // Calculate line numbers by counting ops before this hunk
12246        for op in &ops[..start] {
12247            match op.tag {
12248                ' ' => {
12249                    old_start += 1;
12250                    new_start += 1;
12251                }
12252                '-' => {
12253                    old_start += 1;
12254                }
12255                '+' => {
12256                    new_start += 1;
12257                }
12258                _ => {}
12259            }
12260        }
12261        let mut old_count = 0usize;
12262        let mut new_count = 0usize;
12263        for op in &ops[start..end] {
12264            match op.tag {
12265                ' ' => {
12266                    old_count += 1;
12267                    new_count += 1;
12268                }
12269                '-' => {
12270                    old_count += 1;
12271                }
12272                '+' => {
12273                    new_count += 1;
12274                }
12275                _ => {}
12276            }
12277        }
12278
12279        output.push_str(&format!(
12280            "@@ -{},{} +{},{} @@\n",
12281            old_start, old_count, new_start, new_count
12282        ));
12283        for op in &ops[start..end] {
12284            output.push(op.tag);
12285            output.push_str(&op.line);
12286            output.push('\n');
12287        }
12288    }
12289
12290    output
12291}
12292
12293/// Extract function context for a hunk header.
12294///
12295/// Given a hunk header like `@@ -8,7 +8,7 @@`, find the last line
12296/// before line 8 in the old content that looks like a function header
12297/// (starts with a non-whitespace character, like Git's default).
12298fn func_context_line_matches(line: &str, funcname_matcher: Option<&FuncnameMatcher>) -> bool {
12299    if let Some(m) = funcname_matcher {
12300        return m.match_line(line).is_some();
12301    }
12302    is_func_line(line, None)
12303}
12304
12305fn extract_function_context(
12306    header: &str,
12307    old_lines: &[&str],
12308    funcname_matcher: Option<&FuncnameMatcher>,
12309    _source_path: Option<&str>,
12310) -> Option<String> {
12311    // Parse the old start line number from "@@ -<start>,<count> ..."
12312    let at_pos = header.find("-")?;
12313    let rest = &header[at_pos + 1..];
12314    let comma_or_space = rest.find([',', ' '])?;
12315    let start_str = &rest[..comma_or_space];
12316    let start_line: usize = start_str.parse().ok()?;
12317
12318    // Parse the old line count; "@@ -<start>,<count> ..." (no comma means count 1).
12319    // Only look for the comma inside the old-range token itself — searching the
12320    // whole remainder would pick up the comma from the new side (e.g. "+0,0").
12321    let old_token_end = rest.find([' ', '\t']).unwrap_or(rest.len());
12322    let old_token = &rest[..old_token_end];
12323    let old_count: usize = if let Some(comma) = old_token.find(',') {
12324        old_token[comma + 1..].parse().unwrap_or(1)
12325    } else {
12326        1
12327    };
12328
12329    if start_line == 0 {
12330        return None;
12331    }
12332
12333    // Look backwards for a line that matches the funcname pattern. start_line is
12334    // 1-indexed. For a normal hunk the first changed pre-image line is
12335    // old_lines[start_line-1], so we search lines strictly before it
12336    // (old_lines[0..start_line-1]). For a pure insertion (old count 0) the
12337    // content is inserted *after* old line start_line, so Git's function search
12338    // begins at that line itself: search old_lines[0..start_line].
12339    if start_line == 0 {
12340        return None;
12341    }
12342    let search_end = if old_count == 0 {
12343        start_line.min(old_lines.len())
12344    } else {
12345        if start_line <= 1 {
12346            return None;
12347        }
12348        (start_line - 1).min(old_lines.len())
12349    };
12350    let truncate = |line: &str| {
12351        let mut end = line.len().min(80);
12352        while end > 0 && !line.is_char_boundary(end) {
12353            end -= 1;
12354        }
12355        let mut out = line[..end].to_owned();
12356        let tail = line[end..].trim_start();
12357        // Git drops trailing whitespace on truncated funcname lines when the
12358        // remainder of the source line continues with normal text (t2021-style
12359        // shell tests), but keeps it when the next token is a string delimiter
12360        // (gettext `.po` msgstr lines).
12361        if tail.is_empty()
12362            || (!tail.starts_with('"')
12363                && !tail.starts_with('\'')
12364                && !line.starts_with("msgstr")
12365                && !line.starts_with("msgid"))
12366        {
12367            out = out.trim_end().to_owned();
12368        }
12369        out
12370    };
12371
12372    for i in (0..search_end).rev() {
12373        let line = old_lines[i];
12374        if line.is_empty() {
12375            continue;
12376        }
12377        if func_context_line_matches(line, funcname_matcher) {
12378            if let Some(matcher) = funcname_matcher {
12379                if let Some(matched) = matcher.match_line(line) {
12380                    return Some(truncate(&matched));
12381                }
12382                continue;
12383            }
12384            return Some(truncate(line));
12385        }
12386    }
12387    None
12388}
12389
12390fn last_line_index_without_trailing_newline(text: &str) -> Option<usize> {
12391    LineSide::from_text(text).incomplete_last_line_index()
12392}
12393
12394fn parse_unified_hunk_starts(header: &str) -> Option<(usize, usize)> {
12395    let rest = header.strip_prefix("@@ ")?;
12396    let (old_part, new_part) = rest.split_once(" +")?;
12397    let old_start = parse_unified_range_start(old_part.strip_prefix('-')?)?;
12398    let new_start = parse_unified_range_start(new_part.split(" @@").next()?)?;
12399    Some((old_start, new_start))
12400}
12401
12402fn parse_unified_range_start(spec: &str) -> Option<usize> {
12403    let spec = spec.trim();
12404    if let Some((start, _)) = spec.split_once(',') {
12405        start.parse().ok()
12406    } else {
12407        spec.parse().ok()
12408    }
12409}
12410
12411/// Tracks hunk line numbers while streaming `\ No newline at end of file` markers.
12412struct UnifiedDiffNoNewlineAnnotator {
12413    old_last: Option<usize>,
12414    new_last: Option<usize>,
12415    old_line: usize,
12416    new_line: usize,
12417    passthrough: bool,
12418}
12419
12420impl UnifiedDiffNoNewlineAnnotator {
12421    fn new(old: &str, new: &str) -> Self {
12422        let old_last = last_line_index_without_trailing_newline(old);
12423        let new_last = last_line_index_without_trailing_newline(new);
12424        Self {
12425            old_last,
12426            new_last,
12427            old_line: 1,
12428            new_line: 1,
12429            passthrough: old_last.is_none() && new_last.is_none(),
12430        }
12431    }
12432
12433    fn write_hunk<W: std::io::Write>(&mut self, out: &mut W, patch: &str) -> Result<()> {
12434        if self.passthrough {
12435            out.write_all(patch.as_bytes())?;
12436            return Ok(());
12437        }
12438        let mut lines = patch.lines().peekable();
12439        while let Some(line) = lines.next() {
12440            self.write_annotated_line(out, line, &mut lines)?;
12441        }
12442        Ok(())
12443    }
12444
12445    fn write_annotated_line<W: std::io::Write>(
12446        &mut self,
12447        out: &mut W,
12448        line: &str,
12449        lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
12450    ) -> Result<()> {
12451        if let Some((o, n)) = line
12452            .strip_prefix("@@ ")
12453            .and_then(|_| parse_unified_hunk_starts(line))
12454        {
12455            self.old_line = o;
12456            self.new_line = n;
12457            writeln!(out, "{line}")?;
12458            return Ok(());
12459        }
12460        if line.starts_with("--- ")
12461            || line.starts_with("+++ ")
12462            || line.starts_with("diff --git ")
12463            || line.starts_with("diff --cc ")
12464            || line.starts_with("diff --combined ")
12465        {
12466            writeln!(out, "{line}")?;
12467            return Ok(());
12468        }
12469        if line.starts_with('-') {
12470            writeln!(out, "{line}")?;
12471            let already_marked = lines
12472                .peek()
12473                .is_some_and(|next| *next == "\\ No newline at end of file");
12474            if !already_marked && self.old_last == Some(self.old_line.saturating_sub(1)) {
12475                writeln!(out, "\\ No newline at end of file")?;
12476            }
12477            self.old_line += 1;
12478            return Ok(());
12479        }
12480        if line.starts_with('+') {
12481            writeln!(out, "{line}")?;
12482            let already_marked = lines
12483                .peek()
12484                .is_some_and(|next| *next == "\\ No newline at end of file");
12485            if !already_marked && self.new_last == Some(self.new_line.saturating_sub(1)) {
12486                writeln!(out, "\\ No newline at end of file")?;
12487            }
12488            self.new_line += 1;
12489            return Ok(());
12490        }
12491        if line.starts_with(' ') {
12492            writeln!(out, "{line}")?;
12493            let already_marked = lines
12494                .peek()
12495                .is_some_and(|next| *next == "\\ No newline at end of file");
12496            if !already_marked {
12497                let old_idx = self.old_line.saturating_sub(1);
12498                let new_idx = self.new_line.saturating_sub(1);
12499                if self.old_last == Some(old_idx) || self.new_last == Some(new_idx) {
12500                    writeln!(out, "\\ No newline at end of file")?;
12501                }
12502            }
12503            self.old_line += 1;
12504            self.new_line += 1;
12505            return Ok(());
12506        }
12507        writeln!(out, "{line}")?;
12508        Ok(())
12509    }
12510}
12511
12512/// Insert Git `\ No newline at end of file` after `-`/`+` hunk lines when that side of the
12513/// file lacks a trailing newline on the last line.
12514#[must_use]
12515pub fn annotate_unified_diff_no_newline_at_eof(patch: &str, old: &str, new: &str) -> String {
12516    let mut annotator = UnifiedDiffNoNewlineAnnotator::new(old, new);
12517    if annotator.passthrough {
12518        return patch.to_string();
12519    }
12520    let mut out = Vec::with_capacity(patch.len() + 64);
12521    let mut lines = patch.lines().peekable();
12522    while let Some(line) = lines.next() {
12523        if annotator
12524            .write_annotated_line(&mut out, line, &mut lines)
12525            .is_err()
12526        {
12527            return patch.to_string();
12528        }
12529    }
12530    String::from_utf8(out).unwrap_or_else(|_| patch.to_string())
12531}
12532
12533/// Normalise one line like Git's `-b` / `--ignore-space-change`.
12534#[must_use]
12535pub fn normalize_ignore_space_change_line(line: &str) -> String {
12536    let mut result = String::with_capacity(line.len());
12537    let mut in_space = false;
12538    for c in line.chars() {
12539        if c.is_whitespace() {
12540            if !in_space {
12541                result.push(' ');
12542                in_space = true;
12543            }
12544        } else {
12545            result.push(c);
12546            in_space = false;
12547        }
12548    }
12549    while result.ends_with(' ') {
12550        result.pop();
12551    }
12552    result
12553}
12554
12555/// Normalise text like Git's `-b` / `--ignore-space-change`: on each line, collapse runs of
12556/// whitespace to a single ASCII space and trim trailing spaces.
12557///
12558/// Line breaks are preserved by splitting on [`str::lines`] and rejoining with `\n` (same approach
12559/// as the porcelain `diff` whitespace handling in `grit`).
12560#[must_use]
12561pub fn normalize_ignore_space_change(content: &str) -> String {
12562    content
12563        .lines()
12564        .map(normalize_ignore_space_change_line)
12565        .collect::<Vec<_>>()
12566        .join("\n")
12567}
12568
12569/// Count insertions and deletions using the given line-diff algorithm.
12570///
12571/// Uses the same compacted line alignment as unified patches and [`crate::diff::line_change_counts`].
12572#[must_use]
12573pub fn count_changes_with_algorithm(
12574    old_content: &str,
12575    new_content: &str,
12576    algorithm: similar::Algorithm,
12577    use_git_histogram: bool,
12578) -> (usize, usize) {
12579    use crate::diff::options::{DiffAlgorithm, DiffOptions};
12580    let diff_algorithm = if use_git_histogram {
12581        DiffAlgorithm::Histogram
12582    } else {
12583        match algorithm {
12584            similar::Algorithm::Myers => DiffAlgorithm::Myers,
12585            similar::Algorithm::Lcs => DiffAlgorithm::Minimal,
12586            similar::Algorithm::Patience => DiffAlgorithm::Patience,
12587            _ => DiffAlgorithm::Myers,
12588        }
12589    };
12590    count_line_changes_with_options(
12591        old_content,
12592        new_content,
12593        &DiffOptions {
12594            algorithm: diff_algorithm,
12595            ..DiffOptions::default()
12596        },
12597    )
12598}
12599
12600/// Count insertions and deletions using Git's histogram line-diff engine.
12601#[must_use]
12602#[allow(dead_code)]
12603fn count_changes_git_histogram(old_content: &str, new_content: &str) -> (usize, usize) {
12604    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, true)
12605}
12606
12607/// Line count for diffstat/`--numstat`, matching Git's `count_lines()` in `diff.c`.
12608///
12609/// Counts newline-terminated lines; a final line without trailing newline still counts as one line.
12610/// An empty buffer yields `0`.
12611#[must_use]
12612pub fn count_git_lines(data: &[u8]) -> usize {
12613    if data.is_empty() {
12614        return 0;
12615    }
12616    let mut count = 0usize;
12617    let mut nl_just_seen = false;
12618    for &ch in data {
12619        if ch == b'\n' {
12620            count += 1;
12621            nl_just_seen = true;
12622        } else {
12623            nl_just_seen = false;
12624        }
12625    }
12626    if !nl_just_seen {
12627        count += 1;
12628    }
12629    count
12630}
12631
12632/// Internal maximum diff score used by Git rename/break heuristics (`MAX_SCORE` in `diffcore.h`).
12633pub(crate) const GIT_DIFF_MAX_SCORE: u64 = 60_000;
12634const DIFF_MAX_SCORE: u64 = GIT_DIFF_MAX_SCORE;
12635const DIFF_MINIMUM_BREAK_SIZE: usize = 400;
12636#[allow(dead_code)]
12637const DIFF_DEFAULT_BREAK_SCORE: u64 = 30_000;
12638/// Default break threshold (`DEFAULT_BREAK_SCORE` in `diffcore.h`), internal 0–the internal max score (60_000) scale.
12639#[allow(dead_code)]
12640const GIT_DIFF_DEFAULT_BREAK_SCORE: u64 = DIFF_DEFAULT_BREAK_SCORE;
12641const DIFF_HASHBASE: u32 = 107_927;
12642
12643#[derive(Clone, Copy, Default)]
12644struct SpanSlot {
12645    hashval: u32,
12646    cnt: u32,
12647}
12648
12649struct SpanHashTop {
12650    alloc_log2: u8,
12651    free_slots: i32,
12652    data: Vec<SpanSlot>,
12653}
12654
12655impl SpanHashTop {
12656    fn new(initial_log2: u8) -> Self {
12657        let cap = 1usize << initial_log2;
12658        Self {
12659            alloc_log2: initial_log2,
12660            free_slots: initial_free(initial_log2),
12661            data: vec![SpanSlot::default(); cap],
12662        }
12663    }
12664
12665    fn len(&self) -> usize {
12666        1usize << self.alloc_log2
12667    }
12668
12669    fn add_span(&mut self, hashval: u32, cnt: u32) {
12670        loop {
12671            let lim = self.len();
12672            let mut bucket = (hashval as usize) & (lim - 1);
12673            loop {
12674                let h = &mut self.data[bucket];
12675                if h.cnt == 0 {
12676                    h.hashval = hashval;
12677                    h.cnt = cnt;
12678                    self.free_slots -= 1;
12679                    if self.free_slots < 0 {
12680                        self.rehash();
12681                        break;
12682                    }
12683                    return;
12684                }
12685                if h.hashval == hashval {
12686                    h.cnt = h.cnt.saturating_add(cnt);
12687                    return;
12688                }
12689                bucket += 1;
12690                if bucket >= lim {
12691                    bucket = 0;
12692                }
12693            }
12694        }
12695    }
12696
12697    fn rehash(&mut self) {
12698        let old = std::mem::take(&mut self.data);
12699        let old_log = self.alloc_log2;
12700        self.alloc_log2 = old_log.saturating_add(1);
12701        let new_len = 1usize << self.alloc_log2;
12702        self.free_slots = initial_free(self.alloc_log2);
12703        self.data = vec![SpanSlot::default(); new_len];
12704        let old_sz = 1usize << old_log;
12705        for o in old.iter().take(old_sz) {
12706            let o = *o;
12707            if o.cnt == 0 {
12708                continue;
12709            }
12710            self.add_span_after_rehash(o.hashval, o.cnt);
12711        }
12712    }
12713
12714    fn add_span_after_rehash(&mut self, hashval: u32, cnt: u32) {
12715        loop {
12716            let lim = self.len();
12717            let mut bucket = (hashval as usize) & (lim - 1);
12718            loop {
12719                let h = &mut self.data[bucket];
12720                if h.cnt == 0 {
12721                    h.hashval = hashval;
12722                    h.cnt = cnt;
12723                    self.free_slots -= 1;
12724                    if self.free_slots < 0 {
12725                        self.rehash();
12726                        break;
12727                    }
12728                    return;
12729                }
12730                if h.hashval == hashval {
12731                    h.cnt = h.cnt.saturating_add(cnt);
12732                    return;
12733                }
12734                bucket += 1;
12735                if bucket >= lim {
12736                    bucket = 0;
12737                }
12738            }
12739        }
12740    }
12741
12742    fn sort_by_hashval(&mut self) {
12743        let sz = self.len();
12744        self.data[..sz].sort_by(|a, b| {
12745            if a.cnt == 0 {
12746                return std::cmp::Ordering::Greater;
12747            }
12748            if b.cnt == 0 {
12749                return std::cmp::Ordering::Less;
12750            }
12751            a.hashval.cmp(&b.hashval)
12752        });
12753    }
12754}
12755
12756fn initial_free(sz_log2: u8) -> i32 {
12757    let sz = sz_log2 as i32;
12758    ((1i32 << sz_log2) * (sz - 3) / sz).max(0)
12759}
12760
12761fn hash_blob_spans(buf: &[u8], is_text: bool) -> SpanHashTop {
12762    let mut hash = SpanHashTop::new(9);
12763    let mut n = 0u32;
12764    let mut accum1: u32 = 0;
12765    let mut accum2: u32 = 0;
12766    let mut i = 0usize;
12767    while i < buf.len() {
12768        let c = buf[i] as u32;
12769        let old_1 = accum1;
12770        i += 1;
12771
12772        if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
12773            continue;
12774        }
12775
12776        accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
12777        accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
12778        accum1 = accum1.wrapping_add(c);
12779        n += 1;
12780        if n < 64 && c != b'\n' as u32 {
12781            continue;
12782        }
12783        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
12784        hash.add_span(hashval, n);
12785        n = 0;
12786        accum1 = 0;
12787        accum2 = 0;
12788    }
12789    if n > 0 {
12790        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
12791        hash.add_span(hashval, n);
12792    }
12793    hash.sort_by_hashval();
12794    hash
12795}
12796
12797/// Approximate copied vs added material between two blobs (Git `diffcore_count_changes`).
12798///
12799/// Returns `(copied_bytes_from_src, literal_added_bytes_in_dst)` matching Git's
12800/// `diffcore_count_changes` semantics (used for `--dirstat=changes` damage).
12801#[must_use]
12802pub(crate) fn diffcore_count_changes(old: &[u8], new: &[u8]) -> (u64, u64) {
12803    crate::trace::trace_span!("grit.diff.blob");
12804    let src_is_text = !crate::merge_file::is_binary(old);
12805    let dst_is_text = !crate::merge_file::is_binary(new);
12806    let src_count = hash_blob_spans(old, src_is_text);
12807    let dst_count = hash_blob_spans(new, dst_is_text);
12808    let mut sc: u64 = 0;
12809    let mut la: u64 = 0;
12810    let mut si = 0usize;
12811    let mut di = 0usize;
12812    let src_len = src_count.len();
12813    let dst_len = dst_count.len();
12814    loop {
12815        if si >= src_len || src_count.data[si].cnt == 0 {
12816            break;
12817        }
12818        let s_hash = src_count.data[si].hashval;
12819        let s_cnt = u64::from(src_count.data[si].cnt);
12820        while di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval < s_hash {
12821            la += u64::from(dst_count.data[di].cnt);
12822            di += 1;
12823        }
12824        let mut dst_cnt = 0u64;
12825        if di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval == s_hash {
12826            dst_cnt = u64::from(dst_count.data[di].cnt);
12827            di += 1;
12828        }
12829        if s_cnt < dst_cnt {
12830            la += dst_cnt - s_cnt;
12831            sc += s_cnt;
12832        } else {
12833            sc += dst_cnt;
12834        }
12835        si += 1;
12836    }
12837    while di < dst_len && dst_count.data[di].cnt != 0 {
12838        la += u64::from(dst_count.data[di].cnt);
12839        di += 1;
12840    }
12841    (sc, la)
12842}
12843
12844/// Whether an in-place blob edit should be split into delete+create for rename/copy (`should_break`
12845/// in `diffcore-break.c`). `break_score` is on the internal 0–the internal max score (60_000) scale (default
12846/// `DIFF_DEFAULT_BREAK_SCORE`).
12847#[must_use]
12848pub(crate) fn should_break_rewrite_pair(old: &[u8], new: &[u8], break_score: u64) -> bool {
12849    should_break_rewrite_inner(old, new, break_score)
12850}
12851
12852/// Drop entries that do not match `filter` (Git `--diff-filter`, after rename detection).
12853#[must_use]
12854pub(crate) fn apply_diff_filter(
12855    entries: Vec<DiffEntry>,
12856    filter: Option<super::filter::DiffFilter>,
12857) -> Vec<DiffEntry> {
12858    match filter {
12859        Some(f) => f.filter_entries(entries),
12860        None => entries,
12861    }
12862}
12863
12864/// Parse `-M` / `--find-renames` threshold into Git's 0–100 similarity percent.
12865#[must_use]
12866pub fn parse_diff_rename_threshold_percent(arg: &str) -> Option<u8> {
12867    let token = if arg.is_empty() { "50" } else { arg };
12868    let internal = parse_diff_rename_score_token(token)?;
12869    Some((internal.saturating_mul(100) / GIT_DIFF_MAX_SCORE).min(100) as u8)
12870}
12871
12872/// Parse a single Git `parse_rename_score` token (`50`, `50%`, decimal forms) into internal
12873/// 0–the internal max score (60_000) units.
12874pub(crate) fn parse_diff_rename_score_token(arg: &str) -> Option<u64> {
12875    let mut num: u64 = 0;
12876    let mut scale: u64 = 1;
12877    let mut dot = false;
12878    let mut saw_digit = false;
12879    for ch in arg.chars() {
12880        if !dot && ch == '.' {
12881            scale = 1;
12882            dot = true;
12883            continue;
12884        }
12885        if ch == '%' {
12886            scale = if dot { scale.saturating_mul(100) } else { 100 };
12887            break;
12888        }
12889        if ch.is_ascii_digit() {
12890            saw_digit = true;
12891            if scale < 100_000 {
12892                scale = scale.saturating_mul(10);
12893                num = num.saturating_mul(10) + u64::from(ch as u8 - b'0');
12894            }
12895        } else {
12896            break;
12897        }
12898    }
12899    if !saw_digit {
12900        return None;
12901    }
12902    Some(if num >= scale {
12903        GIT_DIFF_MAX_SCORE
12904    } else {
12905        GIT_DIFF_MAX_SCORE * num / scale
12906    })
12907}
12908
12909/// Git `merge_score` from `diffcore-break.c` when a pair is considered broken: how much of the
12910/// source blob was removed (0–`DIFF_MAX_SCORE` scale). Used for `dissimilarity index` metadata.
12911#[must_use]
12912pub(crate) fn rewrite_merge_score(old: &[u8], new: &[u8]) -> Option<u64> {
12913    if old.is_empty() {
12914        return None;
12915    }
12916    let max_size = old.len().max(new.len());
12917    if max_size < DIFF_MINIMUM_BREAK_SIZE {
12918        return None;
12919    }
12920    let (src_copied, _) = diffcore_count_changes(old, new);
12921    let src_copied = src_copied.min(old.len() as u64);
12922    let src_removed = (old.len() as u64).saturating_sub(src_copied);
12923    Some(src_removed * DIFF_MAX_SCORE / old.len() as u64)
12924}
12925
12926/// Percentage shown in `dissimilarity index N%` for a rewrite (`similarity_index` in Git's diff.c).
12927#[must_use]
12928pub(crate) fn rewrite_dissimilarity_index_percent(old: &[u8], new: &[u8]) -> Option<u32> {
12929    let score = rewrite_merge_score(old, new)?;
12930    Some((score * 100 / DIFF_MAX_SCORE).min(100) as u32)
12931}
12932
12933fn should_break_rewrite_inner(src: &[u8], dst: &[u8], break_score: u64) -> bool {
12934    if src.is_empty() {
12935        return false;
12936    }
12937    let max_size = src.len().max(dst.len());
12938    if max_size < DIFF_MINIMUM_BREAK_SIZE {
12939        return false;
12940    }
12941    let (src_copied, literal_added) = diffcore_count_changes(src, dst);
12942    let src_copied = src_copied.min(src.len() as u64);
12943    let mut literal_added = literal_added;
12944    let dst_len = dst.len() as u64;
12945    if src_copied < dst_len && literal_added + src_copied > dst_len {
12946        literal_added = dst_len.saturating_sub(src_copied);
12947    }
12948    let src_removed = (src.len() as u64).saturating_sub(src_copied);
12949    let merge_score = src_removed * DIFF_MAX_SCORE / src.len() as u64;
12950    if merge_score > break_score {
12951        return true;
12952    }
12953    let delta_size = src_removed.saturating_add(literal_added);
12954    if delta_size * DIFF_MAX_SCORE / (max_size as u64) < break_score {
12955        return false;
12956    }
12957    let s = src.len() as u64;
12958    if (s * break_score < src_removed * DIFF_MAX_SCORE)
12959        && (literal_added * 20 < src_removed)
12960        && (literal_added * 20 < src_copied)
12961    {
12962        return false;
12963    }
12964    true
12965}
12966
12967// ── Helpers ─────────────────────────────────────────────────────────
12968
12969/// Flatten a tree object recursively into a sorted list of (path, mode, oid).
12970struct FlatEntry {
12971    path: String,
12972    mode: u32,
12973    oid: ObjectId,
12974}
12975
12976fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
12977    flatten_tree_with_index(odb, tree_oid, prefix, None)
12978}
12979
12980fn sparse_index_skips_tree_descent(index: &Index, dir_path: &str, tree_oid: &ObjectId) -> bool {
12981    let mut storage = crate::repo_relpath::storage_bytes(dir_path).into_owned();
12982    if !storage.ends_with(b"/") {
12983        storage.push(b'/');
12984    }
12985    index.find_entry_pos(&storage, 0).is_some_and(|pos| {
12986        let entry = &index.entries[pos];
12987        entry.is_sparse_directory_placeholder() && entry.oid == *tree_oid
12988    })
12989}
12990
12991/// Tree OID at `path` (slash-separated, no trailing slash) under `root`, if every component is a tree.
12992fn subtree_oid_at_path(odb: &Odb, root: &ObjectId, path: &str) -> Result<Option<ObjectId>> {
12993    let mut oid = *root;
12994    for component in path.split('/').filter(|c| !c.is_empty()) {
12995        let entries = read_tree(odb, &oid)?;
12996        let Some(entry) = entries
12997            .iter()
12998            .find(|e| e.name.as_slice() == component.as_bytes())
12999        else {
13000            return Ok(None);
13001        };
13002        if !is_tree_mode(entry.mode) {
13003            return Ok(None);
13004        }
13005        oid = entry.oid;
13006    }
13007    Ok(Some(oid))
13008}
13009
13010/// Report every blob in an index subtree as staged additions (HEAD has no tree at this path).
13011fn emit_flattened_subtree_as_added(
13012    odb: &Odb,
13013    subtree: &ObjectId,
13014    prefix: &str,
13015    ignore_submodules: bool,
13016    result: &mut Vec<DiffEntry>,
13017) -> Result<()> {
13018    for entry in flatten_tree(odb, subtree, prefix)? {
13019        if ignore_submodules && entry.mode == 0o160000 {
13020            continue;
13021        }
13022        result.push(DiffEntry {
13023            status: DiffStatus::Added,
13024            old_path: None,
13025            new_path: Some(entry.path),
13026            old_mode: FileMode::ABSENT,
13027            new_mode: FileMode::new(entry.mode),
13028            old_oid: zero_oid(),
13029            new_oid: entry.oid,
13030            score: None,
13031        });
13032    }
13033    Ok(())
13034}
13035
13036/// Compare two flattened subtrees and append staged diff entries.
13037fn diff_flattened_subtrees(
13038    head: &[FlatEntry],
13039    index: &[FlatEntry],
13040    ignore_submodules: bool,
13041    result: &mut Vec<DiffEntry>,
13042    stage0_paths: &mut std::collections::BTreeSet<String>,
13043    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
13044) {
13045    let mut head_map: std::collections::BTreeMap<&str, &FlatEntry> = head
13046        .iter()
13047        .map(|entry| (entry.path.as_str(), entry))
13048        .collect();
13049    for entry in index {
13050        stage0_paths.insert(entry.path.clone());
13051        tree_map.remove(entry.path.as_str());
13052        match head_map.remove(entry.path.as_str()) {
13053            Some(te) => {
13054                if te.oid != entry.oid || te.mode != entry.mode {
13055                    result.push(DiffEntry {
13056                        status: DiffStatus::for_content_change(te.mode, entry.mode),
13057                        old_path: Some(entry.path.clone()),
13058                        new_path: Some(entry.path.clone()),
13059                        old_mode: FileMode::new(te.mode),
13060                        new_mode: FileMode::new(entry.mode),
13061                        old_oid: te.oid,
13062                        new_oid: entry.oid,
13063                        score: None,
13064                    });
13065                }
13066            }
13067            None => {
13068                result.push(DiffEntry {
13069                    status: DiffStatus::Added,
13070                    old_path: None,
13071                    new_path: Some(entry.path.clone()),
13072                    old_mode: FileMode::ABSENT,
13073                    new_mode: FileMode::new(entry.mode),
13074                    old_oid: zero_oid(),
13075                    new_oid: entry.oid,
13076                    score: None,
13077                });
13078            }
13079        }
13080    }
13081    for (path, te) in head_map {
13082        if ignore_submodules && te.mode == 0o160000 {
13083            continue;
13084        }
13085        tree_map.remove(path);
13086        result.push(DiffEntry {
13087            status: DiffStatus::Deleted,
13088            old_path: Some(path.to_owned()),
13089            new_path: None,
13090            old_mode: FileMode::new(te.mode),
13091            new_mode: FileMode::ABSENT,
13092            old_oid: te.oid,
13093            new_oid: zero_oid(),
13094            score: None,
13095        });
13096    }
13097}
13098
13099fn flatten_tree_with_index(
13100    odb: &Odb,
13101    tree_oid: &ObjectId,
13102    prefix: &str,
13103    index: Option<&Index>,
13104) -> Result<Vec<FlatEntry>> {
13105    let entries = read_tree(odb, tree_oid)?;
13106    let mut result = Vec::new();
13107
13108    for entry in entries {
13109        let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
13110        if is_tree_mode(entry.mode) {
13111            if index.is_some_and(|idx| sparse_index_skips_tree_descent(idx, &path, &entry.oid)) {
13112                continue;
13113            }
13114            let nested = flatten_tree_with_index(odb, &entry.oid, &path, index)?;
13115            result.extend(nested);
13116        } else {
13117            result.push(FlatEntry {
13118                path,
13119                mode: entry.mode,
13120                oid: entry.oid,
13121            });
13122        }
13123    }
13124
13125    Ok(result)
13126}
13127
13128/// Paths present in `HEAD`'s tree with mode and blob/commit OID (for status porcelain v2).
13129pub(crate) fn head_path_states(
13130    odb: &Odb,
13131    head_tree: Option<&ObjectId>,
13132) -> Result<std::collections::BTreeMap<String, (u32, ObjectId)>> {
13133    let mut m = std::collections::BTreeMap::new();
13134    let Some(t) = head_tree else {
13135        return Ok(m);
13136    };
13137    for fe in flatten_tree(odb, t, "")? {
13138        m.insert(fe.path, (fe.mode, fe.oid));
13139    }
13140    Ok(m)
13141}
13142
13143/// Whether a mode represents a tree (directory).
13144fn is_tree_mode(mode: u32) -> bool {
13145    crate::index::is_tree_mode(mode)
13146}
13147
13148/// Git orders diff lists by raw path bytes, which differs from the stored form of
13149/// non-UTF-8 paths (see [`crate::repo_relpath::storage_bytes`]).
13150fn path_order(a: &DiffEntry, b: &DiffEntry) -> std::cmp::Ordering {
13151    use crate::repo_relpath::storage_bytes;
13152    storage_bytes(a.path()).cmp(&storage_bytes(b.path()))
13153}
13154
13155fn entries_sorted_by_path(entries: &[DiffEntry]) -> bool {
13156    entries.is_sorted_by(|a, b| path_order(a, b).is_le())
13157}
13158
13159fn entries_strictly_sorted_by_path(entries: &[DiffEntry]) -> bool {
13160    entries.is_sorted_by(|a, b| path_order(a, b).is_lt())
13161}
13162
13163/// Stable-sort diff entries by [`DiffEntry::path`] in raw byte order, Git's output order
13164/// for diff lists (non-UTF-8 paths compare by their decoded bytes).
13165///
13166/// Tree diffs and rename detection already return entries in this order, so the common
13167/// case is a single comparison pass with no allocation (a stable sort would otherwise
13168/// allocate scratch space proportional to the list).
13169///
13170/// # Parameters
13171///
13172/// - `entries`: the list to order in place; entries with equal paths keep their order.
13173pub fn sort_entries_by_path(entries: &mut [DiffEntry]) {
13174    if entries_sorted_by_path(entries) {
13175        return;
13176    }
13177    entries.sort_by(path_order);
13178}
13179
13180/// Format a numeric mode as a zero-padded octal string.
13181pub(crate) fn format_mode(mode: u32) -> String {
13182    format!("{mode:06o}")
13183}
13184
13185/// Read the HEAD commit OID from a submodule checkout directory.
13186///
13187/// Returns `None` if the path is missing, not a submodule checkout, or has no resolvable HEAD.
13188#[must_use]
13189#[allow(dead_code)]
13190fn read_submodule_head_for_checkout(sub_dir: &Path) -> Option<ObjectId> {
13191    read_submodule_head(sub_dir)
13192}
13193
13194/// First line of a commit's message for `git diff --submodule=log` output.
13195///
13196/// Honors `encoding` in the commit object (Latin-1 vs UTF-8) using the same
13197/// rules as Git's submodule summary.
13198#[must_use]
13199#[allow(dead_code)]
13200fn submodule_commit_subject_line(c: &CommitData) -> String {
13201    let enc = c.encoding.as_deref().unwrap_or("UTF-8");
13202    let is_latin1 = enc.eq_ignore_ascii_case("ISO8859-1")
13203        || enc.eq_ignore_ascii_case("ISO-8859-1")
13204        || enc.eq_ignore_ascii_case("LATIN1")
13205        || enc.eq_ignore_ascii_case("ISO-8859-15");
13206    if let Some(raw) = c.raw_message.as_deref() {
13207        let line = raw.split(|b| *b == b'\n').next().unwrap_or(raw);
13208        if is_latin1 {
13209            return line
13210                .iter()
13211                .map(|&b| b as char)
13212                .collect::<String>()
13213                .trim()
13214                .to_owned();
13215        }
13216        return String::from_utf8_lossy(line).trim().to_string();
13217    }
13218    c.message.lines().next().unwrap_or("").trim().to_owned()
13219}
13220
13221/// True when a gitlink directory has no initialized submodule (no embedded `.git`), so the
13222/// recorded index OID is treated as the worktree reference (Git `ce_compare_gitlink` when
13223/// `resolve_gitlink_ref` fails).
13224fn uninitialized_gitlink_worktree_matches_index(sub_dir: &Path) -> bool {
13225    submodule_embedded_git_dir(sub_dir).is_none()
13226}
13227
13228fn read_submodule_head(sub_dir: &Path) -> Option<ObjectId> {
13229    read_submodule_head_oid(sub_dir)
13230}
13231
13232/// Resolve the embedded git directory for a submodule work tree (`sub_dir/.git`).
13233#[must_use]
13234pub(crate) fn submodule_embedded_git_dir(sub_dir: &Path) -> Option<PathBuf> {
13235    let sub_meta = fs::symlink_metadata(sub_dir).ok()?;
13236    if !sub_meta.is_dir() || sub_meta.file_type().is_symlink() {
13237        return None;
13238    }
13239    let gitfile = sub_dir.join(".git");
13240    let meta = fs::metadata(&gitfile).ok()?;
13241    if meta.is_file() {
13242        let content = fs::read_to_string(&gitfile).ok()?;
13243        let gitdir = content
13244            .lines()
13245            .find_map(|l| l.strip_prefix("gitdir: "))?
13246            .trim();
13247        Some(if Path::new(gitdir).is_absolute() {
13248            PathBuf::from(gitdir)
13249        } else {
13250            sub_dir.join(gitdir)
13251        })
13252    } else if meta.is_dir() {
13253        Some(gitfile)
13254    } else {
13255        None
13256    }
13257}
13258
13259/// Whether `abs` is a nested Git checkout (embedded `.git`) not already indexed as a gitlink.
13260///
13261/// Matches Git's `is_nonbare_repository_dir` / `is_git_directory`: the embedded git dir must look
13262/// like a real repository (valid `HEAD` plus `objects/`, or `objects/` and `refs/`), not a bare
13263/// `.git` directory placeholder.
13264#[must_use]
13265pub(crate) fn is_unregistered_embedded_git_repo(index: &Index, rel: &str, abs: &Path) -> bool {
13266    if rel.is_empty() || index.has_gitlink_at(rel.as_bytes()) {
13267        return false;
13268    }
13269    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
13270        return false;
13271    };
13272    crate::submodule_gitdir::is_valid_git_dir(&git_dir)
13273}
13274
13275/// Whether staging should record a gitlink, refuse, or treat the path as a normal directory.
13276pub(crate) enum EmbeddedGitRepoStage {
13277    /// Path is not an unregistered embedded repository.
13278    NotEmbedded,
13279    /// HEAD resolves to a commit that can be stored as mode `160000`.
13280    Gitlink(crate::objects::ObjectId),
13281}
13282
13283/// Classify a worktree path for embedded-repository staging.
13284///
13285/// Unlike [`is_unregistered_embedded_git_repo`], registered gitlinks (including unmerged
13286/// conflict stages) are still checked so an unborn nested HEAD cannot collapse conflicts.
13287///
13288/// Returns [`EmbeddedGitRepoStage::NotEmbedded`] when the path is not a nested checkout.
13289/// Returns [`EmbeddedGitRepoStage::Gitlink`] when HEAD resolves. Otherwise returns
13290/// [`crate::error::Error::EmbeddedRepoWithoutCommit`].
13291pub(crate) fn classify_embedded_git_repo_for_stage(
13292    _index: &Index,
13293    rel: &str,
13294    abs: &Path,
13295) -> crate::error::Result<EmbeddedGitRepoStage> {
13296    if rel.is_empty() {
13297        return Ok(EmbeddedGitRepoStage::NotEmbedded);
13298    }
13299    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
13300        return Ok(EmbeddedGitRepoStage::NotEmbedded);
13301    };
13302    if !crate::submodule_gitdir::is_valid_git_dir(&git_dir) {
13303        return Ok(EmbeddedGitRepoStage::NotEmbedded);
13304    }
13305    let path = rel.trim_end_matches('/').to_owned();
13306    match read_submodule_head_oid_for_stage(abs) {
13307        Some(oid) => Ok(EmbeddedGitRepoStage::Gitlink(oid)),
13308        None => Err(crate::error::Error::EmbeddedRepoWithoutCommit { path }),
13309    }
13310}
13311
13312/// Walk upward from `sub_dir` to find the nearest containing Git work tree.
13313fn find_superproject_git(sub_dir: &Path) -> Option<(PathBuf, PathBuf)> {
13314    let mut cur = sub_dir.parent()?;
13315    loop {
13316        let git_path = cur.join(".git");
13317        if git_path.exists() {
13318            let gd = if git_path.is_file() {
13319                let content = fs::read_to_string(&git_path).ok()?;
13320                let line = content
13321                    .lines()
13322                    .find_map(|l| l.strip_prefix("gitdir: "))?
13323                    .trim();
13324                if Path::new(line).is_absolute() {
13325                    PathBuf::from(line)
13326                } else {
13327                    cur.join(line)
13328                }
13329            } else {
13330                git_path
13331            };
13332            return Some((cur.to_path_buf(), gd));
13333        }
13334        cur = cur.parent()?;
13335    }
13336}
13337
13338fn submodule_git_dir_for_head_read(sub_dir: &Path) -> Option<PathBuf> {
13339    let mut git_dir = submodule_embedded_git_dir(sub_dir)?;
13340    if let Some((super_wt, super_git_dir)) = find_superproject_git(sub_dir) {
13341        let rel = sub_dir.strip_prefix(&super_wt).ok()?;
13342        let rel_str = rel.to_string_lossy().replace('\\', "/");
13343        let local_mod = super_git_dir
13344            .join("modules")
13345            .join(rel_str.trim_start_matches('/'));
13346        if local_mod.join("HEAD").exists() {
13347            let sg = super_git_dir
13348                .canonicalize_simplified()
13349                .unwrap_or(super_git_dir);
13350            let cur = git_dir
13351                .canonicalize_simplified()
13352                .unwrap_or_else(|_| git_dir.clone());
13353            if !cur.starts_with(&sg) {
13354                git_dir = local_mod;
13355            }
13356        }
13357    }
13358    Some(git_dir)
13359}
13360
13361fn read_submodule_head_oid_from_git_dir(
13362    git_dir: &Path,
13363    allow_branch_fallback: bool,
13364) -> Option<ObjectId> {
13365    let head_content = fs::read_to_string(git_dir.join("HEAD")).ok()?;
13366    let head_trimmed = head_content.trim();
13367    if head_trimmed.starts_with("ref: ") {
13368        match crate::refs::resolve_ref(git_dir, "HEAD") {
13369            Ok(oid) => Some(oid),
13370            Err(_) if allow_branch_fallback => {
13371                for branch in ["main", "master"] {
13372                    let p = git_dir.join("refs/heads").join(branch);
13373                    if let Ok(s) = fs::read_to_string(&p) {
13374                        if let Ok(o) = ObjectId::from_hex(s.trim()) {
13375                            return Some(o);
13376                        }
13377                    }
13378                }
13379                None
13380            }
13381            Err(_) => None,
13382        }
13383    } else {
13384        ObjectId::from_hex(head_trimmed).ok()
13385    }
13386}
13387
13388/// Read the HEAD commit OID from a submodule working tree directory.
13389///
13390/// Handles both embedded `.git` directories and `gitdir:` gitfiles pointing at
13391/// `.git/modules/...` (or other locations). Returns `None` if the path is not
13392/// a checkout or has no resolvable HEAD.
13393pub(crate) fn read_submodule_head_oid(sub_dir: &Path) -> Option<ObjectId> {
13394    let git_dir = submodule_git_dir_for_head_read(sub_dir)?;
13395    read_submodule_head_oid_from_git_dir(&git_dir, true)
13396}
13397
13398/// Like [`read_submodule_head_oid`], but requires `HEAD` itself to resolve (no `main`/`master`
13399/// fallback). Used when staging embedded repositories so a broken symref matches Git's refusal.
13400pub(crate) fn read_submodule_head_oid_for_stage(sub_dir: &Path) -> Option<ObjectId> {
13401    let git_dir = submodule_git_dir_for_head_read(sub_dir)?;
13402    read_submodule_head_oid_from_git_dir(&git_dir, false)
13403}
13404
13405/// True when a populated submodule checkout is *broken*: its `HEAD` resolves to a commit OID, but
13406/// that commit object cannot be read from the submodule's own object database.
13407///
13408/// This mirrors Git's `is_submodule_modified`, which shells out to `git status --porcelain=2`
13409/// inside the submodule; when the submodule's object store is corrupt (e.g. `rm -r .git/objects`),
13410/// that inner status fails and Git aborts the surrounding `status`/`diff`/`fetch`. We detect the
13411/// same condition in-process so the superproject operation can return a fatal error rather than
13412/// silently treating the submodule as clean (t5526 "fetching submodule into a broken repository").
13413///
13414/// Returns `false` when the submodule is not checked out, has no embedded git dir, or has an
13415/// unresolvable HEAD (those are handled separately by the unpopulated/placeholder logic).
13416#[must_use]
13417pub(crate) fn submodule_head_object_broken(sub_dir: &Path) -> bool {
13418    let Some(sub_git_dir) = submodule_embedded_git_dir(sub_dir) else {
13419        return false;
13420    };
13421    let Some(head_oid) = read_submodule_head_oid(sub_dir) else {
13422        return false;
13423    };
13424    let odb = Odb::new(&sub_git_dir.join("objects"));
13425    match odb.read(&head_oid) {
13426        // HEAD object present but not a commit would be a different kind of corruption; only the
13427        // missing-object case is the broken-repo scenario Git guards against here.
13428        Ok(obj) => parse_commit(&obj.data).is_err(),
13429        Err(_) => true,
13430    }
13431}
13432
13433/// Submodule dirty bits aligned with Git's `DIRTY_SUBMODULE_*` / porcelain v2 `S???` token.
13434#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13435pub struct SubmodulePorcelainFlags {
13436    /// Submodule checkout HEAD differs from the gitlink OID recorded in the parent index.
13437    pub new_commits: bool,
13438    /// The submodule has its own staged or unstaged changes (`DIRTY_SUBMODULE_MODIFIED`).
13439    pub modified: bool,
13440    /// The submodule work tree contains paths not in its index (`DIRTY_SUBMODULE_UNTRACKED`).
13441    pub untracked: bool,
13442}
13443
13444/// Inspect a checked-out submodule at `rel_path` (relative to `super_worktree`) and return
13445/// flags used for `git status --porcelain=v2` submodule tokens.
13446///
13447/// `recorded_oid` is the gitlink OID stored in the **parent** index (stage 0). When the
13448/// submodule is not checked out or cannot be opened, returns [`Default::default()`].
13449pub fn submodule_porcelain_flags(
13450    super_worktree: &Path,
13451    rel_path: &str,
13452    recorded_oid: ObjectId,
13453) -> SubmodulePorcelainFlags {
13454    let sub_dir = super_worktree.join(rel_path);
13455    let Some(sub_git_dir) = submodule_embedded_git_dir(&sub_dir) else {
13456        return SubmodulePorcelainFlags::default();
13457    };
13458    let Some(sub_head) = read_submodule_head_oid(&sub_dir) else {
13459        return SubmodulePorcelainFlags::default();
13460    };
13461
13462    let new_commits = sub_head != recorded_oid;
13463
13464    let index_path = sub_git_dir.join("index");
13465    let mut sub_index = match crate::index::Index::load(&index_path) {
13466        Ok(ix) => ix,
13467        Err(_) => {
13468            return SubmodulePorcelainFlags {
13469                new_commits,
13470                ..Default::default()
13471            }
13472        }
13473    };
13474
13475    let tracked: std::collections::BTreeSet<String> = sub_index
13476        .entries
13477        .iter()
13478        .filter(|e| e.stage() == 0)
13479        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
13480        .collect();
13481    let untracked = submodule_dir_has_untracked_inner(&sub_dir, &sub_dir, &tracked, &sub_index);
13482
13483    let objects_dir = sub_git_dir.join("objects");
13484    let odb = Odb::new(&objects_dir);
13485
13486    let sub_head_tree = (|| -> Option<ObjectId> {
13487        let h = fs::read_to_string(sub_git_dir.join("HEAD")).ok()?;
13488        let h_str = h.trim();
13489        let commit_oid = if let Some(r) = h_str.strip_prefix("ref: ") {
13490            let oid_hex = fs::read_to_string(sub_git_dir.join(r)).ok()?;
13491            ObjectId::from_hex(oid_hex.trim()).ok()?
13492        } else {
13493            ObjectId::from_hex(h_str).ok()?
13494        };
13495        let obj = odb.read(&commit_oid).ok()?;
13496        let commit = parse_commit(&obj.data).ok()?;
13497        Some(commit.tree)
13498    })();
13499
13500    let staged_dirty = sub_head_tree
13501        .as_ref()
13502        .map(|t| diff_index_to_tree(&odb, &sub_index, Some(t), false).map(|v| !v.is_empty()))
13503        .unwrap_or(Ok(false));
13504    let staged_dirty = staged_dirty.unwrap_or(false);
13505
13506    let unstaged_dirty = diff_index_to_worktree(&odb, &mut sub_index, &sub_dir, false, true)
13507        .map(|v| !v.is_empty())
13508        .unwrap_or(false);
13509
13510    let mut modified = staged_dirty || unstaged_dirty;
13511
13512    // Nested submodule has its own index: OR `modified` from immediate gitlink children so a
13513    // dirty nested checkout (e.g. staged `file` under `sub1/sub2`) marks the parent gitlink as
13514    // modified in the superproject (t7506). Do **not** OR `untracked` — untracked-only inside a
13515    // nested submodule must stay `S..U` on the parent, not `S.U` / `S.M.`.
13516    for e in &sub_index.entries {
13517        if e.stage() != 0 || e.mode != 0o160000 {
13518            continue;
13519        }
13520        let child = String::from_utf8_lossy(&e.path).into_owned();
13521        let full_rel = if rel_path.is_empty() {
13522            child
13523        } else {
13524            format!("{rel_path}/{child}")
13525        };
13526        let nested = submodule_porcelain_flags(super_worktree, &full_rel, e.oid);
13527        modified |= nested.modified;
13528    }
13529
13530    SubmodulePorcelainFlags {
13531        new_commits,
13532        modified,
13533        untracked,
13534    }
13535}
13536
13537fn submodule_dir_has_untracked_inner(
13538    dir: &Path,
13539    root: &Path,
13540    tracked: &std::collections::BTreeSet<String>,
13541    owning_index: &Index,
13542) -> bool {
13543    let entries = match fs::read_dir(dir) {
13544        Ok(e) => e,
13545        Err(_) => return false,
13546    };
13547    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
13548    sorted.sort_by_key(|e| e.file_name());
13549
13550    for entry in sorted {
13551        let name = entry.file_name().to_string_lossy().to_string();
13552        if name == ".git" {
13553            continue;
13554        }
13555        let path = entry.path();
13556        let rel = path
13557            .strip_prefix(root)
13558            .map(|p| p.to_string_lossy().to_string())
13559            .unwrap_or_else(|_| name.clone());
13560
13561        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
13562        if is_dir {
13563            let is_gitlink = owning_index
13564                .get(rel.as_bytes(), 0)
13565                .is_some_and(|e| e.mode == 0o160000);
13566            if is_gitlink {
13567                let Some(nested_git) = submodule_embedded_git_dir(&path) else {
13568                    continue;
13569                };
13570                let nested_index_path = nested_git.join("index");
13571                let Ok(nested_ix) = crate::index::Index::load(&nested_index_path) else {
13572                    continue;
13573                };
13574                let nested_tracked: std::collections::BTreeSet<String> = nested_ix
13575                    .entries
13576                    .iter()
13577                    .filter(|e| e.stage() == 0)
13578                    .map(|e| String::from_utf8_lossy(&e.path).into_owned())
13579                    .collect();
13580                if submodule_dir_has_untracked_inner(&path, &path, &nested_tracked, &nested_ix) {
13581                    return true;
13582                }
13583            } else if submodule_dir_has_untracked_inner(&path, root, tracked, owning_index) {
13584                return true;
13585            }
13586        } else if !tracked.contains(&rel) {
13587            return true;
13588        }
13589    }
13590    false
13591}
13592
13593#[cfg(test)]
13594mod line_compare_key_tests {
13595    use super::*;
13596    use crate::diff::unified_emit::line_diff_token;
13597    use crate::diff::WhitespaceMode;
13598
13599    #[test]
13600    fn borrowed_keys_match_owned_line_tokens() {
13601        let texts = [
13602            "",
13603            "\n",
13604            "a",
13605            "a\n",
13606            "a\nb",
13607            "a\r\nb\r\n",
13608            "a\r\nb\r",
13609            "\n\n\nlast",
13610            "x\n\r\n\ry\n",
13611        ];
13612        for text in texts {
13613            let side = LineSide::from_text(text);
13614            let mut normalized = Vec::new();
13615            let keys = line_compare_keys(&side, WhitespaceMode::default(), &mut normalized);
13616            let expected: Vec<String> = side.lines().map(|l| line_diff_token(&l)).collect();
13617            assert_eq!(keys, expected, "text {text:?}");
13618            let source = text.as_bytes().as_ptr_range();
13619            assert!(
13620                keys.iter()
13621                    .all(|key| source.contains(&key.as_ptr()) || key.is_empty()),
13622                "text {text:?}"
13623            );
13624        }
13625    }
13626
13627    #[test]
13628    fn whitespace_modes_compare_normalized_bodies() {
13629        let text = "a  b\nab \n";
13630        let side = LineSide::from_text(text);
13631        let ws = WhitespaceMode {
13632            ignore_all_space: true,
13633            ..WhitespaceMode::default()
13634        };
13635        let mut normalized = Vec::new();
13636        let keys = line_compare_keys(&side, ws, &mut normalized);
13637        assert_eq!(keys, vec!["ab", "ab"]);
13638    }
13639
13640    #[test]
13641    fn xdiff_marks_only_edited_lines_in_large_file() {
13642        let old: String = (0..5_000).map(|i| format!("line{i}\n")).collect();
13643        let new: String = (0..5_000)
13644            .map(|i| {
13645                if i % 100 == 0 {
13646                    format!("LINE{i}\n")
13647                } else {
13648                    format!("line{i}\n")
13649                }
13650            })
13651            .collect();
13652        let body =
13653            git_xdiff_unified_body_raw(&old, &new, 3, 0, true, false, WhitespaceMode::default());
13654        assert_eq!(body.matches("\n@@ ").count() + 1, 50);
13655        assert_eq!(body.lines().filter(|l| l.starts_with('-')).count(), 50);
13656        assert_eq!(body.lines().filter(|l| l.starts_with('+')).count(), 50);
13657        assert!(body.starts_with("@@ -1,4 +1,4 @@\n-line0\n+LINE0\n line1\n"));
13658    }
13659}
13660
13661#[cfg(test)]
13662mod entry_order_tests {
13663    use super::*;
13664
13665    fn entry(status: DiffStatus, path: &str, score: Option<u32>) -> DiffEntry {
13666        let mode = |present: bool| {
13667            if present {
13668                FileMode::REGULAR
13669            } else {
13670                FileMode::ABSENT
13671            }
13672        };
13673        DiffEntry {
13674            status,
13675            old_path: (status != DiffStatus::Added).then(|| path.to_owned()),
13676            new_path: (status != DiffStatus::Deleted).then(|| path.to_owned()),
13677            old_mode: mode(status != DiffStatus::Added),
13678            new_mode: mode(status != DiffStatus::Deleted),
13679            old_oid: ObjectId::zero(),
13680            new_oid: ObjectId::zero(),
13681            score,
13682        }
13683    }
13684
13685    #[test]
13686    fn sort_entries_by_path_orders_by_path_and_keeps_equal_paths_in_place() {
13687        let mut entries = vec![
13688            entry(DiffStatus::Modified, "b", None),
13689            entry(DiffStatus::Deleted, "a/x", Some(1)),
13690            entry(DiffStatus::Added, "a/x", Some(2)),
13691            entry(DiffStatus::Added, "a", None),
13692        ];
13693        sort_entries_by_path(&mut entries);
13694        let order: Vec<_> = entries.iter().map(|e| (e.path(), e.score)).collect();
13695        assert_eq!(
13696            order,
13697            [("a", None), ("a/x", Some(1)), ("a/x", Some(2)), ("b", None)]
13698        );
13699        let sorted = entries.clone();
13700        sort_entries_by_path(&mut entries);
13701        assert_eq!(entries, sorted);
13702        sort_entries_by_path(&mut []);
13703    }
13704
13705    #[test]
13706    fn sort_entries_by_path_compares_non_utf8_paths_by_raw_bytes() {
13707        let raw = crate::repo_relpath::format_path_stored("", b"raw-\xff");
13708        assert!(raw.starts_with("grit+pathhex:"));
13709        let mut entries = vec![
13710            entry(DiffStatus::Modified, &raw, None),
13711            entry(DiffStatus::Modified, "node/leaf", None),
13712            entry(DiffStatus::Modified, "zeta", None),
13713        ];
13714        sort_entries_by_path(&mut entries);
13715        let paths: Vec<_> = entries.iter().map(DiffEntry::path).collect();
13716        assert_eq!(paths, ["node/leaf", raw.as_str(), "zeta"]);
13717    }
13718
13719    #[test]
13720    fn rename_pass_returns_unpairable_lists_in_path_order() {
13721        let config = ConfigSet::new();
13722        let policy = ThreadPolicy::default();
13723        let odb = Odb::new(Path::new("/nonexistent-objects"));
13724        let run = |entries: Vec<DiffEntry>| {
13725            detect_renames_with_policy(
13726                &odb,
13727                None,
13728                entries,
13729                50,
13730                &config,
13731                &policy,
13732                None,
13733                DetectRenamesOptions::default(),
13734            )
13735            .expect("no blob is read without both adds and deletes")
13736            .entries
13737        };
13738        let sorted = vec![
13739            entry(DiffStatus::Deleted, "a", None),
13740            entry(DiffStatus::Modified, "b", None),
13741            entry(DiffStatus::Deleted, "c", None),
13742        ];
13743        assert_eq!(run(sorted.clone()), sorted);
13744        let unsorted = vec![
13745            entry(DiffStatus::Added, "z", None),
13746            entry(DiffStatus::Modified, "m", None),
13747            entry(DiffStatus::Added, "a", None),
13748        ];
13749        let paths: Vec<_> = run(unsorted).iter().map(|e| e.path().to_owned()).collect();
13750        assert_eq!(paths, ["a", "m", "z"]);
13751        // A duplicate path takes the regrouping path: modifications before additions.
13752        let duplicate = vec![
13753            entry(DiffStatus::Added, "p", None),
13754            entry(DiffStatus::Modified, "p", None),
13755        ];
13756        let statuses: Vec<_> = run(duplicate).iter().map(|e| e.status).collect();
13757        assert_eq!(statuses, [DiffStatus::Modified, DiffStatus::Added]);
13758    }
13759}
13760
13761#[cfg(test)]
13762mod diff_index_to_tree_cache_tree_tests {
13763    use std::rc::Rc;
13764    use std::sync::atomic::{AtomicUsize, Ordering};
13765
13766    use super::*;
13767    use crate::index::{Index, IndexEntry, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
13768    use crate::objects::ObjectKind;
13769    use crate::write_tree::{build_cache_tree_from_index, write_tree_from_index};
13770    use tempfile::TempDir;
13771
13772    struct TreeReadCounter {
13773        counter: Rc<AtomicUsize>,
13774    }
13775
13776    impl TreeReadCounter {
13777        fn start() -> Self {
13778            let counter = Rc::new(AtomicUsize::new(0));
13779            set_diff_tree_read_hook_for_tests(Some(counter.clone()));
13780            Self { counter }
13781        }
13782
13783        fn get(&self) -> usize {
13784            self.counter.load(Ordering::Relaxed)
13785        }
13786    }
13787
13788    impl Drop for TreeReadCounter {
13789        fn drop(&mut self) {
13790            set_diff_tree_read_hook_for_tests(None);
13791        }
13792    }
13793
13794    fn diff_index_to_tree_flatten_reference(
13795        odb: &Odb,
13796        index: &Index,
13797        head_tree: &ObjectId,
13798        ignore_submodules: bool,
13799    ) -> Result<Vec<DiffEntry>> {
13800        let mut flat_index = index.clone();
13801        flat_index.clear_cache_tree();
13802        diff_index_to_tree(odb, &flat_index, Some(head_tree), ignore_submodules)
13803    }
13804
13805    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
13806        IndexEntry {
13807            ctime_sec: 0,
13808            ctime_nsec: 0,
13809            mtime_sec: 0,
13810            mtime_nsec: 0,
13811            dev: 0,
13812            ino: 0,
13813            mode,
13814            uid: 0,
13815            gid: 0,
13816            size: 0,
13817            oid,
13818            flags: path.len().min(0xfff) as u16,
13819            flags_extended: None,
13820            path: path.as_bytes().to_vec(),
13821            base_index_pos: 0,
13822        }
13823    }
13824
13825    fn repo_with_tree(depth: usize, width: usize) -> (TempDir, Odb, ObjectId, Index) {
13826        let temp = TempDir::new().expect("tempdir");
13827        let odb = Odb::new(temp.path());
13828        let mut index = Index::new();
13829        for d in 0..depth {
13830            for w in 0..width {
13831                let path = format!("unchanged/d{d}/f{w}.txt");
13832                let oid = odb
13833                    .write(ObjectKind::Blob, format!("body-{d}-{w}").as_bytes())
13834                    .expect("blob");
13835                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
13836            }
13837        }
13838        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
13839        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
13840        index.set_cache_tree(cache);
13841        index.cache_tree_root = Some(head_tree);
13842        (temp, odb, head_tree, index)
13843    }
13844
13845    #[test]
13846    fn diff_index_to_tree_cache_tree_fast_path_matches_slow() {
13847        let (_temp, odb, head_tree, mut index) = repo_with_tree(4, 5);
13848        assert!(index.cache_tree.as_ref().is_some_and(|c| c.is_valid()));
13849
13850        let mut state: u64 = 0xDEAD_BEEF;
13851        for _ in 0..24 {
13852            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
13853            let d = (state % 4) as usize;
13854            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
13855            let w = (state % 5) as usize;
13856            let path = format!("unchanged/d{d}/f{w}.txt");
13857            index.invalidate_cache_tree_for_path(path.as_bytes());
13858            assert!(
13859                index.cache_tree_root.is_none(),
13860                "partial invalidation clears cache_tree_root like Index::add"
13861            );
13862            assert!(
13863                index.cache_tree.as_ref().is_some_and(|r| !r.is_valid()),
13864                "root cache node must be invalid after partial invalidation"
13865            );
13866            if let Some(e) = index.get_mut(path.as_bytes(), 0) {
13867                let new_oid = odb
13868                    .write(ObjectKind::Blob, format!("new-{d}-{w}").as_bytes())
13869                    .expect("blob");
13870                e.oid = new_oid;
13871            }
13872            let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
13873            let flat = diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false)
13874                .expect("flat");
13875            assert_eq!(fast, flat, "mismatch after mutating {path}");
13876        }
13877    }
13878
13879    #[test]
13880    fn diff_index_to_tree_skips_unchanged_subtrees() {
13881        let (_temp, odb, head_tree, index) = repo_with_tree(5, 6);
13882        let reads = TreeReadCounter::start();
13883        diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("cached diff");
13884        let cached_reads = reads.get();
13885
13886        let mut no_cache = index.clone();
13887        no_cache.clear_cache_tree();
13888        let reads = TreeReadCounter::start();
13889        diff_index_to_tree(&odb, &no_cache, Some(&head_tree), false).expect("flat diff");
13890        let flat_reads = reads.get();
13891
13892        assert!(
13893            cached_reads < flat_reads,
13894            "expected fewer tree reads with cache-tree (cached={cached_reads}, flat={flat_reads})"
13895        );
13896        assert_eq!(
13897            cached_reads, 1,
13898            "unchanged repo should only read the root tree"
13899        );
13900    }
13901
13902    #[test]
13903    fn diff_index_to_tree_partial_invalidation_skips_sibling_subtree() {
13904        let (_temp, odb, head_tree, mut index) = repo_with_tree(5, 6);
13905        let path = "unchanged/d1/f2.txt";
13906        index.invalidate_cache_tree_for_path(path.as_bytes());
13907        let new_oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
13908        index.get_mut(path.as_bytes(), 0).expect("entry").oid = new_oid;
13909
13910        let flat =
13911            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
13912        let reads = TreeReadCounter::start();
13913        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
13914        let cached_reads = reads.get();
13915        assert_eq!(fast, flat);
13916
13917        let reads = TreeReadCounter::start();
13918        diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
13919        let flat_reads = reads.get();
13920
13921        assert!(
13922            cached_reads < flat_reads,
13923            "partial cache-tree should skip sibling dirs (cached={cached_reads}, flat={flat_reads})"
13924        );
13925    }
13926
13927    fn cache_leaf(name: &str) -> CacheTreeNode {
13928        CacheTreeNode::valid(name.as_bytes().to_vec(), 1, ObjectId::zero(), Vec::new())
13929    }
13930
13931    #[test]
13932    fn cache_children_lookup_matches_linear_scan_on_wide_unsorted_nodes() {
13933        let mut children: Vec<CacheTreeNode> = (0..CACHE_CHILD_LINEAR_SCAN_MAX * 3)
13934            .rev()
13935            .map(|i| cache_leaf(&format!("d{i:03}")))
13936            .collect();
13937        let mut first_dup = cache_leaf("d007");
13938        first_dup.entry_count = 7;
13939        children.insert(3, first_dup);
13940        let root = CacheTreeNode::valid(Vec::new(), 0, ObjectId::zero(), children);
13941
13942        let lookup = CacheChildren::new(Some(&root));
13943        assert!(
13944            !lookup.sorted.is_empty(),
13945            "wide nodes must use the sorted lookup"
13946        );
13947        for name in ["d000", "d007", "d020", "d047", "zzz", "d", ""] {
13948            let linear = root.children.iter().find(|c| c.name == name.as_bytes());
13949            let fast = lookup.get(name.as_bytes());
13950            assert_eq!(
13951                fast.map(|c| (&c.name, c.entry_count)),
13952                linear.map(|c| (&c.name, c.entry_count)),
13953                "lookup of {name:?}"
13954            );
13955        }
13956        assert_eq!(lookup.get(b"d007").map(|c| c.entry_count), Some(7));
13957
13958        let narrow = CacheTreeNode::valid(
13959            Vec::new(),
13960            0,
13961            ObjectId::zero(),
13962            vec![cache_leaf("b"), cache_leaf("a")],
13963        );
13964        let lookup = CacheChildren::new(Some(&narrow));
13965        assert!(lookup.sorted.is_empty());
13966        assert_eq!(lookup.get(b"a").map(|c| c.name.as_slice()), Some(&b"a"[..]));
13967        assert!(lookup.get(b"c").is_none());
13968        assert!(CacheChildren::new(None).get(b"a").is_none());
13969    }
13970
13971    #[test]
13972    fn index_relative_in_dir_requires_a_component_boundary() {
13973        assert_eq!(index_relative_in_dir(b"a/b", b""), Some(&b"a/b"[..]));
13974        assert_eq!(index_relative_in_dir(b"a/b", b"a"), Some(&b"b"[..]));
13975        assert_eq!(index_relative_in_dir(b"a", b"a"), Some(&b""[..]));
13976        assert_eq!(index_relative_in_dir(b"ab/c", b"a"), None);
13977        assert_eq!(index_relative_in_dir(b"a.c", b"a"), None);
13978        assert_eq!(index_relative_in_dir(b"b", b"a"), None);
13979        assert_eq!(join_index_path(b"", b"x"), b"x".to_vec());
13980        assert_eq!(join_index_path(b"a/b", b"x"), b"a/b/x".to_vec());
13981    }
13982
13983    /// Wide root (more children than the linear-scan cutoff), cache-tree children stored in
13984    /// reverse order, a few changed paths, plus additions and deletions at both levels.
13985    #[test]
13986    fn diff_index_to_tree_wide_unsorted_cache_tree_matches_flatten() {
13987        let temp = TempDir::new().expect("tempdir");
13988        let odb = Odb::new(temp.path());
13989        let mut index = Index::new();
13990        let width = CACHE_CHILD_LINEAR_SCAN_MAX * 4;
13991        for d in 0..width {
13992            for f in 0..2 {
13993                let path = format!("dir{d:03}/sub/f{f}.txt");
13994                let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
13995                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
13996            }
13997        }
13998        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
13999        let mut cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
14000        cache.children.reverse();
14001        index.set_cache_tree(cache);
14002        index.cache_tree_root = Some(head_tree);
14003
14004        let changed = [
14005            "dir003/sub/f1.txt",
14006            "dir031/sub/f0.txt",
14007            "dir063/sub/f1.txt",
14008        ];
14009        for path in changed {
14010            index.invalidate_cache_tree_for_path(path.as_bytes());
14011            let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
14012            index.get_mut(path.as_bytes(), 0).expect("entry").oid = oid;
14013        }
14014        for path in ["dir010/sub/new.txt", "dir999/new.txt", "top.txt"] {
14015            index.invalidate_cache_tree_for_path(path.as_bytes());
14016            let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
14017            index.add_or_replace(index_entry(path, MODE_REGULAR, oid));
14018        }
14019        for path in [
14020            "dir020/sub/f0.txt",
14021            "dir020/sub/f1.txt",
14022            "dir021/sub/f0.txt",
14023        ] {
14024            index.invalidate_cache_tree_for_path(path.as_bytes());
14025            index.remove(path.as_bytes());
14026        }
14027
14028        let flat =
14029            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
14030        let reads = TreeReadCounter::start();
14031        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
14032        let cached_reads = reads.get();
14033        assert_eq!(fast, flat);
14034        assert_eq!(fast.len(), changed.len() + 3 + 3);
14035        assert!(
14036            cached_reads <= 1 + 2 * 6,
14037            "unchanged siblings must be skipped through the cache-tree (reads={cached_reads})"
14038        );
14039    }
14040
14041    #[test]
14042    fn diff_index_to_tree_cache_tree_handles_non_utf8_directory_names() {
14043        let temp = TempDir::new().expect("tempdir");
14044        let odb = Odb::new(temp.path());
14045        let mut index = Index::new();
14046        let raw_dirs: [&[u8]; 3] = [b"caf\xe9", b"caf\xef\xbf\xbd", b"plain"];
14047        for dir in raw_dirs {
14048            for f in [&b"a.txt"[..], b"b.txt"] {
14049                let path = [dir, b"/", f].concat();
14050                let oid = odb.write(ObjectKind::Blob, &path).expect("blob");
14051                let mut entry = index_entry("x", MODE_REGULAR, oid);
14052                entry.flags = path.len().min(0xfff) as u16;
14053                entry.path = path;
14054                index.add_or_replace(entry);
14055            }
14056        }
14057        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
14058        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
14059        index.set_cache_tree(cache);
14060        index.cache_tree_root = Some(head_tree);
14061
14062        let changed: &[u8] = b"caf\xe9/b.txt";
14063        index.invalidate_cache_tree_for_path(changed);
14064        let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
14065        index.get_mut(changed, 0).expect("entry").oid = oid;
14066
14067        // Directory prefixes are compared as raw bytes, so `caf\xe9` and a directory literally
14068        // named `caf\u{fffd}` stay distinct even though they print the same.
14069        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
14070        assert_eq!(fast.len(), 1, "{fast:?}");
14071        assert_eq!(fast[0].status, DiffStatus::Modified);
14072        assert_eq!(
14073            fast[0].old_oid,
14074            odb.write(ObjectKind::Blob, changed).expect("blob")
14075        );
14076        assert_eq!(fast[0].new_oid, oid);
14077    }
14078
14079    #[test]
14080    fn diff_index_to_tree_reports_mode_gitlink_and_intent_to_add() {
14081        let temp = TempDir::new().expect("tempdir");
14082        let odb = Odb::new(temp.path());
14083        let blob = odb.write(ObjectKind::Blob, b"x").expect("blob");
14084        let mut index = Index::new();
14085        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, blob));
14086        let head_tree = write_tree_from_index(&odb, &index, "").expect("tree");
14087        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
14088        index.set_cache_tree(cache);
14089
14090        let mode_entry = index_entry("tracked.txt", MODE_EXECUTABLE, blob);
14091        index.add_or_replace(mode_entry);
14092        index.invalidate_cache_tree_for_path(b"tracked.txt");
14093
14094        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
14095        assert_eq!(diffs.len(), 1);
14096        assert_eq!(diffs[0].status, DiffStatus::Modified);
14097        assert_eq!(diffs[0].old_mode, FileMode::new(MODE_REGULAR));
14098        assert_eq!(diffs[0].new_mode, FileMode::new(MODE_EXECUTABLE));
14099
14100        // Intent-to-add is omitted from staged diff.
14101        let mut ita = index_entry("new.txt", MODE_REGULAR, blob);
14102        ita.set_intent_to_add(true);
14103        index.add_or_replace(ita);
14104        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
14105        assert!(
14106            diffs.iter().all(|d| d.path() != "new.txt"),
14107            "intent-to-add must not appear in staged diff"
14108        );
14109
14110        // Gitlink with ignore_submodules skips diff entry but still matches tree.
14111        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14112            .parse()
14113            .expect("oid");
14114        let mut sub_index = Index::new();
14115        sub_index.add_or_replace(index_entry("sub", MODE_GITLINK, gitlink));
14116        let sub_tree = write_tree_from_index(&odb, &sub_index, "").expect("tree");
14117        let sub_cache = build_cache_tree_from_index(&odb, &sub_index).expect("cache");
14118        sub_index.set_cache_tree(sub_cache);
14119        let diffs =
14120            diff_index_to_tree(&odb, &sub_index, Some(&sub_tree), true).expect("gitlink diff");
14121        assert!(
14122            diffs.is_empty(),
14123            "ignored gitlink should not diff: {diffs:?}"
14124        );
14125    }
14126
14127    #[test]
14128    fn ignore_submodules_skips_gitlink_in_added_directory_cache_walk() {
14129        let temp = TempDir::new().expect("tempdir");
14130        let odb = Odb::new(temp.path());
14131        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14132            .parse()
14133            .expect("oid");
14134        let head_tree = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
14135        let mut index = Index::new();
14136        index.add_or_replace(index_entry("newdir/sub", MODE_GITLINK, gitlink));
14137        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
14138        index.set_cache_tree(cache);
14139        index.invalidate_cache_tree_for_path(b"newdir/sub");
14140
14141        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
14142        let flat =
14143            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
14144        assert_eq!(fast, flat);
14145        assert!(
14146            fast.is_empty(),
14147            "nested gitlinks in added dirs must be ignored: {fast:?}"
14148        );
14149    }
14150
14151    #[test]
14152    fn ignore_submodules_skips_gitlink_in_deleted_directory_cache_walk() {
14153        let temp = TempDir::new().expect("tempdir");
14154        let odb = Odb::new(temp.path());
14155        let blob = odb.write(ObjectKind::Blob, b"keep").expect("blob");
14156        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14157            .parse()
14158            .expect("oid");
14159        let mut index = Index::new();
14160        index.add_or_replace(index_entry("keep.txt", MODE_REGULAR, blob));
14161        index.add_or_replace(index_entry("olddir/sub", MODE_GITLINK, gitlink));
14162        let head_tree = write_tree_from_index(&odb, &index, "").expect("head");
14163        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
14164        index.set_cache_tree(cache);
14165        index
14166            .entries
14167            .retain(|e| !String::from_utf8_lossy(&e.path).starts_with("olddir/"));
14168        index.invalidate_cache_tree_for_path(b"olddir/sub");
14169
14170        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
14171        let flat =
14172            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
14173        assert_eq!(fast, flat);
14174        assert!(
14175            fast.is_empty(),
14176            "nested gitlinks in deleted dirs must be ignored: {fast:?}"
14177        );
14178    }
14179
14180    #[test]
14181    fn diff_trees_skips_identical_subtree_oids() {
14182        let temp = TempDir::new().expect("tempdir");
14183        let odb = Odb::new(temp.path());
14184        let leaf = odb.write(ObjectKind::Blob, b"leaf").expect("blob");
14185        let mut deep = Index::new();
14186        for i in 0..20 {
14187            deep.add_or_replace(index_entry(&format!("deep/n{i}.txt"), MODE_REGULAR, leaf));
14188        }
14189        let tree = write_tree_from_index(&odb, &deep, "").expect("tree");
14190        let reads = TreeReadCounter::start();
14191        let entries = diff_trees(&odb, Some(&tree), Some(&tree), "").expect("diff");
14192        assert!(entries.is_empty());
14193        assert_eq!(
14194            reads.get(),
14195            0,
14196            "identical tree oids should short-circuit without reading tree objects"
14197        );
14198    }
14199
14200    #[test]
14201    fn diff_tree_path_change_count_matches_diff_trees() {
14202        let temp = TempDir::new().expect("tempdir");
14203        let odb = Odb::new(temp.path());
14204        let a = odb.write(ObjectKind::Blob, b"a").expect("blob");
14205        let b = odb.write(ObjectKind::Blob, b"b").expect("blob");
14206        let mut left = Index::new();
14207        left.add_or_replace(index_entry("f.txt", MODE_REGULAR, a));
14208        let mut right = Index::new();
14209        right.add_or_replace(index_entry("f.txt", MODE_REGULAR, b));
14210        right.add_or_replace(index_entry("g.txt", MODE_REGULAR, a));
14211        let old_tree = write_tree_from_index(&odb, &left, "").expect("old");
14212        let new_tree = write_tree_from_index(&odb, &right, "").expect("new");
14213        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
14214        let count =
14215            diff_tree_path_change_count(&odb, Some(&old_tree), Some(&new_tree), "").expect("count");
14216        assert_eq!(count, entries.len());
14217        assert_eq!(count, 2);
14218    }
14219
14220    #[test]
14221    fn diff_trees_matches_whether_trees_were_cached_parsed_or_raw() {
14222        let temp = TempDir::new().expect("tempdir");
14223        let a = Odb::new(temp.path())
14224            .write(ObjectKind::Blob, b"a")
14225            .expect("blob");
14226        let b = Odb::new(temp.path())
14227            .write(ObjectKind::Blob, b"b")
14228            .expect("blob");
14229        let mut left = Index::new();
14230        let mut right = Index::new();
14231        for i in 0..50 {
14232            left.add_or_replace(index_entry(&format!("same{i:02}.txt"), MODE_REGULAR, a));
14233            right.add_or_replace(index_entry(&format!("same{i:02}.txt"), MODE_REGULAR, a));
14234        }
14235        left.add_or_replace(index_entry("dir/changed.txt", MODE_REGULAR, a));
14236        left.add_or_replace(index_entry("dir/kept.txt", MODE_REGULAR, a));
14237        left.add_or_replace(index_entry("gone/x.txt", MODE_REGULAR, a));
14238        left.add_or_replace(index_entry("mode.sh", MODE_REGULAR, a));
14239        left.add_or_replace(index_entry("swap", MODE_REGULAR, a));
14240        right.add_or_replace(index_entry("dir/changed.txt", MODE_REGULAR, b));
14241        right.add_or_replace(index_entry("dir/kept.txt", MODE_REGULAR, a));
14242        right.add_or_replace(index_entry("mode.sh", MODE_EXECUTABLE, a));
14243        right.add_or_replace(index_entry("new/y.txt", MODE_REGULAR, b));
14244        right.add_or_replace(index_entry("swap/inner.txt", MODE_REGULAR, b));
14245        let setup = Odb::new(temp.path());
14246        let old_tree = write_tree_from_index(&setup, &left, "").expect("old");
14247        let new_tree = write_tree_from_index(&setup, &right, "").expect("new");
14248
14249        let raw = diff_trees(&Odb::new(temp.path()), Some(&old_tree), Some(&new_tree), "")
14250            .expect("raw diff");
14251        let changed: Vec<(DiffStatus, &str)> = raw.iter().map(|e| (e.status, e.path())).collect();
14252        assert_eq!(
14253            changed,
14254            vec![
14255                (DiffStatus::Modified, "dir/changed.txt"),
14256                (DiffStatus::Deleted, "gone/x.txt"),
14257                (DiffStatus::Modified, "mode.sh"),
14258                (DiffStatus::Added, "new/y.txt"),
14259                (DiffStatus::Deleted, "swap"),
14260                (DiffStatus::Added, "swap/inner.txt"),
14261            ]
14262        );
14263
14264        let parsed = Odb::new(temp.path());
14265        parsed.cached_tree_entries(&old_tree).expect("parse old");
14266        parsed.cached_tree_entries(&new_tree).expect("parse new");
14267        assert_eq!(
14268            diff_trees(&parsed, Some(&old_tree), Some(&new_tree), "").expect("parsed diff"),
14269            raw
14270        );
14271
14272        let mixed = Odb::new(temp.path());
14273        mixed.cached_tree_entries(&new_tree).expect("parse new");
14274        assert_eq!(
14275            diff_trees(&mixed, Some(&old_tree), Some(&new_tree), "").expect("mixed diff"),
14276            raw
14277        );
14278        let reparsed = mixed
14279            .cached_tree_entries(&old_tree)
14280            .expect("parse cached raw");
14281        assert_eq!(
14282            reparsed.as_slice(),
14283            crate::objects::parse_tree(&mixed.read(&old_tree).expect("read").data)
14284                .expect("parse")
14285                .as_slice(),
14286            "a tree cached raw by a diff parses to the same entries"
14287        );
14288    }
14289
14290    #[test]
14291    fn diff_trees_reports_a_corrupt_tree() {
14292        let temp = TempDir::new().expect("tempdir");
14293        let odb = Odb::new(temp.path());
14294        let good = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
14295        let corrupt = odb
14296            .write(ObjectKind::Tree, b"100644 name-without-oid")
14297            .expect("tree");
14298        let err = diff_trees(&odb, Some(&good), Some(&corrupt), "").expect_err("corrupt");
14299        assert!(matches!(err, Error::CorruptObject(_)), "{err:?}");
14300    }
14301}
14302
14303#[cfg(test)]
14304mod changed_flags_shared_tests {
14305    use super::git_xdiff;
14306    use crate::line_interner::LineInterner;
14307
14308    /// Cutting identical leading and trailing records out and passing their occurrence counts
14309    /// gives the same changed records as diffing the whole files.
14310    #[test]
14311    fn shared_counts_stand_in_for_records_left_out() {
14312        // `}` is frequent only outside the cut, which makes xdiff discard the middle `}` between
14313        // unique lines instead of matching it.
14314        let mut old: Vec<String> = Vec::new();
14315        let mut new: Vec<String> = Vec::new();
14316        for i in 0..60 {
14317            for side in [&mut old, &mut new] {
14318                side.push(format!("fn f{i}() {{\n"));
14319                side.push("}\n".to_owned());
14320            }
14321        }
14322        old.extend(
14323            [
14324                "u1\n", "u2\n", "u3\n", "u4\n", "}\n", "u5\n", "u6\n", "u7\n",
14325            ]
14326            .map(str::to_owned),
14327        );
14328        new.extend(
14329            [
14330                "v1\n", "v2\n", "v3\n", "v4\n", "}\n", "v5\n", "v6\n", "v7\n",
14331            ]
14332            .map(str::to_owned),
14333        );
14334        for i in 0..40 {
14335            for side in [&mut old, &mut new] {
14336                side.push(format!("fn g{i}() {{\n"));
14337                side.push("}\n".to_owned());
14338            }
14339        }
14340        let old: Vec<&str> = old.iter().map(String::as_str).collect();
14341        let new: Vec<&str> = new.iter().map(String::as_str).collect();
14342        let (head, tail) = (120, 80);
14343
14344        let mut interner = LineInterner::with_capacity(old.len() + new.len());
14345        let ids1 = interner.intern_all(&old);
14346        let ids2 = interner.intern_all(&new);
14347        let (full1, full2) = git_xdiff::changed_flags(&ids1, &ids2, false);
14348
14349        let mut cut = LineInterner::default();
14350        let cut1 = cut.intern_all(&old[head..old.len() - tail]);
14351        let cut2 = cut.intern_all(&new[head..new.len() - tail]);
14352        let mut shared = vec![0u32; cut.len()];
14353        for line in old[..head].iter().chain(&old[old.len() - tail..]) {
14354            if let Some(id) = cut.get(line) {
14355                shared[id as usize] += 1;
14356            }
14357        }
14358        let (part1, part2) =
14359            git_xdiff::changed_flags_with_shared(&cut1, &cut2, &shared, head + tail, false);
14360        assert_eq!(part1, full1[head..old.len() - tail]);
14361        assert_eq!(part2, full2[head..new.len() - tail]);
14362        assert!(full1[..head]
14363            .iter()
14364            .chain(&full1[old.len() - tail..])
14365            .all(|c| !c));
14366        // Like `git diff`, the whole middle is one change: the `}` among unique lines is
14367        // discarded because it is frequent in the whole file.
14368        assert!(full1[head..old.len() - tail].iter().all(|&c| c));
14369        let (alone1, _) = git_xdiff::changed_flags(&cut1, &cut2, false);
14370        assert!(
14371            !alone1[4],
14372            "counted alone, the cut keeps the `}}` as a match"
14373        );
14374    }
14375}
14376
14377#[cfg(test)]
14378mod gitlink_tree_worktree_tests {
14379    use std::fs;
14380
14381    use crate::index::{Index, IndexEntry, MODE_GITLINK};
14382    use crate::objects::{ObjectId, ObjectKind};
14383    use crate::repo::init_repository;
14384
14385    use super::diff_tree_to_worktree;
14386
14387    fn encode_tree_gitlink(name: &str, oid: &ObjectId) -> Vec<u8> {
14388        let mut data = format!("160000 {name}\0").into_bytes();
14389        data.extend_from_slice(oid.as_bytes());
14390        data
14391    }
14392
14393    #[test]
14394    fn unpopulated_gitlink_matching_index_produces_no_diff_entry() {
14395        let dir = tempfile::tempdir().expect("tempdir");
14396        let work_tree = dir.path();
14397        fs::create_dir(work_tree.join("sub")).expect("sub placeholder");
14398
14399        let repo = init_repository(
14400            work_tree,
14401            false,
14402            "main",
14403            None,
14404            crate::RefStorageFormat::Files,
14405        )
14406        .expect("init");
14407        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14408            .parse()
14409            .expect("gitlink oid");
14410        let tree_oid = repo
14411            .odb()
14412            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
14413            .expect("write tree");
14414
14415        let path = b"sub".to_vec();
14416        let mut index = Index::new();
14417        index.add_or_replace(IndexEntry {
14418            ctime_sec: 0,
14419            ctime_nsec: 0,
14420            mtime_sec: 0,
14421            mtime_nsec: 0,
14422            dev: 0,
14423            ino: 0,
14424            mode: MODE_GITLINK,
14425            uid: 0,
14426            gid: 0,
14427            size: 0,
14428            oid: gitlink,
14429            flags: (path.len().min(0xfff)) as u16,
14430            flags_extended: None,
14431            path,
14432            base_index_pos: 0,
14433        });
14434
14435        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
14436            .expect("diff_tree_to_worktree");
14437        assert!(
14438            entries.is_empty(),
14439            "clean uninitialized gitlink should not appear in tree→worktree diff: {entries:?}"
14440        );
14441    }
14442
14443    #[test]
14444    fn unpopulated_gitlink_with_stray_file_produces_no_diff_entry() {
14445        let dir = tempfile::tempdir().expect("tempdir");
14446        let work_tree = dir.path();
14447        let sub = work_tree.join("sub");
14448        fs::create_dir(&sub).expect("sub placeholder");
14449        fs::write(sub.join("x.txt"), b"stray\n").expect("stray file");
14450
14451        let repo = init_repository(
14452            work_tree,
14453            false,
14454            "main",
14455            None,
14456            crate::RefStorageFormat::Files,
14457        )
14458        .expect("init");
14459        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14460            .parse()
14461            .expect("gitlink oid");
14462        let tree_oid = repo
14463            .odb()
14464            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
14465            .expect("write tree");
14466
14467        let path = b"sub".to_vec();
14468        let mut index = Index::new();
14469        index.add_or_replace(IndexEntry {
14470            ctime_sec: 0,
14471            ctime_nsec: 0,
14472            mtime_sec: 0,
14473            mtime_nsec: 0,
14474            dev: 0,
14475            ino: 0,
14476            mode: MODE_GITLINK,
14477            uid: 0,
14478            gid: 0,
14479            size: 0,
14480            oid: gitlink,
14481            flags: (path.len().min(0xfff)) as u16,
14482            flags_extended: None,
14483            path,
14484            base_index_pos: 0,
14485        });
14486
14487        let tree_entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
14488            .expect("diff_tree_to_worktree");
14489        assert!(
14490            tree_entries.is_empty(),
14491            "uninitialized gitlink with stray files should not diff: {tree_entries:?}"
14492        );
14493
14494        let wt_entries =
14495            super::diff_index_to_worktree(&repo.odb(), &mut index.clone(), work_tree, false, true)
14496                .expect("diff_index_to_worktree");
14497        assert!(
14498            wt_entries.is_empty(),
14499            "uninitialized gitlink with stray files should not diff: {wt_entries:?}"
14500        );
14501
14502        let ie = index.entries()[0].clone();
14503        assert!(
14504            !super::worktree_differs_from_index_entry(
14505                repo.odb(),
14506                repo.git_dir(),
14507                work_tree,
14508                &ie,
14509                false,
14510                None,
14511                None,
14512            )
14513            .expect("differs"),
14514            "stray files in uninitialized submodule should not differ from index"
14515        );
14516    }
14517
14518    #[cfg_attr(
14519        not(feature = "full-tests"),
14520        ignore = "starts system git or is heavy; run with --features full-tests"
14521    )]
14522    #[test]
14523    fn untracked_only_in_checked_out_submodule_produces_no_diff_entry() {
14524        use std::process::Command;
14525
14526        let dir = tempfile::tempdir().expect("tempdir");
14527        let work_tree = dir.path();
14528        fs::create_dir(work_tree.join("sub")).expect("sub dir");
14529
14530        let repo = init_repository(
14531            work_tree,
14532            false,
14533            "main",
14534            None,
14535            crate::RefStorageFormat::Files,
14536        )
14537        .expect("init");
14538        let sub = work_tree.join("sub");
14539        for args in [
14540            &["init", "-q"][..],
14541            &["config", "user.name", "T"][..],
14542            &["config", "user.email", "t@e.com"][..],
14543        ] {
14544            let status = Command::new("git")
14545                .current_dir(&sub)
14546                .args(args)
14547                .status()
14548                .expect("git");
14549            assert!(status.success(), "git {args:?} failed");
14550        }
14551        fs::write(sub.join("tracked.txt"), b"v1\n").expect("write");
14552        for args in [&["add", "tracked.txt"][..], &["commit", "-qm", "sub"][..]] {
14553            let status = Command::new("git")
14554                .current_dir(&sub)
14555                .args(args)
14556                .status()
14557                .expect("git");
14558            assert!(status.success());
14559        }
14560        let head_out = Command::new("git")
14561            .current_dir(&sub)
14562            .args(["rev-parse", "HEAD"])
14563            .output()
14564            .expect("rev-parse");
14565        let gitlink = std::str::from_utf8(&head_out.stdout)
14566            .expect("utf8")
14567            .trim()
14568            .parse::<ObjectId>()
14569            .expect("oid");
14570
14571        let tree_oid = repo
14572            .odb()
14573            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
14574            .expect("tree");
14575        let path = b"sub".to_vec();
14576        let mut index = Index::new();
14577        index.add_or_replace(IndexEntry {
14578            ctime_sec: 0,
14579            ctime_nsec: 0,
14580            mtime_sec: 0,
14581            mtime_nsec: 0,
14582            dev: 0,
14583            ino: 0,
14584            mode: MODE_GITLINK,
14585            uid: 0,
14586            gid: 0,
14587            size: 0,
14588            oid: gitlink,
14589            flags: (path.len().min(0xfff)) as u16,
14590            flags_extended: None,
14591            path,
14592            base_index_pos: 0,
14593        });
14594
14595        fs::write(sub.join("untracked.txt"), b"extra\n").expect("untracked");
14596
14597        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
14598            .expect("diff_tree_to_worktree");
14599        assert!(
14600            entries.is_empty(),
14601            "untracked-only submodule dirt must not appear in default tree→worktree diff: {entries:?}"
14602        );
14603    }
14604}
14605
14606#[cfg(test)]
14607mod racy_index_mtime_diff_tests {
14608    use super::*;
14609    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
14610    use crate::objects::{HashAlgo, ObjectKind};
14611    use filetime::FileTime;
14612    use std::fs;
14613    use std::path::PathBuf;
14614
14615    const INDEX_MTIME: (u32, u32) = (1_700_000_000, 123_456_789);
14616
14617    fn pin_mtime(path: &Path, sec: u32, nsec: u32) {
14618        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
14619            .expect("set mtime");
14620    }
14621
14622    fn minimal_sha256_repo(work_tree: &Path) -> (PathBuf, Odb) {
14623        let git_dir = work_tree.join(".git");
14624        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14625        fs::write(
14626            git_dir.join("config"),
14627            "[extensions]\n\tobjectformat = sha256\n",
14628        )
14629        .expect("config");
14630        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
14631        (git_dir, odb)
14632    }
14633
14634    #[test]
14635    fn detects_same_size_change_when_entry_mtime_matches_index_mtime() {
14636        let td = tempfile::tempdir().expect("tempdir");
14637        let wt = td.path();
14638        let content_a = b"same length!!";
14639        let content_b = b"same length??";
14640        assert_eq!(content_a.len(), content_b.len());
14641
14642        fs::write(wt.join("f.txt"), content_a).expect("write");
14643        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
14644        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14645        let git_dir = wt.join(".git");
14646        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14647        let odb = Odb::new(&git_dir.join("objects"));
14648        let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
14649        let entry = entry_from_metadata(&meta, b"f.txt", oid_a, MODE_REGULAR);
14650
14651        let mut index = Index::new();
14652        index.source_mtime = Some(INDEX_MTIME);
14653        index.entries.push(entry);
14654
14655        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14656        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
14657
14658        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
14659        assert_eq!(
14660            diff.len(),
14661            1,
14662            "racy entry must be re-hashed, not stat-trusted"
14663        );
14664        assert_eq!(diff[0].path(), "f.txt");
14665    }
14666
14667    #[test]
14668    #[cfg(unix)]
14669    fn non_utf8_symlink_clean_racy_equal_mtime_reports_no_diff() {
14670        use crate::index::MODE_SYMLINK;
14671        use std::os::unix::ffi::OsStrExt as _;
14672        use std::os::unix::fs::symlink;
14673
14674        let td = tempfile::tempdir().expect("tempdir");
14675        let wt = td.path();
14676        let target = std::ffi::OsStr::from_bytes(b"bad-\xff-target");
14677        let link_path = wt.join("link");
14678        symlink(target, &link_path).expect("symlink");
14679
14680        let ft = FileTime::from_unix_time(i64::from(INDEX_MTIME.0), INDEX_MTIME.1);
14681        filetime::set_symlink_file_times(&link_path, ft, ft).expect("set symlink mtime");
14682        let meta = fs::symlink_metadata(&link_path).expect("stat");
14683        let git_dir = wt.join(".git");
14684        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14685        let odb = Odb::new(&git_dir.join("objects"));
14686        let oid = odb.hash(ObjectKind::Blob, target.as_bytes()).expect("hash");
14687        let entry = entry_from_metadata(&meta, b"link", oid, MODE_SYMLINK);
14688
14689        let mut index = Index::new();
14690        index.source_mtime = Some(INDEX_MTIME);
14691        index.entries.push(entry);
14692
14693        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
14694        assert!(
14695            diff.is_empty(),
14696            "clean non-UTF-8 symlink must not be reported modified: {diff:?}"
14697        );
14698    }
14699
14700    #[test]
14701    fn sha256_clean_racy_equal_mtime_reports_no_diff() {
14702        let td = tempfile::tempdir().expect("tempdir");
14703        let wt = td.path();
14704        let (_git_dir, odb) = minimal_sha256_repo(wt);
14705        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
14706
14707        let bytes = b"sha256 width!!";
14708        fs::write(wt.join("f.txt"), bytes).expect("write");
14709        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
14710        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14711        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
14712        let entry = entry_from_metadata(&meta, b"f.txt", oid, MODE_REGULAR);
14713
14714        let mut index = Index::new();
14715        index.hash_algo = HashAlgo::Sha256;
14716        index.source_mtime = Some(INDEX_MTIME);
14717        index.entries.push(entry);
14718
14719        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
14720        assert!(
14721            diff.is_empty(),
14722            "clean SHA-256 racy entry must not be reported modified: {diff:?}"
14723        );
14724    }
14725
14726    /// Racy entries are hashed with their own path's attributes even when the paths
14727    /// alternate between attribute sets: CRLF files clean to LF, binary files hash as is.
14728    #[test]
14729    fn racy_entries_hash_with_their_own_attributes() {
14730        let td = tempfile::tempdir().expect("tempdir");
14731        let wt = td.path();
14732        let git_dir = wt.join(".git");
14733        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14734        fs::write(git_dir.join("config"), "[core]\n").expect("config");
14735        fs::write(wt.join(".gitattributes"), "*.crlf eol=crlf\n*.bin -text\n").expect("attrs");
14736        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
14737
14738        let files: [(&str, &[u8], &[u8]); 5] = [
14739            ("a.crlf", b"one\r\ntwo\r\n", b"one\ntwo\n"),
14740            ("b.bin", b"raw\r\nbytes\r\n", b"raw\r\nbytes\r\n"),
14741            ("c.crlf", b"three\r\n", b"three\n"),
14742            ("d.txt", b"plain\n", b"plain\n"),
14743            ("e.bin", b"more\r\n", b"more\r\n"),
14744        ];
14745        let mut index = Index::new();
14746        index.source_mtime = Some(INDEX_MTIME);
14747        for (path, on_disk, blob) in files {
14748            let file = wt.join(path);
14749            fs::write(&file, on_disk).expect("write");
14750            pin_mtime(&file, INDEX_MTIME.0, INDEX_MTIME.1);
14751            let meta = fs::symlink_metadata(&file).expect("stat");
14752            let oid = odb.hash(ObjectKind::Blob, blob).expect("hash");
14753            index.entries.push(entry_from_metadata(
14754                &meta,
14755                path.as_bytes(),
14756                oid,
14757                MODE_REGULAR,
14758            ));
14759        }
14760
14761        let status_diff = |index: &mut Index| {
14762            diff_index_to_worktree_with_options(
14763                &odb,
14764                index,
14765                wt,
14766                DiffIndexToWorktreeOptions {
14767                    index_mtime: Some(INDEX_MTIME),
14768                    repository_git_dir: Some(git_dir.clone()),
14769                    refresh_index_stat_in_pass: true,
14770                    for_status: true,
14771                    ..Default::default()
14772                },
14773            )
14774            .expect("diff")
14775            .0
14776        };
14777        let diff = status_diff(&mut index.clone());
14778        assert!(diff.is_empty(), "clean racy entries reported: {diff:?}");
14779
14780        fs::write(wt.join("c.crlf"), b"thrEe\r\n").expect("rewrite");
14781        pin_mtime(&wt.join("c.crlf"), INDEX_MTIME.0, INDEX_MTIME.1);
14782        let diff = status_diff(&mut index);
14783        let paths: Vec<_> = diff.iter().map(DiffEntry::path).collect();
14784        assert_eq!(paths, ["c.crlf"]);
14785    }
14786}
14787
14788#[cfg(test)]
14789mod smudge_racily_clean_tests {
14790    use super::*;
14791    use crate::config::ConfigSet;
14792    use crate::crlf::{self, FileAttrs};
14793    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
14794    use crate::objects::{HashAlgo, ObjectKind};
14795    use filetime::FileTime;
14796    use std::fs;
14797    use std::path::PathBuf;
14798
14799    const INDEX_MTIME_PIN: (u32, u32) = (1_700_000_000, 123_456_789);
14800
14801    fn mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
14802        #[cfg(unix)]
14803        {
14804            use std::os::unix::fs::MetadataExt;
14805            (meta.mtime() as u32, meta.mtime_nsec() as u32)
14806        }
14807        #[cfg(not(unix))]
14808        {
14809            use std::time::UNIX_EPOCH;
14810            let mtime = meta
14811                .modified()
14812                .ok()
14813                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
14814                .unwrap_or_default();
14815            (mtime.as_secs() as u32, mtime.subsec_nanos())
14816        }
14817    }
14818
14819    /// Pin mtime then read back the value the index would store (NTFS quantizes subseconds).
14820    fn pin_mtime_and_sample(path: &Path, sec: u32, nsec: u32) -> (u32, u32) {
14821        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
14822            .expect("set mtime");
14823        let meta = fs::symlink_metadata(path).expect("stat file");
14824        mtime_from_metadata(&meta)
14825    }
14826
14827    /// Sample the on-disk index mtime used in racy-git tests (matches filesystem quantization).
14828    fn setup_index_mtime(work_tree: &Path) -> (u32, u32) {
14829        let probe = work_tree.join(".grit-index-mtime-probe");
14830        fs::write(&probe, b"").expect("probe file");
14831        pin_mtime_and_sample(&probe, INDEX_MTIME_PIN.0, INDEX_MTIME_PIN.1)
14832    }
14833
14834    fn minimal_repo(work_tree: &Path, config: &str) -> (PathBuf, Odb) {
14835        let git_dir = work_tree.join(".git");
14836        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14837        fs::write(git_dir.join("config"), config).expect("config");
14838        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
14839        (git_dir, odb)
14840    }
14841
14842    fn staged_entry_for_file(
14843        odb: &Odb,
14844        work_tree: &Path,
14845        rel: &str,
14846        bytes: &[u8],
14847        mtime: (u32, u32),
14848    ) -> IndexEntry {
14849        let path = work_tree.join(rel);
14850        fs::write(&path, bytes).expect("write file");
14851        pin_mtime_and_sample(&path, mtime.0, mtime.1);
14852        let meta = fs::symlink_metadata(&path).expect("stat file");
14853        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
14854        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
14855    }
14856
14857    fn staged_entry_with_oid(
14858        work_tree: &Path,
14859        rel: &str,
14860        worktree_bytes: &[u8],
14861        oid: crate::objects::ObjectId,
14862        mtime: (u32, u32),
14863    ) -> IndexEntry {
14864        let path = work_tree.join(rel);
14865        fs::write(&path, worktree_bytes).expect("write file");
14866        pin_mtime_and_sample(&path, mtime.0, mtime.1);
14867        let meta = fs::symlink_metadata(&path).expect("stat file");
14868        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
14869    }
14870
14871    #[test]
14872    fn index_stat_cache_trustworthy_rejects_unknown_mtime() {
14873        assert!(!super::index_stat_cache_trustworthy(None));
14874        assert!(!super::index_stat_cache_trustworthy(Some((0, 0))));
14875        assert!(super::index_stat_cache_trustworthy(Some((1, 0))));
14876    }
14877
14878    #[test]
14879    fn index_entry_skips_content_hash_after_stat_match_when_trusted_and_not_racy() {
14880        use crate::index::StatMatchPolicy;
14881        let td = tempfile::tempdir().expect("tempdir");
14882        let wt = td.path();
14883        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = true\n");
14884        let rel = "trusted.txt";
14885        let file_mtime = (1_700_000_000, 0);
14886        let mut entry = staged_entry_for_file(&odb, wt, rel, b"payload\n", file_mtime);
14887        let index_mtime = (file_mtime.0 + 1, 0);
14888        let policy = StatMatchPolicy::from_config(None);
14889        assert!(
14890            super::index_entry_skips_worktree_content_hash_after_stat_match(
14891                &entry,
14892                Some(index_mtime),
14893                policy,
14894            )
14895        );
14896        entry.size = 0;
14897        entry.mtime_sec = 0;
14898        entry.mtime_nsec = 0;
14899        assert!(
14900            !super::index_entry_skips_worktree_content_hash_after_stat_match(
14901                &entry,
14902                Some(index_mtime),
14903                policy,
14904            )
14905        );
14906        let _ = git_dir;
14907    }
14908
14909    #[cfg(unix)]
14910    #[test]
14911    fn symlink_dir_cache_detects_symlinked_ancestors_across_threads() {
14912        let td = tempfile::tempdir().expect("tempdir");
14913        let wt = td.path();
14914        fs::create_dir_all(wt.join("real/deep")).expect("mkdir");
14915        fs::create_dir_all(wt.join("target/deep")).expect("mkdir");
14916        std::os::unix::fs::symlink(wt.join("target"), wt.join("linked")).expect("symlink");
14917        std::thread::scope(|scope| {
14918            for _ in 0..4 {
14919                scope.spawn(|| {
14920                    let mut cache = super::SymlinkDirCache::default();
14921                    assert!(!cache.has_symlink_in_path_bytes(wt, b"top.txt", false, false));
14922                    assert!(!cache.has_symlink_in_path_bytes(wt, b"linked", false, false));
14923                    assert!(!cache.has_symlink_in_path_bytes(wt, b"real/deep/f", false, false));
14924                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/f", false, false));
14925                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/deep/f", false, false));
14926                    assert!(!cache.has_symlink_in_path_bytes(wt, b"\xff\xfe", false, false));
14927                });
14928            }
14929        });
14930    }
14931
14932    #[cfg(unix)]
14933    #[test]
14934    fn symlink_dir_cache_accepts_a_work_tree_reached_through_a_symlink() {
14935        let td = tempfile::tempdir().expect("tempdir");
14936        let real = td.path().join("real");
14937        fs::create_dir_all(real.join("a/b/c")).expect("mkdir");
14938        fs::create_dir_all(real.join("t/b/c")).expect("mkdir");
14939        std::os::unix::fs::symlink(real.join("t"), real.join("l")).expect("symlink");
14940        let wt = td.path().join("alias");
14941        std::os::unix::fs::symlink(&real, &wt).expect("symlink");
14942        let mut cache = super::SymlinkDirCache::default();
14943        assert!(!cache.has_symlink_in_path_bytes(&wt, b"a/b/c/f", false, false));
14944        assert!(cache.has_symlink_in_path_bytes(&wt, b"l/b/c/f", false, false));
14945        assert!(!cache.has_symlink_in_path_bytes(&real, b"a/b/c/f", false, false));
14946        assert!(cache.has_symlink_in_path_bytes(&real, b"l/b/c/f", false, false));
14947    }
14948
14949    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
14950    #[test]
14951    fn real_dir_chain_confirms_only_symlink_free_directories() {
14952        let td = tempfile::tempdir().expect("tempdir");
14953        let wt = td.path();
14954        fs::create_dir_all(wt.join("a/b/c")).expect("mkdir");
14955        fs::create_dir_all(wt.join("t/c")).expect("mkdir");
14956        fs::write(wt.join("a/file"), b"f").expect("write");
14957        std::os::unix::fs::symlink(wt.join("t"), wt.join("a/l")).expect("symlink");
14958        let mut chain = crate::worktree_scan::RealDirChain::open(wt);
14959        assert_eq!(chain.work_tree(), wt);
14960        assert!(chain.confirms(b"a"));
14961        assert!(chain.confirms(b"a/b/c"));
14962        assert!(!chain.confirms(b"a/l"));
14963        assert!(!chain.confirms(b"a/l/c"));
14964        assert!(!chain.confirms(b"a/file"));
14965        assert!(!chain.confirms(b"a/missing/c"));
14966        assert!(!chain.confirms(b""));
14967        assert!(!chain.confirms(b"a/\0b"));
14968        assert!(chain.confirms(b"a/b"));
14969    }
14970
14971    #[test]
14972    fn symlink_dir_cache_probes_each_new_directory_once_in_index_order() {
14973        let td = tempfile::tempdir().expect("tempdir");
14974        let wt = td.path();
14975        let depth = 12;
14976        let chains = 20;
14977        let mut paths = Vec::new();
14978        for chain in 0..chains {
14979            let dir: Vec<String> = (0..depth)
14980                .map(|level| format!("c{chain:02}l{level:02}"))
14981                .collect();
14982            let dir = format!("shared/{}", dir.join("/"));
14983            fs::create_dir_all(wt.join(&dir)).expect("mkdir");
14984            paths.extend((0..3).map(|file| format!("{dir}/f{file}")));
14985        }
14986        paths.sort();
14987        let mut cache = super::SymlinkDirCache::default();
14988        for rel in &paths {
14989            assert!(!cache.has_symlink_in_path_bytes(wt, rel.as_bytes(), false, false));
14990        }
14991        // Each chain's directories are new; `shared` and sibling files are already known.
14992        let unique_dirs = 1 + chains * depth;
14993        assert!(
14994            cache.fs_queries <= unique_dirs,
14995            "{} filesystem queries for {unique_dirs} directories",
14996            cache.fs_queries
14997        );
14998        #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
14999        assert!(
15000            cache.fs_queries <= chains + 1,
15001            "one chain walk per new directory: {} queries for {chains} chains",
15002            cache.fs_queries
15003        );
15004    }
15005
15006    #[test]
15007    fn common_dir_prefix_len_stops_at_whole_components() {
15008        use super::{common_dir_prefix_len, path_within_dir};
15009        assert_eq!(common_dir_prefix_len(b"a/b/c", b"a/b/c"), 5);
15010        assert_eq!(common_dir_prefix_len(b"a/b/c", b"a/b"), 3);
15011        assert_eq!(common_dir_prefix_len(b"a/b", b"a/b/c"), 3);
15012        assert_eq!(common_dir_prefix_len(b"a/bc/d", b"a/b"), 1);
15013        assert_eq!(common_dir_prefix_len(b"a/b", b"a/bc/d"), 1);
15014        assert_eq!(common_dir_prefix_len(b"ab", b"a"), 0);
15015        assert_eq!(common_dir_prefix_len(b"a/b", b""), 0);
15016        assert!(path_within_dir(b"a/b", b"a/b"));
15017        assert!(path_within_dir(b"a/b/c", b"a/b"));
15018        assert!(!path_within_dir(b"a/bc", b"a/b"));
15019        assert!(!path_within_dir(b"a", b"a/b"));
15020    }
15021
15022    /// Answer the ancestor-symlink question without any caching, one probe per ancestor.
15023    #[cfg(unix)]
15024    fn naive_has_symlink_ancestor(wt: &Path, rel: &str) -> bool {
15025        let Some((dir, _)) = rel.rsplit_once('/') else {
15026            return false;
15027        };
15028        let mut prefix = wt.to_path_buf();
15029        dir.split('/').any(|component| {
15030            prefix.push(component);
15031            fs::symlink_metadata(&prefix).is_ok_and(|m| m.file_type().is_symlink())
15032        })
15033    }
15034
15035    #[cfg(unix)]
15036    #[test]
15037    fn symlink_dir_cache_matches_uncached_probe_in_any_query_order() {
15038        let td = tempfile::tempdir().expect("tempdir");
15039        let wt = td.path();
15040        fs::create_dir_all(wt.join("a/b/c/d")).expect("mkdir");
15041        fs::create_dir_all(wt.join("a/bb/c")).expect("mkdir");
15042        fs::create_dir_all(wt.join("a/b2")).expect("mkdir");
15043        fs::create_dir_all(wt.join("t/c/d")).expect("mkdir");
15044        fs::create_dir_all(wt.join("x/y")).expect("mkdir");
15045        fs::write(wt.join("a/file"), b"f").expect("write");
15046        fs::write(wt.join("x/y/plain"), b"f").expect("write");
15047        std::os::unix::fs::symlink(wt.join("t"), wt.join("a/b/link")).expect("symlink");
15048        std::os::unix::fs::symlink(wt.join("t"), wt.join("x/l")).expect("symlink");
15049        std::os::unix::fs::symlink(wt.join("t"), wt.join("top")).expect("symlink");
15050
15051        let paths = [
15052            "a/b/c/d/f",
15053            "a/b/c/f",
15054            "a/b/f",
15055            "a/b/link/f",
15056            "a/b/link/c/d/f",
15057            "a/b/linker/f",
15058            "a/b2/f",
15059            "a/bb/c/f",
15060            "a/file/under/f",
15061            "a/missing/deeper/f",
15062            "a/missing/f",
15063            "a/f",
15064            "f",
15065            "top/c/f",
15066            "top/f",
15067            "topper/f",
15068            "x/l/c/d/f",
15069            "x/y/f",
15070            "x/y/plain/f",
15071        ];
15072        let mut orders: Vec<Vec<&str>> = vec![paths.to_vec()];
15073        orders.push(paths.iter().rev().copied().collect());
15074        orders.push(
15075            paths
15076                .iter()
15077                .step_by(2)
15078                .chain(paths.iter().skip(1).step_by(2))
15079                .copied()
15080                .collect(),
15081        );
15082        orders.push(paths.iter().flat_map(|p| [*p, *p]).collect());
15083        for order in orders {
15084            let mut cache = super::SymlinkDirCache::default();
15085            for rel in order {
15086                assert_eq!(
15087                    cache.has_symlink_in_path_bytes(wt, rel.as_bytes(), false, false),
15088                    naive_has_symlink_ancestor(wt, rel),
15089                    "ancestor symlink answer for {rel}"
15090                );
15091            }
15092        }
15093    }
15094
15095    #[test]
15096    fn content_hash_skip_trusts_empty_blob_but_not_smudged_size() {
15097        use crate::index::StatMatchPolicy;
15098        let td = tempfile::tempdir().expect("tempdir");
15099        let wt = td.path();
15100        let (_git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
15101        let file_mtime = (1_700_000_000, 0);
15102        let index_mtime = Some((file_mtime.0 + 1, 0));
15103        let policy = StatMatchPolicy::from_config(None);
15104
15105        let empty = staged_entry_for_file(&odb, wt, "empty.txt", b"", file_mtime);
15106        assert_eq!(empty.size, 0);
15107        assert!(
15108            super::index_entry_skips_worktree_content_hash_after_stat_match(
15109                &empty,
15110                index_mtime,
15111                policy,
15112            )
15113        );
15114
15115        let smudged_oid = odb.hash(ObjectKind::Blob, b"abc\n").expect("hash");
15116        let smudged = staged_entry_with_oid(wt, "smudged.txt", b"", smudged_oid, file_mtime);
15117        assert_eq!(smudged.size, 0);
15118        assert!(
15119            !super::index_entry_skips_worktree_content_hash_after_stat_match(
15120                &smudged,
15121                index_mtime,
15122                policy,
15123            )
15124        );
15125    }
15126
15127    #[test]
15128    fn status_diff_reports_smudged_entry_whose_file_became_empty() {
15129        let td = tempfile::tempdir().expect("tempdir");
15130        let wt = td.path();
15131        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
15132        let oid = odb.write(ObjectKind::Blob, b"abc\n").expect("write blob");
15133        let file_mtime = (1_700_000_000, 0);
15134        let mut index = Index::new();
15135        index.entries.push(staged_entry_with_oid(
15136            wt,
15137            "smudged.txt",
15138            b"",
15139            oid,
15140            file_mtime,
15141        ));
15142        let (entries, _) = super::diff_index_to_worktree_with_options(
15143            &odb,
15144            &mut index,
15145            wt,
15146            super::DiffIndexToWorktreeOptions {
15147                repository_git_dir: Some(git_dir),
15148                index_mtime: Some((file_mtime.0 + 1, 0)),
15149                for_status: true,
15150                stat_parallel_threads: Some(1),
15151                ..Default::default()
15152            },
15153        )
15154        .expect("diff");
15155        assert_eq!(entries.len(), 1, "{entries:?}");
15156        assert_eq!(entries[0].status, DiffStatus::Modified);
15157    }
15158
15159    #[test]
15160    fn status_diff_verifies_content_when_index_stat_cache_unset() {
15161        let td = tempfile::tempdir().expect("tempdir");
15162        let wt = td.path();
15163        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15164        let rel = "unset-stat.txt";
15165        let bytes = b"same bytes\n";
15166        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
15167        fs::write(wt.join(rel), bytes).expect("write");
15168        let mut index = Index::new();
15169        let mut entry = staged_entry_for_file(&odb, wt, rel, bytes, (100, 0));
15170        entry.size = 0;
15171        entry.mtime_sec = 0;
15172        entry.mtime_nsec = 0;
15173        entry.oid = oid;
15174        index.entries.push(entry);
15175        let (entries, _) = super::diff_index_to_worktree_with_options(
15176            &odb,
15177            &mut index,
15178            wt,
15179            super::DiffIndexToWorktreeOptions {
15180                repository_git_dir: Some(git_dir),
15181                for_status: true,
15182                ..Default::default()
15183            },
15184        )
15185        .expect("diff");
15186        assert!(
15187            entries.is_empty(),
15188            "unset stat must not size-mismatch a matching worktree: {entries:?}"
15189        );
15190    }
15191
15192    #[cfg_attr(
15193        not(feature = "full-tests"),
15194        ignore = "starts system git or is heavy; run with --features full-tests"
15195    )]
15196    #[test]
15197    fn status_reports_modified_when_ctime_differs_but_mtime_sec_and_size_match_non_racy() {
15198        let td = tempfile::tempdir().expect("tempdir");
15199        let wt = td.path();
15200        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15201        let rel = "tracked.txt";
15202        let original = b"original\n";
15203        let modified = b"modified\n";
15204        assert_eq!(original.len(), modified.len());
15205
15206        let entry_mtime = (1_700_000_000u32, 0u32);
15207        let index_file_mtime = (1_700_000_100u32, 0u32);
15208
15209        std::process::Command::new("git")
15210            .args(["-C", &wt.to_string_lossy(), "init", "-q"])
15211            .status()
15212            .expect("git init");
15213        std::process::Command::new("git")
15214            .args([
15215                "-C",
15216                &wt.to_string_lossy(),
15217                "config",
15218                "user.email",
15219                "t@example.com",
15220            ])
15221            .status()
15222            .expect("git config email");
15223        std::process::Command::new("git")
15224            .args(["-C", &wt.to_string_lossy(), "config", "user.name", "T"])
15225            .status()
15226            .expect("git config name");
15227
15228        fs::write(wt.join(rel), original).expect("write original");
15229        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
15230        std::process::Command::new("git")
15231            .args(["-C", &wt.to_string_lossy(), "add", rel])
15232            .status()
15233            .expect("git add");
15234        std::process::Command::new("git")
15235            .args([
15236                "-C",
15237                &wt.to_string_lossy(),
15238                "commit",
15239                "-qm",
15240                "seed",
15241                "--no-gpg-sign",
15242            ])
15243            .status()
15244            .expect("git commit");
15245
15246        let mut index = Index::new();
15247        index.source_mtime = Some(index_file_mtime);
15248        let staged = staged_entry_for_file(&odb, wt, rel, original, entry_mtime);
15249        let oid_original = staged.oid;
15250        index.entries.push(staged);
15251
15252        fs::write(wt.join(rel), modified).expect("modify worktree");
15253        let git_dirty = std::process::Command::new("git")
15254            .args([
15255                "-C",
15256                &wt.to_string_lossy(),
15257                "--no-optional-locks",
15258                "status",
15259                "--short",
15260            ])
15261            .output()
15262            .expect("git status");
15263        assert!(
15264            git_dirty.status.success(),
15265            "git status failed: {}",
15266            String::from_utf8_lossy(&git_dirty.stderr)
15267        );
15268        assert!(
15269            String::from_utf8_lossy(&git_dirty.stdout).contains("tracked.txt"),
15270            "system git must see modification before mtime pin: {}",
15271            String::from_utf8_lossy(&git_dirty.stdout)
15272        );
15273
15274        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
15275
15276        let meta = fs::symlink_metadata(wt.join(rel)).expect("stat");
15277        let ie = &mut index.entries[0];
15278        assert_eq!(ie.oid, oid_original);
15279        if stat_matches(ie, &meta) {
15280            ie.ctime_sec = ie.ctime_sec.wrapping_sub(3600);
15281        }
15282        assert!(
15283            !stat_matches(ie, &meta),
15284            "ctime/inode change must fail full stat match"
15285        );
15286        let stat_policy = crate::index::StatMatchPolicy::default();
15287        assert!(
15288            !entry_is_racy_with_policy(ie, Some(index_file_mtime), stat_policy),
15289            "fixture must be non-racy"
15290        );
15291
15292        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
15293            &odb,
15294            &mut index,
15295            wt,
15296            super::DiffIndexToWorktreeOptions {
15297                index_mtime: Some(index_file_mtime),
15298                repository_git_dir: Some(git_dir),
15299                refresh_index_stat_in_pass: true,
15300                for_status: true,
15301                ..Default::default()
15302            },
15303        )
15304        .expect("diff");
15305        assert_eq!(
15306            entries.len(),
15307            1,
15308            "must report modified, not refresh stat clean: {entries:?}, index_changed={index_changed}"
15309        );
15310        assert_eq!(entries[0].path(), rel);
15311    }
15312
15313    #[test]
15314    fn diff_name_status_skips_big_worktree_read_on_size_mismatch() {
15315        let td = tempfile::tempdir().expect("tempdir");
15316        let wt = td.path();
15317        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tbigFileThreshold = 4k\n");
15318        let rel = "big.bin";
15319        let indexed = vec![b'x'; 100];
15320        let mut index = Index::new();
15321        index
15322            .entries
15323            .push(staged_entry_for_file(&odb, wt, rel, &indexed, (100, 0)));
15324        fs::write(wt.join(rel), vec![b'y'; 5000]).expect("rewrite bigger");
15325        let metrics = odb.hot_path_metrics();
15326        metrics.reset_blob_content_reads();
15327        metrics.set_blob_counting(true);
15328        let (entries, _) = super::diff_index_to_worktree_with_options(
15329            &odb,
15330            &mut index,
15331            wt,
15332            super::DiffIndexToWorktreeOptions {
15333                repository_git_dir: Some(git_dir),
15334                for_status: false,
15335                ..Default::default()
15336            },
15337        )
15338        .expect("diff");
15339        metrics.set_blob_counting(false);
15340        assert_eq!(entries.len(), 1);
15341        assert_eq!(entries[0].status, super::DiffStatus::Modified);
15342        assert_eq!(entries[0].new_oid, super::zero_oid());
15343        assert_eq!(
15344            metrics.blob_content_reads(),
15345            0,
15346            "large size-only drift must not hash or read the worktree"
15347        );
15348    }
15349
15350    #[test]
15351    fn status_diff_skips_hash_when_worktree_size_differs() {
15352        let td = tempfile::tempdir().expect("tempdir");
15353        let wt = td.path();
15354        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15355        let rel = "size-change.txt";
15356        let original = b"short\n";
15357        let mut index = Index::new();
15358        index
15359            .entries
15360            .push(staged_entry_for_file(&odb, wt, rel, original, (100, 0)));
15361        fs::write(wt.join(rel), b"longer content\n").expect("rewrite");
15362        let metrics = odb.hot_path_metrics();
15363        metrics.reset_blob_content_reads();
15364        metrics.set_blob_counting(true);
15365        let (entries, _) = super::diff_index_to_worktree_with_options(
15366            &odb,
15367            &mut index,
15368            wt,
15369            super::DiffIndexToWorktreeOptions {
15370                repository_git_dir: Some(git_dir),
15371                for_status: true,
15372                ..Default::default()
15373            },
15374        )
15375        .expect("diff");
15376        metrics.set_blob_counting(false);
15377        assert_eq!(entries.len(), 1);
15378        assert_eq!(entries[0].status, super::DiffStatus::Modified);
15379        assert_eq!(entries[0].path(), rel);
15380        assert_eq!(entries[0].old_oid, index.entries[0].oid);
15381        assert_eq!(entries[0].new_oid, super::zero_oid());
15382        assert_eq!(
15383            metrics.blob_content_reads(),
15384            0,
15385            "identity conversion: a size change alone proves the file modified"
15386        );
15387    }
15388
15389    #[test]
15390    fn status_diff_hashes_size_change_when_eol_conversion_restores_blob() {
15391        for refresh_index_stat_in_pass in [false, true] {
15392            let td = tempfile::tempdir().expect("tempdir");
15393            let wt = td.path();
15394            let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15395            fs::write(wt.join(".gitattributes"), "*.txt text eol=crlf\n").expect("attrs");
15396            let rel = "n.txt";
15397            let blob = odb
15398                .write(ObjectKind::Blob, b"one\ntwo\n")
15399                .expect("write blob");
15400            let mut index = Index::new();
15401            index.entries.push(staged_entry_with_oid(
15402                wt,
15403                rel,
15404                b"one\r\ntwo\r\n",
15405                blob,
15406                (100, 0),
15407            ));
15408            fs::write(wt.join(rel), b"one\ntwo\n").expect("rewrite as LF");
15409            let (entries, index_changed) = super::diff_index_to_worktree_with_options(
15410                &odb,
15411                &mut index,
15412                wt,
15413                super::DiffIndexToWorktreeOptions {
15414                    index_mtime: Some((200, 0)),
15415                    repository_git_dir: Some(git_dir),
15416                    refresh_index_stat_in_pass,
15417                    for_status: true,
15418                    ..Default::default()
15419                },
15420            )
15421            .expect("diff");
15422            assert!(
15423                entries.is_empty(),
15424                "refresh={refresh_index_stat_in_pass}: LF worktree cleans to the indexed blob: \
15425                 {entries:?}"
15426            );
15427            if refresh_index_stat_in_pass {
15428                assert!(index_changed, "verified entry adopts the new stat");
15429                assert_eq!(index.entries[0].size, 8);
15430            }
15431        }
15432    }
15433
15434    #[test]
15435    fn status_diff_reads_size_changed_converted_file_once() {
15436        for refresh_index_stat_in_pass in [false, true] {
15437            let td = tempfile::tempdir().expect("tempdir");
15438            let wt = td.path();
15439            let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15440            fs::write(wt.join(".gitattributes"), "*.txt text eol=crlf\n").expect("attrs");
15441            let rel = "n.txt";
15442            let blob = odb
15443                .write(ObjectKind::Blob, b"one\ntwo\n")
15444                .expect("write blob");
15445            let mut index = Index::new();
15446            index.entries.push(staged_entry_with_oid(
15447                wt,
15448                rel,
15449                b"one\r\ntwo\r\n",
15450                blob,
15451                (100, 0),
15452            ));
15453            fs::write(wt.join(rel), b"one\ntwo\nthree\n").expect("real edit");
15454            let metrics = odb.hot_path_metrics();
15455            metrics.reset_blob_content_reads();
15456            metrics.set_blob_counting(true);
15457            let (entries, _) = super::diff_index_to_worktree_with_options(
15458                &odb,
15459                &mut index,
15460                wt,
15461                super::DiffIndexToWorktreeOptions {
15462                    index_mtime: Some((200, 0)),
15463                    repository_git_dir: Some(git_dir),
15464                    refresh_index_stat_in_pass,
15465                    for_status: true,
15466                    ..Default::default()
15467                },
15468            )
15469            .expect("diff");
15470            metrics.set_blob_counting(false);
15471            assert_eq!(entries.len(), 1, "refresh={refresh_index_stat_in_pass}");
15472            assert_eq!(entries[0].status, super::DiffStatus::Modified);
15473            assert_eq!(entries[0].new_oid, zero_oid());
15474            assert_eq!(
15475                index.entries[0].size, 10,
15476                "a modified file keeps its stale stat"
15477            );
15478            assert_eq!(
15479                metrics.blob_content_reads(),
15480                1,
15481                "refresh={refresh_index_stat_in_pass}: read once, not again after the refresh"
15482            );
15483        }
15484    }
15485
15486    #[test]
15487    fn stat_matches_ignores_subsecond_nsec_by_default() {
15488        let td = tempfile::tempdir().expect("tempdir");
15489        let wt = td.path();
15490        fs::write(wt.join("f.txt"), b"x\n").expect("write");
15491        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
15492        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15493        let mut entry = entry_from_metadata(&meta, b"f.txt", zero_oid(), MODE_REGULAR);
15494        entry.mtime_nsec = entry.mtime_nsec.wrapping_add(1_000_000);
15495        assert!(stat_matches(&entry, &meta));
15496    }
15497
15498    #[test]
15499    fn classify_add_up_to_date_when_stat_matches() {
15500        let td = tempfile::tempdir().expect("tempdir");
15501        let wt = td.path();
15502        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
15503        let bytes = b"hello world\n";
15504        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, (100, 0));
15505        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15506        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15507        let attrs = FileAttrs::default();
15508        let params = WorktreeAddRefreshParams {
15509            odb: &odb,
15510            ie: &entry,
15511            meta: &meta,
15512            abs_path: &wt.join("f.txt"),
15513            rel_path: "f.txt",
15514            conv: &conv,
15515            file_attrs: &attrs,
15516            index_mtime: Some((200, 0)),
15517            staged_mode: MODE_REGULAR,
15518            filter_process: None,
15519        };
15520        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
15521        assert_eq!(refresh, WorktreeAddRefresh::UpToDate);
15522    }
15523
15524    #[test]
15525    fn classify_add_rehashes_when_index_mtime_unknown_and_stat_matches() {
15526        let td = tempfile::tempdir().expect("tempdir");
15527        let wt = td.path();
15528        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
15529        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
15530        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
15531        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
15532        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15533        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15534        let attrs = FileAttrs::default();
15535        let params = WorktreeAddRefreshParams {
15536            odb: &odb,
15537            ie: &entry,
15538            meta: &meta,
15539            abs_path: &wt.join("f.txt"),
15540            rel_path: "f.txt",
15541            conv: &conv,
15542            file_attrs: &attrs,
15543            index_mtime: None,
15544            staged_mode: MODE_REGULAR,
15545            filter_process: None,
15546        };
15547        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
15548        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
15549    }
15550
15551    #[test]
15552    fn classify_add_needs_restaging_when_content_differs() {
15553        let td = tempfile::tempdir().expect("tempdir");
15554        let wt = td.path();
15555        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
15556        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
15557        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
15558        pin_mtime_and_sample(&wt.join("f.txt"), 101, 0);
15559        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15560        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15561        let attrs = FileAttrs::default();
15562        let params = WorktreeAddRefreshParams {
15563            odb: &odb,
15564            ie: &entry,
15565            meta: &meta,
15566            abs_path: &wt.join("f.txt"),
15567            rel_path: "f.txt",
15568            conv: &conv,
15569            file_attrs: &attrs,
15570            index_mtime: Some((200, 0)),
15571            staged_mode: MODE_REGULAR,
15572            filter_process: None,
15573        };
15574        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
15575        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
15576    }
15577
15578    #[test]
15579    fn smudges_racily_clean_modified_entry() {
15580        let td = tempfile::tempdir().expect("tempdir");
15581        let wt = td.path();
15582        let index_mtime = setup_index_mtime(wt);
15583        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15584        let content_a = b"same length!!";
15585        let content_b = b"same length??";
15586        assert_eq!(content_a.len(), content_b.len());
15587
15588        let mut index = Index::new();
15589        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
15590        let stale_oid = entry.oid;
15591
15592        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
15593        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15594        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15595        // Stat was refreshed from the worktree but the blob OID is still stale (racy window).
15596        let entry = entry_from_metadata(&meta, b"f.txt", stale_oid, MODE_REGULAR);
15597        index.entries.push(entry.clone());
15598
15599        assert!(stat_matches(&entry, &meta));
15600        assert!(entry_is_racy(&entry, Some(index_mtime)));
15601
15602        let smudged = smudge_racily_clean_entries(
15603            &odb,
15604            &git_dir,
15605            &mut index,
15606            wt,
15607            Some(index_mtime),
15608            None,
15609            None,
15610        );
15611        assert!(smudged);
15612        assert_eq!(index.entries[0].size, 0);
15613
15614        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
15615        assert_eq!(diff.len(), 1);
15616        assert_eq!(diff[0].path(), "f.txt");
15617    }
15618
15619    /// A racy index row plus the git dir and odb it lives in; the worktree no longer matches.
15620    fn racy_stale_entry(wt: &Path) -> (PathBuf, Odb, Index, (u32, u32), ObjectId) {
15621        let index_mtime = setup_index_mtime(wt);
15622        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15623        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
15624        let stale_oid = entry.oid;
15625        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
15626        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15627        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15628        let mut index = Index::new();
15629        index.entries.push(entry_from_metadata(
15630            &meta,
15631            b"f.txt",
15632            stale_oid,
15633            MODE_REGULAR,
15634        ));
15635        (git_dir, odb, index, index_mtime, stale_oid)
15636    }
15637
15638    #[test]
15639    fn content_verified_entry_is_not_reread_and_the_mark_is_consumed() {
15640        let td = tempfile::tempdir().expect("tempdir");
15641        let wt = td.path();
15642        let (git_dir, odb, mut index, index_mtime, stale_oid) = racy_stale_entry(wt);
15643        index.mark_content_verified(b"f.txt", stale_oid);
15644
15645        let smudge = |index: &mut Index| {
15646            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
15647        };
15648        assert!(!smudge(&mut index), "a verified entry is trusted");
15649        assert_ne!(index.entries[0].size, 0);
15650        assert!(index.content_verified.is_empty());
15651        assert!(smudge(&mut index), "without the mark the entry is checked");
15652        assert_eq!(index.entries[0].size, 0);
15653    }
15654
15655    #[test]
15656    fn content_verified_entry_bit_is_trusted_and_consumed() {
15657        let td = tempfile::tempdir().expect("tempdir");
15658        let wt = td.path();
15659        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
15660        index.entries[0].mark_content_verified();
15661
15662        let smudge = |index: &mut Index| {
15663            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
15664        };
15665        assert!(!smudge(&mut index), "a marked entry is trusted");
15666        assert_ne!(index.entries[0].size, 0);
15667        assert_eq!(
15668            index.entries[0].flags_extended, None,
15669            "the mark is consumed"
15670        );
15671        assert!(smudge(&mut index), "without the mark the entry is checked");
15672        assert_eq!(index.entries[0].size, 0);
15673    }
15674
15675    #[test]
15676    fn content_verified_mark_for_another_oid_does_not_skip_the_check() {
15677        let td = tempfile::tempdir().expect("tempdir");
15678        let wt = td.path();
15679        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
15680        let other = odb.hash(ObjectKind::Blob, b"other").expect("hash");
15681        index.mark_content_verified(b"f.txt", other);
15682        assert!(smudge_racily_clean_entries(
15683            &odb,
15684            &git_dir,
15685            &mut index,
15686            wt,
15687            Some(index_mtime),
15688            None,
15689            None,
15690        ));
15691        assert_eq!(index.entries[0].size, 0);
15692    }
15693
15694    #[test]
15695    fn leaves_non_racy_entry_alone() {
15696        let td = tempfile::tempdir().expect("tempdir");
15697        let wt = td.path();
15698        let index_mtime = setup_index_mtime(wt);
15699        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15700        let content_a = b"same length!!";
15701        let content_b = b"same length??";
15702        let entry_mtime = (index_mtime.0 - 10, 0);
15703
15704        let mut index = Index::new();
15705        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, entry_mtime);
15706        index.entries.push(entry.clone());
15707
15708        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
15709        pin_mtime_and_sample(&wt.join("f.txt"), entry_mtime.0, entry_mtime.1);
15710
15711        assert!(!entry_is_racy(&entry, Some(index_mtime)));
15712        let smudged = smudge_racily_clean_entries(
15713            &odb,
15714            &git_dir,
15715            &mut index,
15716            wt,
15717            Some(index_mtime),
15718            None,
15719            None,
15720        );
15721        assert!(!smudged);
15722        assert_eq!(index.entries[0].size, entry.size);
15723    }
15724
15725    #[test]
15726    fn leaves_matching_content_alone() {
15727        let td = tempfile::tempdir().expect("tempdir");
15728        let wt = td.path();
15729        let index_mtime = setup_index_mtime(wt);
15730        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15731        let bytes = b"unchanged!!!!";
15732
15733        let mut index = Index::new();
15734        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
15735        index.entries.push(entry.clone());
15736
15737        assert!(entry_is_racy(&entry, Some(index_mtime)));
15738        let smudged = smudge_racily_clean_entries(
15739            &odb,
15740            &git_dir,
15741            &mut index,
15742            wt,
15743            Some(index_mtime),
15744            None,
15745            None,
15746        );
15747        assert!(!smudged);
15748        assert_eq!(index.entries[0].size, entry.size);
15749    }
15750
15751    #[test]
15752    fn status_diff_skips_refresh_rewrite_for_racy_clean_matching_stat() {
15753        let td = tempfile::tempdir().expect("tempdir");
15754        let wt = td.path();
15755        let index_mtime = setup_index_mtime(wt);
15756        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15757        let bytes = b"racy clean payload\n";
15758
15759        let mut index = Index::new();
15760        index.source_mtime = Some(index_mtime);
15761        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
15762        index.entries.push(entry.clone());
15763        assert!(entry_is_racy(&entry, Some(index_mtime)));
15764
15765        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
15766            &odb,
15767            &mut index,
15768            wt,
15769            super::DiffIndexToWorktreeOptions {
15770                repository_git_dir: Some(git_dir),
15771                index_mtime: Some(index_mtime),
15772                refresh_index_stat_in_pass: true,
15773                for_status: true,
15774                ..Default::default()
15775            },
15776        )
15777        .expect("status diff");
15778        assert!(entries.is_empty(), "unexpected unstaged: {entries:?}");
15779        assert!(
15780            !index_changed,
15781            "matching stat on a racy entry must skip refresh without rewriting the index"
15782        );
15783    }
15784
15785    #[test]
15786    fn leaves_matching_sha256_racy_content_alone() {
15787        let td = tempfile::tempdir().expect("tempdir");
15788        let wt = td.path();
15789        let index_mtime = setup_index_mtime(wt);
15790        let (git_dir, odb) = minimal_repo(wt, "[extensions]\n\tobjectformat = sha256\n");
15791        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
15792
15793        let bytes = b"sha256 width!!";
15794        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
15795        let mut index = Index::new();
15796        let entry = staged_entry_with_oid(wt, "f.txt", bytes, oid, index_mtime);
15797        index.entries.push(entry.clone());
15798
15799        assert!(entry_is_racy(&entry, Some(index_mtime)));
15800        let smudged = smudge_racily_clean_entries(
15801            &odb,
15802            &git_dir,
15803            &mut index,
15804            wt,
15805            Some(index_mtime),
15806            None,
15807            None,
15808        );
15809        assert!(!smudged, "matching SHA-256 blob must not be smudged");
15810        assert_eq!(index.entries[0].size, entry.size);
15811    }
15812
15813    #[test]
15814    fn materialize_big_binary_under_autocrlf_streams_into_pack() {
15815        use std::io::Write;
15816
15817        let td = tempfile::tempdir().expect("tempdir");
15818        let wt = td.path();
15819        let (git_dir, odb) =
15820            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 64k\n");
15821        let path = wt.join("big.bin");
15822        {
15823            let mut f = fs::File::create(&path).expect("create");
15824            f.write_all(&[0u8; 9000]).expect("nul prefix");
15825            let chunk = vec![1u8; 256 * 1024];
15826            for _ in 0..20 {
15827                f.write_all(&chunk).expect("chunk");
15828            }
15829        }
15830        let meta = fs::metadata(&path).expect("stat");
15831        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15832        conv.autocrlf = crlf::AutoCrlf::True;
15833        let attrs = FileAttrs::default();
15834        let oid =
15835            materialize_worktree_blob(&odb, &path, &meta, &conv, &attrs, "big.bin", None, None)
15836                .expect("materialize");
15837        assert!(
15838            !odb.test_loose_object_path(&oid).exists(),
15839            "big binary under autocrlf should pack, not write loose"
15840        );
15841        let pack_dir = git_dir.join("objects/pack");
15842        let names: Vec<_> = fs::read_dir(&pack_dir)
15843            .expect("pack dir")
15844            .map(|e| e.expect("entry").file_name())
15845            .collect();
15846        assert!(
15847            names.iter().any(|n| n.to_string_lossy().ends_with(".pack")),
15848            "{names:?}"
15849        );
15850        assert!(odb.read(&oid).expect("read blob").data.len() > 9000);
15851    }
15852
15853    #[test]
15854    fn indexed_binary_under_autocrlf_rehash_streams() {
15855        use std::io::Write;
15856
15857        let td = tempfile::tempdir().expect("tempdir");
15858        let wt = td.path();
15859        let (_git_dir, odb) =
15860            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 1m\n");
15861        let path = wt.join("big.bin");
15862        {
15863            let mut f = fs::File::create(&path).expect("create");
15864            f.write_all(&[0u8; 9000]).expect("nul prefix");
15865            let chunk = vec![1u8; 512 * 1024];
15866            for _ in 0..3 {
15867                f.write_all(&chunk).expect("chunk");
15868            }
15869        }
15870        let meta = fs::symlink_metadata(&path).expect("stat");
15871        let oid = hash_worktree_file(
15872            &odb,
15873            &path,
15874            &meta,
15875            &crlf::ConversionConfig::from_config(&ConfigSet::new()),
15876            &FileAttrs::default(),
15877            "big.bin",
15878            None,
15879            None,
15880        )
15881        .expect("hash");
15882        let entry = entry_from_metadata(&meta, b"big.bin", oid, MODE_REGULAR);
15883        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15884        conv.autocrlf = crlf::AutoCrlf::True;
15885        let attrs = FileAttrs::default();
15886        let content = read_regular_worktree_blob(
15887            &odb,
15888            &path,
15889            &conv,
15890            &attrs,
15891            "big.bin",
15892            Some(&entry),
15893            None,
15894            1024 * 1024,
15895        )
15896        .expect("read blob");
15897        assert!(
15898            matches!(content, WorktreeBlobContent::Stream { .. }),
15899            "indexed binary re-hash must stream, not buffer"
15900        );
15901        assert_eq!(content.hash(&odb).expect("hash"), entry.oid);
15902    }
15903
15904    #[test]
15905    fn leaves_matching_crlf_worktree_content_alone() {
15906        let td = tempfile::tempdir().expect("tempdir");
15907        let wt = td.path();
15908        let index_mtime = setup_index_mtime(wt);
15909        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tautocrlf = true\n");
15910        let git_form = b"line one\nline two\n";
15911        let worktree_form = b"line one\r\nline two\r\n";
15912        let oid = odb.hash(ObjectKind::Blob, git_form).expect("hash");
15913
15914        let mut index = Index::new();
15915        let entry = staged_entry_with_oid(wt, "f.txt", worktree_form, oid, index_mtime);
15916        index.entries.push(entry.clone());
15917
15918        assert!(entry_is_racy(&entry, Some(index_mtime)));
15919        let smudged = smudge_racily_clean_entries(
15920            &odb,
15921            &git_dir,
15922            &mut index,
15923            wt,
15924            Some(index_mtime),
15925            None,
15926            None,
15927        );
15928        assert!(
15929            !smudged,
15930            "CRLF worktree matching clean index OID must not be smudged"
15931        );
15932        assert_eq!(index.entries[0].size, entry.size);
15933    }
15934
15935    #[test]
15936    fn skips_skip_worktree_and_intent_to_add() {
15937        let td = tempfile::tempdir().expect("tempdir");
15938        let wt = td.path();
15939        let index_mtime = setup_index_mtime(wt);
15940        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15941        let content_a = b"same length!!";
15942        let content_b = b"same length??";
15943
15944        let mut index = Index::new();
15945        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
15946        entry.set_skip_worktree(true);
15947        index.entries.push(entry);
15948        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
15949        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15950        assert!(!smudge_racily_clean_entries(
15951            &odb,
15952            &git_dir,
15953            &mut index,
15954            wt,
15955            Some(index_mtime),
15956            None,
15957            None,
15958        ));
15959        assert_ne!(index.entries[0].size, 0);
15960
15961        let mut index = Index::new();
15962        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
15963        entry.set_intent_to_add(true);
15964        index.entries.push(entry);
15965        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
15966        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15967        assert!(!smudge_racily_clean_entries(
15968            &odb,
15969            &git_dir,
15970            &mut index,
15971            wt,
15972            Some(index_mtime),
15973            None,
15974            None,
15975        ));
15976        assert_ne!(index.entries[0].size, 0);
15977    }
15978
15979    #[test]
15980    fn smudges_nothing_without_prior_index_mtime() {
15981        let td = tempfile::tempdir().expect("tempdir");
15982        let wt = td.path();
15983        let index_mtime = setup_index_mtime(wt);
15984        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15985        let mut index = Index::new();
15986        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
15987        index.entries.push(entry);
15988
15989        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
15990        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15991
15992        let smudged = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, None, None);
15993        assert!(!smudged);
15994        assert_ne!(index.entries[0].size, 0);
15995    }
15996}
15997
15998#[cfg(all(test, unix))]
15999mod read_worktree_bytes_for_mode_tests {
16000    use super::*;
16001    use crate::index::MODE_SYMLINK;
16002    use std::fs;
16003    use std::os::unix::fs::symlink;
16004
16005    #[test]
16006    fn symlink_mode_reads_link_target_not_followed_file() {
16007        let td = tempfile::tempdir().expect("tempdir");
16008        let wt = td.path();
16009        fs::write(wt.join("payload.txt"), b"secret\n").expect("write");
16010        symlink("payload.txt", wt.join("link")).expect("symlink");
16011        let bytes =
16012            read_worktree_bytes_for_mode(&wt.join("link"), MODE_SYMLINK).expect("read link");
16013        assert_eq!(bytes, b"payload.txt");
16014    }
16015
16016    #[test]
16017    fn symlink_mode_reads_plain_file_holding_the_target() {
16018        let td = tempfile::tempdir().expect("tempdir");
16019        let path = td.path().join("link");
16020        fs::write(&path, b"payload.txt").expect("write");
16021        let bytes = read_worktree_bytes_for_mode(&path, MODE_SYMLINK).expect("read plain link");
16022        assert_eq!(bytes, b"payload.txt");
16023    }
16024
16025    #[test]
16026    fn regular_mode_reads_file_contents() {
16027        let td = tempfile::tempdir().expect("tempdir");
16028        let path = td.path().join("f.txt");
16029        fs::write(&path, b"hello\n").expect("write");
16030        let bytes = read_worktree_bytes_for_mode(&path, crate::index::MODE_REGULAR).expect("read");
16031        assert_eq!(bytes, b"hello\n");
16032    }
16033
16034    #[test]
16035    fn annotate_marks_context_line_when_eof_line_is_unchanged() {
16036        let old = "a\nb\nc";
16037        let new = "a\nx\nc";
16038        let raw = super::unified_diff_histogram_hunks_only(old, new, 3, 0, false);
16039        let out = annotate_unified_diff_no_newline_at_eof(&raw, old, new);
16040        assert!(
16041            out.contains(" c\n\\ No newline at end of file"),
16042            "histogram hunk should mark trailing context line:\n{out}"
16043        );
16044    }
16045
16046    #[test]
16047    fn annotate_does_not_treat_removed_dash_lines_as_file_headers() {
16048        use super::unified_diff_with_prefix_and_funcname_and_algorithm;
16049        use similar::Algorithm;
16050
16051        let old = "--old";
16052        let new = "--new";
16053        let raw = unified_diff_with_prefix_and_funcname_and_algorithm(
16054            old,
16055            new,
16056            "a/x",
16057            "b/x",
16058            3,
16059            0,
16060            "",
16061            "",
16062            None,
16063            Algorithm::Myers,
16064            false,
16065            false,
16066            false,
16067            false,
16068        );
16069        assert_eq!(
16070            raw.matches("\\ No newline at end of file").count(),
16071            2,
16072            "expected two markers like git, got:\n{raw}"
16073        );
16074    }
16075}
16076
16077#[cfg(test)]
16078mod annotate_no_newline_tests {
16079    use super::annotate_unified_diff_no_newline_at_eof;
16080
16081    #[test]
16082    fn annotate_marks_both_sides_without_trailing_newline() {
16083        let raw = "@@ -1 +1 @@\n-foo\n+f o o\n";
16084        let out = annotate_unified_diff_no_newline_at_eof(raw, "foo", "f o o");
16085        assert!(out.contains("\\ No newline at end of file"));
16086        assert_eq!(out.matches("\\ No newline at end of file").count(), 2);
16087    }
16088}
16089
16090#[cfg(test)]
16091mod blob_diff_algorithm_smoke {
16092    use super::{
16093        anchored_unified_diff, count_changes_with_algorithm, unified_diff_histogram_hunks_only,
16094        unified_diff_with_prefix_and_funcname_and_algorithm, unified_hunk_body_for_diff_options,
16095    };
16096    use similar::Algorithm;
16097
16098    const OLD: &str = "alpha\nbeta\ngamma\n";
16099    const NEW: &str = "alpha\nBETA\ngamma\ndelta\n";
16100
16101    #[test]
16102    fn seed15_histogram_u0_body_matches_git() {
16103        use crate::diff::options::{DiffAlgorithm, DiffOptions};
16104        use std::collections::hash_map::DefaultHasher;
16105        use std::hash::{Hash, Hasher};
16106        let seed = 15u64;
16107        let mut hasher = DefaultHasher::new();
16108        seed.hash(&mut hasher);
16109        let h = hasher.finish();
16110        let line_count = 4 + (h as usize % 12);
16111        let mut old = String::new();
16112        let mut new = String::new();
16113        for i in 0..line_count {
16114            let token = (h.wrapping_add(i as u64 * 131) % 7) as u8 + b'a';
16115            let ch = token as char;
16116            old.push(ch);
16117            if (h >> (i % 8)) & 1 == 1 {
16118                new.push(((token as u8).wrapping_add(1)) as char);
16119            } else {
16120                new.push(ch);
16121            }
16122            old.push('\n');
16123            new.push('\n');
16124        }
16125        let opts = DiffOptions {
16126            context_lines: 0,
16127            algorithm: DiffAlgorithm::Histogram,
16128            indent_heuristic: false,
16129            ..DiffOptions::default()
16130        };
16131        let body = unified_hunk_body_for_diff_options(&old, &new, &opts);
16132        assert_eq!(
16133            body.matches("@@ ").count(),
16134            2,
16135            "git 2.55 splits u0 histogram hunks at seed 15: {body}"
16136        );
16137    }
16138
16139    #[test]
16140    fn t4071_histogram_changed_flags_git_myers_completes() {
16141        let old: Vec<&str> = "x\nx\nx\nx\n".lines().collect();
16142        let new: Vec<&str> = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n".lines().collect();
16143        let (c1, c2) = super::git_xdiff::histogram_changed_flags(&old, &new);
16144        assert_eq!(c1.len(), 4);
16145        assert_eq!(c2.len(), 11);
16146        let ops = super::changed_flags_to_ops(&c1, &c2, old.len(), new.len());
16147        assert!(!ops.is_empty());
16148    }
16149
16150    #[test]
16151    fn t4071_histogram_compacted_ops_completes() {
16152        use crate::diff::WhitespaceMode;
16153        let old = "x\nx\nx\nx\n";
16154        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
16155        let ops = super::histogram_compacted_line_ops(old, new, false, WhitespaceMode::default());
16156        assert!(!ops.is_empty(), "expected diff ops");
16157    }
16158
16159    #[test]
16160    fn t4071_histogram_u0_body_completes() {
16161        use crate::diff::options::{DiffAlgorithm, DiffOptions};
16162        let old = "x\nx\nx\nx\n";
16163        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
16164        let opts = DiffOptions {
16165            context_lines: 0,
16166            algorithm: DiffAlgorithm::Histogram,
16167            indent_heuristic: false,
16168            ..DiffOptions::default()
16169        };
16170        let body = unified_hunk_body_for_diff_options(old, new, &opts);
16171        assert!(body.contains("@@"), "expected hunk header: {body}");
16172    }
16173
16174    #[test]
16175    fn t4071_minimal_u0_matches_git_hunk_headers() {
16176        use crate::diff::options::{DiffAlgorithm, DiffOptions};
16177        let old = "x\nx\nx\nx\n";
16178        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
16179        let opts = DiffOptions {
16180            context_lines: 0,
16181            algorithm: DiffAlgorithm::Minimal,
16182            indent_heuristic: false,
16183            ..DiffOptions::default()
16184        };
16185        let body = unified_hunk_body_for_diff_options(old, new, &opts);
16186        assert_eq!(
16187            body.matches("@@ ").count(),
16188            2,
16189            "git 2.55+ splits u0 minimal hunks at repeated anchors: {body}"
16190        );
16191    }
16192
16193    #[test]
16194    fn hunks_do_not_slide_onto_final_line_without_newline() {
16195        use crate::diff::options::{DiffAlgorithm, DiffOptions};
16196
16197        let old = "{\n  \"a\": 1\n}\n";
16198        let new = "{\n  \"a\": 2\n}\n{\n  \"b\": 2\n}";
16199        let expected = "@@ -1,3 +1,6 @@\n {\n-  \"a\": 1\n+  \"a\": 2\n }\n+{\n+  \"b\": 2\n+}\n\\ No newline at end of file\n";
16200        for algorithm in [
16201            DiffAlgorithm::Myers,
16202            DiffAlgorithm::Minimal,
16203            DiffAlgorithm::Patience,
16204            DiffAlgorithm::Histogram,
16205        ] {
16206            for indent_heuristic in [true, false] {
16207                let options = DiffOptions {
16208                    algorithm,
16209                    indent_heuristic,
16210                    ..DiffOptions::default()
16211                };
16212                assert_eq!(
16213                    unified_hunk_body_for_diff_options(old, new, &options),
16214                    expected,
16215                    "{algorithm:?}, indent heuristic {indent_heuristic}"
16216                );
16217            }
16218        }
16219    }
16220
16221    #[test]
16222    fn myers_patience_histogram_minimal_emit_unified_hunks() {
16223        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
16224            let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
16225                OLD,
16226                NEW,
16227                "a/old.txt",
16228                "b/new.txt",
16229                3,
16230                0,
16231                "a/",
16232                "b/",
16233                None,
16234                algo,
16235                false,
16236                false,
16237                true,
16238                false,
16239            );
16240            assert!(patch.contains("@@"), "algo {algo:?} should emit hunks");
16241            assert!(patch.contains("-beta") || patch.contains("-BETA"));
16242        }
16243    }
16244
16245    #[test]
16246    fn git_histogram_printer_and_anchored_diff_smoke() {
16247        let hist = unified_diff_histogram_hunks_only(OLD, NEW, 3, 0, false);
16248        assert!(!hist.is_empty());
16249        let anchors = vec!["beta".to_string()];
16250        let anchored = anchored_unified_diff(
16251            OLD,
16252            NEW,
16253            "old",
16254            "new",
16255            3,
16256            &anchors,
16257            Algorithm::Myers,
16258            false,
16259            true,
16260            false,
16261        );
16262        assert!(anchored.contains("@@"));
16263    }
16264
16265    #[test]
16266    fn count_changes_git_histogram_vs_similar() {
16267        let hist = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, true);
16268        let myers = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, false);
16269        assert!(hist.0 + hist.1 > 0);
16270        assert!(myers.0 + myers.1 > 0);
16271    }
16272}
16273
16274#[cfg(test)]
16275mod line_coverage_tests {
16276    use super::*;
16277    use crate::config::ConfigSet;
16278    use crate::index::StatMatchPolicy;
16279    use crate::index::{Index, IndexEntry, MODE_REGULAR};
16280    use crate::objects::ObjectKind;
16281    use crate::parallel::ThreadPolicy;
16282    use crate::write_tree::write_tree_from_index;
16283    use similar::Algorithm;
16284    use tempfile::TempDir;
16285
16286    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
16287        IndexEntry {
16288            ctime_sec: 0,
16289            ctime_nsec: 0,
16290            mtime_sec: 0,
16291            mtime_nsec: 0,
16292            dev: 0,
16293            ino: 0,
16294            mode,
16295            uid: 0,
16296            gid: 0,
16297            size: 0,
16298            oid,
16299            flags: path.len().min(0xfff) as u16,
16300            flags_extended: None,
16301            path: path.as_bytes().to_vec(),
16302            base_index_pos: 0,
16303        }
16304    }
16305
16306    fn write_tree(odb: &Odb, entries: &[(&str, ObjectId)]) -> ObjectId {
16307        let mut body = Vec::new();
16308        for (name, oid) in entries {
16309            body.extend_from_slice(format!("100644 {name}\0").as_bytes());
16310            body.extend_from_slice(oid.as_bytes());
16311        }
16312        odb.write(ObjectKind::Tree, &body).expect("tree")
16313    }
16314
16315    fn pair_of_trees_with_rename() -> (TempDir, Odb, ObjectId, ObjectId) {
16316        let temp = TempDir::new().expect("tempdir");
16317        let odb = Odb::new(temp.path());
16318        let old_blob = odb
16319            .write(ObjectKind::Blob, b"line one\nline two\n")
16320            .expect("old");
16321        let new_blob = odb
16322            .write(ObjectKind::Blob, b"line one\nline TWO\nline three\n")
16323            .expect("new");
16324        let old_tree = write_tree(&odb, &[("old.txt", old_blob)]);
16325        let new_tree = write_tree(&odb, &[("new.txt", new_blob), ("kept.txt", old_blob)]);
16326        (temp, odb, old_tree, new_tree)
16327    }
16328
16329    #[test]
16330    fn detect_copies_reports_source_modes_without_tree_entries() {
16331        let temp = TempDir::new().expect("tempdir");
16332        let odb = Odb::new(temp.path());
16333        let content = odb
16334            .write(ObjectKind::Blob, b"shared content\n")
16335            .expect("blob");
16336        let changed = odb.write(ObjectKind::Blob, b"changed\n").expect("blob");
16337        let old_tree = write_tree(&odb, &[("gone.txt", content), ("mod.txt", content)]);
16338        let new_tree = write_tree(
16339            &odb,
16340            &[
16341                ("copy.txt", content),
16342                ("mod.txt", changed),
16343                ("moved.txt", content),
16344            ],
16345        );
16346        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
16347        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
16348        let pairs: Vec<_> = result
16349            .iter()
16350            .filter(|e| matches!(e.status, DiffStatus::Renamed | DiffStatus::Copied))
16351            .map(|e| (e.old_mode, e.new_mode))
16352            .collect();
16353        assert_eq!(pairs.len(), 2, "{result:?}");
16354        assert!(
16355            pairs
16356                .iter()
16357                .all(|p| *p == (FileMode::REGULAR, FileMode::REGULAR)),
16358            "{pairs:?}"
16359        );
16360    }
16361
16362    #[test]
16363    fn exact_rename_detection_pairs_only_equal_blobs_among_many() -> Result<()> {
16364        let temp = TempDir::new().expect("tempdir");
16365        let odb = Odb::new(temp.path());
16366        const N: usize = 200;
16367        let shared = odb.write(ObjectKind::Blob, b"shared\n").expect("blob");
16368        let mut old_names = Vec::with_capacity(N + 1);
16369        let mut old_oids = Vec::with_capacity(N + 1);
16370        for i in 0..N {
16371            old_names.push(format!("old/{i:03}.txt"));
16372            old_oids.push(
16373                odb.write(ObjectKind::Blob, format!("old {i}\n").as_bytes())
16374                    .expect("blob"),
16375            );
16376        }
16377        old_names.push("shared-old.txt".to_owned());
16378        old_oids.push(shared);
16379        let old_pairs: Vec<(&str, ObjectId)> = old_names
16380            .iter()
16381            .zip(old_oids.iter().copied())
16382            .map(|(name, oid)| (name.as_str(), oid))
16383            .collect();
16384
16385        let mut new_names = Vec::with_capacity(N + 1);
16386        let mut new_oids = Vec::with_capacity(N + 1);
16387        for i in 0..N {
16388            new_names.push(format!("new/{i:03}.txt"));
16389            new_oids.push(
16390                odb.write(ObjectKind::Blob, format!("new {i}\n").as_bytes())
16391                    .expect("blob"),
16392            );
16393        }
16394        new_names.push("shared-new.txt".to_owned());
16395        new_oids.push(shared);
16396        let new_pairs: Vec<(&str, ObjectId)> = new_names
16397            .iter()
16398            .zip(new_oids.iter().copied())
16399            .map(|(name, oid)| (name.as_str(), oid))
16400            .collect();
16401
16402        let old_tree = write_tree(&odb, &old_pairs);
16403        let new_tree = write_tree(&odb, &new_pairs);
16404        let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
16405        assert_eq!(raw.len(), (N + 1) * 2);
16406        let config = ConfigSet::new();
16407        let policy = ThreadPolicy::default();
16408        let renamed = detect_renames_with_policy(
16409            &odb,
16410            None,
16411            raw,
16412            50,
16413            &config,
16414            &policy,
16415            None,
16416            DetectRenamesOptions::default(),
16417        )?;
16418        assert_eq!(
16419            renamed
16420                .entries
16421                .iter()
16422                .filter(|e| e.status == DiffStatus::Renamed)
16423                .count(),
16424            1
16425        );
16426        Ok(())
16427    }
16428
16429    #[test]
16430    fn pathspec_limited_diff_does_not_pair_outside_pathspec() -> Result<()> {
16431        let temp = TempDir::new().expect("tempdir");
16432        let odb = Odb::new(temp.path());
16433        let shared = odb.write(ObjectKind::Blob, b"payload\n").expect("blob");
16434        let mut old_names = Vec::new();
16435        let mut old_oids = Vec::new();
16436        for i in 0..50 {
16437            old_names.push(format!("bulk/old/{i:02}.txt"));
16438            old_oids.push(
16439                odb.write(ObjectKind::Blob, format!("bulk old {i}\n").as_bytes())
16440                    .expect("blob"),
16441            );
16442        }
16443        old_names.push("target.txt".to_owned());
16444        old_oids.push(shared);
16445        let old_pairs: Vec<(&str, ObjectId)> = old_names
16446            .iter()
16447            .zip(old_oids.iter().copied())
16448            .map(|(name, oid)| (name.as_str(), oid))
16449            .collect();
16450
16451        let mut new_names = Vec::new();
16452        let mut new_oids = Vec::new();
16453        for i in 0..50 {
16454            new_names.push(format!("bulk/new/{i:02}.txt"));
16455            new_oids.push(
16456                odb.write(ObjectKind::Blob, format!("bulk new {i}\n").as_bytes())
16457                    .expect("blob"),
16458            );
16459        }
16460        new_names.push("elsewhere.txt".to_owned());
16461        new_oids.push(shared);
16462        let new_pairs: Vec<(&str, ObjectId)> = new_names
16463            .iter()
16464            .zip(new_oids.iter().copied())
16465            .map(|(name, oid)| (name.as_str(), oid))
16466            .collect();
16467
16468        let old_tree = write_tree(&odb, &old_pairs);
16469        let new_tree = write_tree(&odb, &new_pairs);
16470        let config = ConfigSet::new();
16471        let policy = ThreadPolicy::default();
16472        let pathspecs = vec!["target.txt".to_owned()];
16473        let entries = diff_trees_with_renames_for_pathspecs_and_policy(
16474            &odb,
16475            Some(&old_tree),
16476            Some(&new_tree),
16477            "",
16478            &pathspecs,
16479            None,
16480            50,
16481            &config,
16482            &policy,
16483        )?;
16484        assert_eq!(entries.len(), 1);
16485        assert_eq!(entries[0].status, DiffStatus::Deleted);
16486        assert_eq!(entries[0].path(), "target.txt");
16487        Ok(())
16488    }
16489
16490    #[test]
16491    fn detect_copies_keeps_modifications_as_modifications() {
16492        // `git diff -C` reports `b` as modified even though its new content equals the
16493        // old content of the also-modified `a`: only created paths become copies.
16494        let temp = TempDir::new().expect("tempdir");
16495        let odb = Odb::new(temp.path());
16496        let one = odb.write(ObjectKind::Blob, b"1\n").expect("blob");
16497        let two = odb.write(ObjectKind::Blob, b"2\n").expect("blob");
16498        let old_tree = write_tree(&odb, &[("a", one), ("b", two)]);
16499        let new_tree = write_tree(&odb, &[("a", two), ("b", one)]);
16500        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
16501        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
16502        let statuses: Vec<_> = result.iter().map(|e| (e.path(), e.status)).collect();
16503        assert_eq!(
16504            statuses,
16505            [("a", DiffStatus::Modified), ("b", DiffStatus::Modified)]
16506        );
16507    }
16508
16509    #[test]
16510    fn diff_helper_and_format_sweep() {
16511        let cfg = ConfigSet::new();
16512        assert!(resolve_indent_heuristic(&cfg, false, false));
16513        assert!(!resolve_indent_heuristic(&cfg, false, true));
16514
16515        let old = "alpha beta\ngamma\n";
16516        let new = "alpha BETA\ngamma delta\n";
16517        let _ = normalize_ignore_space_change(old);
16518        let _ = normalize_ignore_space_change_line("- alpha  beta ");
16519        let _ = count_git_lines(old.as_bytes());
16520        let _ = diffcore_count_changes(old.as_bytes(), new.as_bytes());
16521        assert!(parse_diff_rename_score_token("50%").is_some());
16522        let _ = rewrite_merge_score(b"a", b"b");
16523        let _ = rewrite_dissimilarity_index_percent(b"abc", b"abd");
16524        assert_eq!(format_mode(0o100644), "100644");
16525
16526        let old_lines = ["a", "b", "c"];
16527        let new_lines = ["a", "b", "d"];
16528        let compact = diff_slice_ops_compacted(&old_lines, &new_lines, Algorithm::Myers, false);
16529        assert!(!compact.is_empty());
16530    }
16531
16532    #[test]
16533    fn tree_diff_rename_copy_postprocess_matrix() -> Result<()> {
16534        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
16535        let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
16536        assert!(!raw.is_empty());
16537        let with_renames = diff_trees_with_renames(&odb, Some(&old_tree), Some(&new_tree), "", 50)?;
16538        assert!(!with_renames.is_empty());
16539        let config = ConfigSet::new();
16540        let policy = ThreadPolicy::default();
16541        let detected = detect_renames_with_policy(
16542            &odb,
16543            None,
16544            raw.clone(),
16545            50,
16546            &config,
16547            &policy,
16548            None,
16549            DetectRenamesOptions::default(),
16550        )?
16551        .entries;
16552        let source_flat = flatten_tree_entries(&odb, &old_tree)?;
16553        let _ = detect_copies_with_policy(
16554            &odb,
16555            None,
16556            detected.clone(),
16557            50,
16558            true,
16559            &source_flat,
16560            &config,
16561            &policy,
16562        )?;
16563        let opts = DiffPostprocessOptions {
16564            break_score: Some(50),
16565            rename_threshold: 50,
16566            copy_threshold: Some(50),
16567            find_copies_harder: true,
16568            materialize_break_worktree_new_oid: false,
16569        };
16570        let processed = postprocess_diff_entries(
16571            &odb,
16572            raw,
16573            Some(&old_tree),
16574            None,
16575            None,
16576            &config,
16577            &policy,
16578            &opts,
16579            None,
16580        )?;
16581        assert!(!processed.is_empty());
16582        let _ = status_apply_rename_copy_detection(
16583            &odb,
16584            with_renames.clone(),
16585            50,
16586            true,
16587            Some(&old_tree),
16588            &config,
16589            &policy,
16590            None,
16591        )?;
16592        let flat = flatten_tree_entries(&odb, &new_tree)?;
16593        assert!(!flat.is_empty());
16594        let _ = diff_trees_show_tree_entries(&odb, Some(&old_tree), Some(&new_tree), "")?;
16595        Ok(())
16596    }
16597
16598    #[test]
16599    fn unified_diff_flag_matrix() {
16600        let old = "fn main() {\n    println!(\"hi\");\n}\n";
16601        let new = "fn main() {\n    println!(\"bye\");\n}\n";
16602        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
16603            for (ws, ws_all, fc, ig) in [(true, false, false, false), (false, true, true, true)] {
16604                let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
16605                    old,
16606                    new,
16607                    "a/main.rs",
16608                    "b/main.rs",
16609                    3,
16610                    0,
16611                    "a/",
16612                    "b/",
16613                    None,
16614                    algo,
16615                    ws,
16616                    ws_all,
16617                    fc,
16618                    ig,
16619                );
16620                assert!(patch.contains("@@"), "algo {algo:?}");
16621            }
16622        }
16623        let hist = unified_diff_histogram_ignore_with_prefix_and_funcname(
16624            old,
16625            new,
16626            "a/x",
16627            "b/x",
16628            3,
16629            0,
16630            "",
16631            "",
16632            None,
16633            false,
16634            false,
16635            false,
16636            |_old, _new| false,
16637        );
16638        assert!(!hist.is_empty());
16639        let raw = format_raw_abbrev(
16640            &DiffEntry {
16641                status: DiffStatus::Modified,
16642                old_path: Some("x".into()),
16643                new_path: Some("x".into()),
16644                old_mode: FileMode::REGULAR,
16645                new_mode: FileMode::REGULAR,
16646                old_oid: empty_blob_oid(),
16647                new_oid: empty_blob_oid(),
16648                score: Some(80),
16649            },
16650            7,
16651        );
16652        assert!(raw.contains('M') || raw.contains('x'));
16653    }
16654
16655    #[test]
16656    fn index_and_commit_diff_paths() -> Result<()> {
16657        let temp = TempDir::new().expect("tempdir");
16658        let odb = Odb::new(temp.path());
16659        let b1 = odb.write(ObjectKind::Blob, b"v1\n").expect("b1");
16660        let b2 = odb.write(ObjectKind::Blob, b"v2\n").expect("b2");
16661        let b3 = odb.write(ObjectKind::Blob, b"v3\n").expect("b3");
16662        let mut index = Index::new();
16663        index.add_or_replace(index_entry("file.txt", MODE_REGULAR, b2));
16664        let head = write_tree_from_index(&odb, &index, "").expect("tree");
16665        index.entries_mut()[0].oid = b1;
16666        let idx_diff = diff_index_to_tree(&odb, &index, Some(&head), false)?;
16667        assert!(!idx_diff.is_empty());
16668        let commit_body = format!(
16669            "tree {head}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
16670        );
16671        let c1 = odb
16672            .write(ObjectKind::Commit, commit_body.as_bytes())
16673            .expect("c1");
16674        let mut index2 = Index::new();
16675        index2.add_or_replace(index_entry("file.txt", MODE_REGULAR, b3));
16676        let head2 = write_tree_from_index(&odb, &index2, "").expect("head2");
16677        let c2_body = format!(
16678            "tree {head2}\nparent {c1}\nauthor T <t@e.com> 2 +0000\ncommitter T <t@e.com> 2 +0000\n\nm2\n"
16679        );
16680        let c2 = odb
16681            .write(ObjectKind::Commit, c2_body.as_bytes())
16682            .expect("c2");
16683        let commit_diff = diff_commits_with_renames(&odb, &c1, &c2, 50)?;
16684        assert!(!commit_diff.is_empty());
16685        let _ = tree_oid_of_commit(&odb, &c2)?;
16686        let mtime = index_mtime_for_diff(&index, Some((1, 0)));
16687        let _ = entry_is_racy(&index.entries()[0], mtime);
16688        let _ = entry_is_racy_with_policy(&index.entries()[0], mtime, StatMatchPolicy::default());
16689        Ok(())
16690    }
16691
16692    #[test]
16693    fn tree_oid_of_commit_reports_invalid_ref_for_non_commits() {
16694        let temp = TempDir::new().expect("tempdir");
16695        let odb = Odb::new(temp.path());
16696        let tree = odb.write(ObjectKind::Tree, &[]).expect("tree");
16697        let body = format!(
16698            "tree {tree}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
16699        );
16700        let commit = odb
16701            .write(ObjectKind::Commit, body.as_bytes())
16702            .expect("commit");
16703        let blob = odb.write(ObjectKind::Blob, b"x\n").expect("blob");
16704
16705        assert_eq!(tree_oid_of_commit(&odb, &commit).expect("tree"), tree);
16706        assert!(matches!(
16707            tree_oid_of_commit(&odb, &blob),
16708            Err(Error::InvalidRef(_))
16709        ));
16710        assert!(matches!(
16711            tree_oid_of_commit(&odb, &tree),
16712            Err(Error::InvalidRef(_))
16713        ));
16714    }
16715
16716    #[test]
16717    fn worktree_index_diff_and_rename_score() -> Result<()> {
16718        let temp = TempDir::new().expect("tempdir");
16719        let wt = temp.path();
16720        let git_dir = wt.join(".git");
16721        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
16722        let objects_dir = git_dir.join("objects");
16723        let odb = Odb::new(&objects_dir);
16724        let content = b"hello world\n";
16725        std::fs::write(wt.join("tracked.txt"), content).expect("write");
16726        let oid = odb.write(ObjectKind::Blob, content).expect("blob");
16727        let mut index = Index::new();
16728        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, oid));
16729        let head = write_tree_from_index(&odb, &index, "").expect("tree");
16730        std::fs::write(wt.join("tracked.txt"), b"hello WORLD\n").expect("mutate");
16731        let mut index_mut = index.clone();
16732        let diff = diff_index_to_worktree(&odb, &mut index_mut, wt, false, false)?;
16733        assert!(!diff.is_empty());
16734        let _ = diff_tree_to_worktree(&odb, Some(&head), wt, &index)?;
16735        assert_eq!(rename_similarity_score(b"a", b"a\n"), 0);
16736        assert_eq!(rename_similarity_score(b"a\n", b"a"), 0);
16737        let score = rename_similarity_score(b"line one\nline two\n", b"line one\nline TWO\n");
16738        assert!(score >= 50);
16739        let _ = detect_renames(&odb, Some(wt), diff, 50);
16740        Ok(())
16741    }
16742
16743    #[test]
16744    fn extended_diff_helpers_sweep() -> Result<()> {
16745        use crate::crlf::{ConversionConfig, FileAttrs};
16746        let temp = TempDir::new().expect("tempdir");
16747        let wt = temp.path();
16748        let git_dir = wt.join(".git");
16749        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
16750        let objects_dir = git_dir.join("objects");
16751        let odb = Odb::new(&objects_dir);
16752        let b_old = odb.write(ObjectKind::Blob, b"alpha\nbeta\n").expect("old");
16753        let b_new = odb
16754            .write(ObjectKind::Blob, b"alpha\nBETA\ngamma\n")
16755            .expect("new");
16756        let old_tree = write_tree(&odb, &[("a.txt", b_old), ("b.txt", b_old)]);
16757        let new_tree = write_tree(
16758            &odb,
16759            &[("a.txt", b_new), ("b.txt", b_new), ("c.txt", b_new)],
16760        );
16761        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
16762        assert!(entries.len() >= 2);
16763
16764        let cfg = ConfigSet::new();
16765        assert!(indent_heuristic_from_config(&cfg));
16766        let _ = unified_diff_histogram_hunks_only("a\nb\n", "a\nB\n", 3, 0, false);
16767        let _ = unified_diff_histogram_with_prefix_and_funcname_indent(
16768            "x\n", "y\n", "a/f", "b/f", 3, 0, "", "", None, false, false,
16769        );
16770        let _ = count_changes_with_algorithm("a\n", "b\n", Algorithm::Myers, false);
16771        let _ = should_break_rewrite_pair(b"short", b"totally different body", 50);
16772        let _ = head_path_states(&odb, Some(&new_tree))?;
16773        std::fs::write(wt.join("tracked.txt"), b"hello\n").expect("wt");
16774        let abs = wt.join("tracked.txt");
16775        let meta = std::fs::metadata(&abs).expect("meta");
16776        let mut index = Index::new();
16777        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, b_old));
16778        let _ = stat_matches_with_policy(&index.entries()[0], &meta, StatMatchPolicy::default());
16779        let _ = worktree_differs_from_index_entry(
16780            &odb,
16781            &git_dir,
16782            wt,
16783            &index.entries()[0],
16784            false,
16785            None,
16786            None,
16787        )?;
16788        let conv = ConversionConfig::from_config(&cfg);
16789        let attrs = FileAttrs::default();
16790        let params = WorktreeAddRefreshParams {
16791            odb: &odb,
16792            ie: &index.entries()[0],
16793            meta: &meta,
16794            abs_path: &abs,
16795            rel_path: "tracked.txt",
16796            conv: &conv,
16797            file_attrs: &attrs,
16798            index_mtime: None,
16799            staged_mode: MODE_REGULAR,
16800            filter_process: None,
16801        };
16802        let _ = classify_worktree_entry_for_add(&params)?;
16803        let _ = hash_worktree_file(
16804            &odb,
16805            &abs,
16806            &meta,
16807            &conv,
16808            &attrs,
16809            "tracked.txt",
16810            Some(&index.entries()[0]),
16811            None,
16812        )?;
16813        let _ = materialize_worktree_blob(
16814            &odb,
16815            &abs,
16816            &meta,
16817            &conv,
16818            &attrs,
16819            "tracked.txt",
16820            Some(&index.entries()[0]),
16821            None,
16822        )?;
16823        let _ = read_worktree_bytes_for_mode(&abs, MODE_REGULAR);
16824        let _ = mode_from_metadata(&meta);
16825        let _ = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, Some(&cfg), None);
16826        let _ = refresh_index_stat_content_verified(
16827            &odb,
16828            &git_dir,
16829            &mut index,
16830            wt,
16831            None,
16832            Some(&cfg),
16833            None,
16834        )?;
16835        let _ = refresh_index_stat_content_verified_with_rules(
16836            &odb,
16837            &git_dir,
16838            &mut index,
16839            wt,
16840            None,
16841            Some(&cfg),
16842            None,
16843            None,
16844        )?;
16845        let _ = path_checkout_skip_blob_write_when_up_to_date_with_config(
16846            &odb,
16847            &git_dir,
16848            wt,
16849            &index,
16850            &index.entries()[0],
16851            &b_old,
16852            MODE_REGULAR,
16853            "tracked.txt",
16854            None,
16855            None,
16856        )?;
16857        let patch = anchored_unified_diff(
16858            "a\nb\n",
16859            "a\nB\n",
16860            "f",
16861            "f",
16862            3,
16863            &[String::from("b")],
16864            Algorithm::Myers,
16865            false,
16866            true,
16867            false,
16868        );
16869        assert!(patch.contains("@@"));
16870        let _ = annotate_unified_diff_no_newline_at_eof("@@\n+a\n\\ No newline\n", "a", "a\n");
16871        Ok(())
16872    }
16873
16874    #[test]
16875    fn unmerged_only_tree_worktree_diff_and_func_heuristics() -> Result<()> {
16876        use similar::DiffOp;
16877        assert!(inserted_block_starts_with_c_like_function_definition(
16878            "static int foo(int bar) {"
16879        ));
16880        assert!(!inserted_block_starts_with_c_like_function_definition(
16881            "printf(hi);"
16882        ));
16883        let ops = vec![
16884            DiffOp::Equal {
16885                old_index: 0,
16886                new_index: 0,
16887                len: 4,
16888            },
16889            DiffOp::Insert {
16890                old_index: 4,
16891                new_index: 4,
16892                new_len: 2,
16893            },
16894        ];
16895        let new_lines = ["a\n", "b\n", "c\n", "d\n", "void added() {\n", "}\n"];
16896        assert!(append_with_whole_function_added(
16897            &ops, 4, 6, &new_lines, None
16898        ));
16899        let _ = hunk_old_change_end_exclusive(&ops);
16900
16901        let temp = TempDir::new().expect("tempdir");
16902        let wt = temp.path();
16903        let git_dir = wt.join(".git");
16904        let objects_dir = git_dir.join("objects");
16905        std::fs::create_dir_all(&objects_dir).expect("objects");
16906        let odb = Odb::new(&objects_dir);
16907        let tree_blob = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
16908        std::fs::write(wt.join("conflict.txt"), b"worktree\n").expect("wt");
16909        let tree = write_tree(&odb, &[("conflict.txt", tree_blob)]);
16910        let mut index = Index::new();
16911        let mut stage1 = index_entry("conflict.txt", MODE_REGULAR, tree_blob);
16912        stage1.flags = (stage1.flags & 0x0FFF) | (1 << 12);
16913        index.entries_mut().push(stage1);
16914        let cfg = ConfigSet::new();
16915        let diff = diff_tree_to_worktree_with_git_dir(
16916            &odb,
16917            Some(&tree),
16918            wt,
16919            &git_dir,
16920            &index,
16921            Some(&cfg),
16922        )?;
16923        assert!(!diff.is_empty());
16924        Ok(())
16925    }
16926
16927    #[test]
16928    fn high_volume_unified_diff_and_postprocess() -> Result<()> {
16929        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
16930        let config = ConfigSet::new();
16931        let policy = ThreadPolicy::default();
16932        for i in 0..600u32 {
16933            let old = format!("seed {i}\nalpha\n");
16934            let new = format!("seed {i}\nalpha\nvoid fn_{i}(int arg) {{\n  return arg;\n}}\n");
16935            let _ = unified_diff_with_prefix_and_funcname_and_algorithm(
16936                &old,
16937                &new,
16938                "a/f.c",
16939                "b/f.c",
16940                3,
16941                0,
16942                "a/",
16943                "b/",
16944                None,
16945                Algorithm::Myers,
16946                i % 2 == 0,
16947                i % 3 == 0,
16948                i % 5 == 0,
16949                i % 7 == 0,
16950            );
16951            let threshold = 30 + (i % 40);
16952            let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
16953            let opts = DiffPostprocessOptions {
16954                rename_threshold: threshold,
16955                copy_threshold: Some(threshold),
16956                find_copies_harder: i % 2 == 0,
16957                break_score: Some(threshold as u64),
16958                materialize_break_worktree_new_oid: i % 4 == 0,
16959            };
16960            let _ = postprocess_diff_entries(
16961                &odb,
16962                raw,
16963                Some(&old_tree),
16964                None,
16965                None,
16966                &config,
16967                &policy,
16968                &opts,
16969                None,
16970            )?;
16971        }
16972        Ok(())
16973    }
16974}
16975
16976#[cfg(test)]
16977mod refresh_checks_tests {
16978    use super::{DistinctFileAttrs, RefreshChecks, RefreshContent};
16979    use crate::crlf::{EolAttr, FileAttrs, TextAttr};
16980    use crate::objects::ObjectId;
16981
16982    fn attrs_with_eol(eol: EolAttr) -> FileAttrs {
16983        FileAttrs {
16984            eol,
16985            ..FileAttrs::default()
16986        }
16987    }
16988
16989    #[test]
16990    fn equal_attributes_share_one_slot() {
16991        let mut attrs = DistinctFileAttrs::default();
16992        assert_eq!(attrs.intern(FileAttrs::default()), 0);
16993        assert_eq!(attrs.intern(attrs_with_eol(EolAttr::Crlf)), 1);
16994        assert_eq!(attrs.intern(FileAttrs::default()), 0);
16995        assert_eq!(attrs.intern(attrs_with_eol(EolAttr::Crlf)), 1);
16996        let binary = FileAttrs {
16997            text: TextAttr::Unset,
16998            ..FileAttrs::default()
16999        };
17000        assert_eq!(attrs.intern(binary.clone()), 2);
17001        assert_eq!(attrs.intern(binary), 2);
17002        assert_eq!(attrs.0.len(), 3);
17003        assert_eq!(attrs.0[1], attrs_with_eol(EolAttr::Crlf));
17004    }
17005
17006    #[test]
17007    fn sets_past_the_search_limit_are_stored_again() {
17008        let mut attrs = DistinctFileAttrs::default();
17009        let first = attrs.intern(FileAttrs::default());
17010        for n in 0..DistinctFileAttrs::SEARCH_LIMIT {
17011            let distinct = FileAttrs {
17012                whitespace: Some(format!("tabwidth={}", n + 1)),
17013                ..FileAttrs::default()
17014            };
17015            assert_eq!(attrs.intern(distinct), n + 1);
17016        }
17017        let again = attrs.intern(FileAttrs::default());
17018        assert_ne!(again, first, "only the most recent sets are searched");
17019        assert_eq!(attrs.0[again], attrs.0[first]);
17020        assert_eq!(attrs.intern(FileAttrs::default()), again);
17021    }
17022
17023    #[test]
17024    fn empty_checks_answer_none_for_every_entry() {
17025        let checks = RefreshChecks::default();
17026        assert!(checks.0.is_empty());
17027        assert!(checks.get(0).is_none());
17028        assert!(checks.get(9_999).is_none());
17029    }
17030
17031    #[test]
17032    fn filled_checks_answer_by_entry_position() {
17033        let oid = ObjectId::from_bytes(&[7u8; 20]).unwrap();
17034        let other = ObjectId::from_bytes(&[8u8; 20]).unwrap();
17035        let checks = RefreshChecks(vec![None, Some(RefreshContent::Hashed(oid)), None]);
17036        assert!(checks.get(0).is_none());
17037        assert!(RefreshContent::matches(checks.get(1), &oid));
17038        assert!(RefreshContent::differs(checks.get(1), &other));
17039        assert!(
17040            checks.get(3).is_none(),
17041            "positions past the end have no check"
17042        );
17043    }
17044}