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};
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 fn indent_heuristic_from_config(config: &ConfigSet) -> bool {
1648    match config.get_bool("diff.indentHeuristic") {
1649        Some(Ok(b)) => b,
1650        Some(Err(_)) | None => true,
1651    }
1652}
1653
1654/// Resolve indent heuristic: `--no-indent-heuristic` and `--indent-heuristic` override config.
1655#[must_use]
1656pub fn resolve_indent_heuristic(
1657    config: &ConfigSet,
1658    cli_indent_heuristic: bool,
1659    cli_no_indent_heuristic: bool,
1660) -> bool {
1661    if cli_no_indent_heuristic {
1662        false
1663    } else if cli_indent_heuristic {
1664        true
1665    } else {
1666        indent_heuristic_from_config(config)
1667    }
1668}
1669
1670// ---------------------------------------------------------------------------
1671// Git-compatible implementation of the xdiff Myers engine,
1672// restricted to what the word-diff path needs (no rename/ignore-whitespace
1673// flags). Git's word diff runs the *default* xdiff Myers over the per-word
1674// token streams; matching Git's exact change-record selection requires
1675// reproducing its preprocessing (record classification + `xdl_cleanup_records`
1676// + `xdl_trim_ends`) and its divide-and-conquer split, because the histogram
1677// engine (and `similar`) pick different — but equally minimal — alignments for inputs
1678// with many repeated tokens (e.g. the `{`/`}`/`,` in the `bibtex` driver).
1679mod git_xdiff {
1680    use std::cell::RefCell;
1681
1682    const XDL_MAX_COST_MIN: i64 = 256;
1683    const XDL_HEUR_MIN_COST: i64 = 256;
1684    const XDL_SNAKE_CNT: i64 = 20;
1685    const XDL_K_HEUR: i64 = 4;
1686    const XDL_LINE_MAX: i64 = i64::MAX;
1687    const XDL_KPDIS_RUN: i64 = 4;
1688    const XDL_MAX_EQLIMIT: i64 = 1024;
1689    const XDL_SIMSCAN_WINDOW: i64 = 100;
1690
1691    // record-classification actions (xprepare.c)
1692    const DISCARD: u8 = 0;
1693    const KEEP: u8 = 1;
1694    const INVESTIGATE: u8 = 2;
1695
1696    /// Classical integer square root approximation using shifts (`xdl_bogosqrt`).
1697    fn bogosqrt(mut n: i64) -> i64 {
1698        let mut i: i64 = 1;
1699        while n > 0 {
1700            i <<= 1;
1701            n >>= 2;
1702        }
1703        i
1704    }
1705
1706    struct XdAlgoEnv {
1707        mxcost: i64,
1708        snake_cnt: i64,
1709        heur_min: i64,
1710    }
1711
1712    struct XdpSplit {
1713        i1: i64,
1714        i2: i64,
1715        min_lo: bool,
1716        min_hi: bool,
1717    }
1718
1719    /// A single side of the diff: token ids plus the `changed` flags and the
1720    /// `reference_index` mapping from effective record -> original record.
1721    struct XdFile<'a> {
1722        #[allow(dead_code)]
1723        ids: &'a [u32],
1724        changed: Vec<u8>,
1725        reference_index: Vec<usize>,
1726        /// Interned token id per effective record (parallel to `reference_index`).
1727        effective_ids: Vec<u32>,
1728        dstart: i64,
1729        dend: i64,
1730        nreff: i64,
1731    }
1732
1733    #[inline]
1734    fn effective_id(xdf: &XdFile<'_>, idx: i64) -> u32 {
1735        xdf.effective_ids[idx as usize]
1736    }
1737
1738    struct XdiffScratch {
1739        count1: Vec<u32>,
1740        count2: Vec<u32>,
1741        action1: Vec<u8>,
1742        action2: Vec<u8>,
1743        kvd: Vec<i64>,
1744    }
1745
1746    impl XdiffScratch {
1747        fn prepare_counts(&mut self, nclasses: usize) {
1748            if self.count1.len() < nclasses {
1749                self.count1.resize(nclasses, 0);
1750                self.count2.resize(nclasses, 0);
1751            }
1752            self.count1[..nclasses].fill(0);
1753            self.count2[..nclasses].fill(0);
1754        }
1755
1756        fn prepare_actions(&mut self, nrec1: usize, nrec2: usize) {
1757            let a1 = nrec1 + 1;
1758            let a2 = nrec2 + 1;
1759            if self.action1.len() < a1 {
1760                self.action1.resize(a1, 0);
1761            } else {
1762                self.action1[..a1].fill(0);
1763            }
1764            if self.action2.len() < a2 {
1765                self.action2.resize(a2, 0);
1766            } else {
1767                self.action2[..a2].fill(0);
1768            }
1769        }
1770
1771        fn prepare_kvd(&mut self, kvd_len: usize) {
1772            // The search reads only diagonals it has written, so the contents never need
1773            // clearing. A fresh zeroed allocation leaves the pages a small diff never reaches
1774            // untouched, where growing in place would write every one of them.
1775            if self.kvd.len() < kvd_len {
1776                self.kvd = vec![0; kvd_len];
1777            }
1778        }
1779    }
1780
1781    thread_local! { // hygiene: per-thread xdiff Myers scratch (class counts, K-vectors) reused across files
1782        static XDIFF_SCRATCH: RefCell<XdiffScratch> = const {
1783            RefCell::new(XdiffScratch {
1784                count1: Vec::new(),
1785                count2: Vec::new(),
1786                action1: Vec::new(),
1787                action2: Vec::new(),
1788                kvd: Vec::new(),
1789            })
1790        };
1791    }
1792
1793    fn bool_changed(changed: Vec<u8>) -> Vec<bool> {
1794        changed.into_iter().map(|b| b != 0).collect()
1795    }
1796
1797    /// Faithful port of `xdl_split` (the middle-snake finder). `kvdf`/`kvdb` are
1798    /// the forward/backward k-vectors, offset so index 0 maps to diagonal 0.
1799    #[allow(clippy::too_many_arguments)]
1800    fn xdl_split(
1801        xdf1: &XdFile<'_>,
1802        off1: i64,
1803        lim1: i64,
1804        xdf2: &XdFile<'_>,
1805        off2: i64,
1806        lim2: i64,
1807        kvdf: &mut [i64],
1808        kvdb: &mut [i64],
1809        koff: i64,
1810        need_min: bool,
1811        spl: &mut XdpSplit,
1812        xenv: &XdAlgoEnv,
1813    ) -> i64 {
1814        let dmin = off1 - lim2;
1815        let dmax = lim1 - off2;
1816        let fmid = off1 - off2;
1817        let bmid = lim1 - lim2;
1818        let odd = ((fmid - bmid) & 1) != 0;
1819        let mut fmin = fmid;
1820        let mut fmax = fmid;
1821        let mut bmin = bmid;
1822        let mut bmax = bmid;
1823
1824        let kf = |k: i64| -> usize { (k + koff) as usize };
1825
1826        kvdf[kf(fmid)] = off1;
1827        kvdb[kf(bmid)] = lim1;
1828
1829        let mut ec: i64 = 1;
1830        loop {
1831            let mut got_snake = false;
1832
1833            if fmin > dmin {
1834                fmin -= 1;
1835                kvdf[kf(fmin - 1)] = -1;
1836            } else {
1837                fmin += 1;
1838            }
1839            if fmax < dmax {
1840                fmax += 1;
1841                kvdf[kf(fmax + 1)] = -1;
1842            } else {
1843                fmax -= 1;
1844            }
1845
1846            let mut d = fmax;
1847            while d >= fmin {
1848                let mut i1 = if kvdf[kf(d - 1)] >= kvdf[kf(d + 1)] {
1849                    kvdf[kf(d - 1)] + 1
1850                } else {
1851                    kvdf[kf(d + 1)]
1852                };
1853                let prev1 = i1;
1854                let mut i2 = i1 - d;
1855                while i1 < lim1 && i2 < lim2 && effective_id(xdf1, i1) == effective_id(xdf2, i2) {
1856                    i1 += 1;
1857                    i2 += 1;
1858                }
1859                if i1 - prev1 > xenv.snake_cnt {
1860                    got_snake = true;
1861                }
1862                kvdf[kf(d)] = i1;
1863                if odd && bmin <= d && d <= bmax && kvdb[kf(d)] <= i1 {
1864                    spl.i1 = i1;
1865                    spl.i2 = i2;
1866                    spl.min_lo = true;
1867                    spl.min_hi = true;
1868                    return ec;
1869                }
1870                d -= 2;
1871            }
1872
1873            if bmin > dmin {
1874                bmin -= 1;
1875                kvdb[kf(bmin - 1)] = XDL_LINE_MAX;
1876            } else {
1877                bmin += 1;
1878            }
1879            if bmax < dmax {
1880                bmax += 1;
1881                kvdb[kf(bmax + 1)] = XDL_LINE_MAX;
1882            } else {
1883                bmax -= 1;
1884            }
1885
1886            let mut d = bmax;
1887            while d >= bmin {
1888                let mut i1 = if kvdb[kf(d - 1)] < kvdb[kf(d + 1)] {
1889                    kvdb[kf(d - 1)]
1890                } else {
1891                    kvdb[kf(d + 1)] - 1
1892                };
1893                let prev1 = i1;
1894                let mut i2 = i1 - d;
1895                while i1 > off1
1896                    && i2 > off2
1897                    && effective_id(xdf1, i1 - 1) == effective_id(xdf2, i2 - 1)
1898                {
1899                    i1 -= 1;
1900                    i2 -= 1;
1901                }
1902                if prev1 - i1 > xenv.snake_cnt {
1903                    got_snake = true;
1904                }
1905                kvdb[kf(d)] = i1;
1906                if !odd && fmin <= d && d <= fmax && i1 <= kvdf[kf(d)] {
1907                    spl.i1 = i1;
1908                    spl.i2 = i2;
1909                    spl.min_lo = true;
1910                    spl.min_hi = true;
1911                    return ec;
1912                }
1913                d -= 2;
1914            }
1915
1916            if need_min {
1917                ec += 1;
1918                continue;
1919            }
1920
1921            if got_snake && ec > xenv.heur_min {
1922                let mut best = 0i64;
1923                let mut d = fmax;
1924                while d >= fmin {
1925                    let dd = if d > fmid { d - fmid } else { fmid - d };
1926                    let i1 = kvdf[kf(d)];
1927                    let i2 = i1 - d;
1928                    let v = (i1 - off1) + (i2 - off2) - dd;
1929                    if v > XDL_K_HEUR * ec
1930                        && v > best
1931                        && off1 + xenv.snake_cnt <= i1
1932                        && i1 < lim1
1933                        && off2 + xenv.snake_cnt <= i2
1934                        && i2 < lim2
1935                    {
1936                        let mut k = 1i64;
1937                        while effective_id(xdf1, i1 - k) == effective_id(xdf2, i2 - k) {
1938                            if k == xenv.snake_cnt {
1939                                best = v;
1940                                spl.i1 = i1;
1941                                spl.i2 = i2;
1942                                break;
1943                            }
1944                            k += 1;
1945                        }
1946                    }
1947                    d -= 2;
1948                }
1949                if best > 0 {
1950                    spl.min_lo = true;
1951                    spl.min_hi = false;
1952                    return ec;
1953                }
1954
1955                let mut best = 0i64;
1956                let mut d = bmax;
1957                while d >= bmin {
1958                    let dd = if d > bmid { d - bmid } else { bmid - d };
1959                    let i1 = kvdb[kf(d)];
1960                    let i2 = i1 - d;
1961                    let v = (lim1 - i1) + (lim2 - i2) - dd;
1962                    if v > XDL_K_HEUR * ec
1963                        && v > best
1964                        && off1 < i1
1965                        && i1 <= lim1 - xenv.snake_cnt
1966                        && off2 < i2
1967                        && i2 <= lim2 - xenv.snake_cnt
1968                    {
1969                        let mut k = 0i64;
1970                        while effective_id(xdf1, i1 + k) == effective_id(xdf2, i2 + k) {
1971                            if k == xenv.snake_cnt - 1 {
1972                                best = v;
1973                                spl.i1 = i1;
1974                                spl.i2 = i2;
1975                                break;
1976                            }
1977                            k += 1;
1978                        }
1979                    }
1980                    d -= 2;
1981                }
1982                if best > 0 {
1983                    spl.min_lo = false;
1984                    spl.min_hi = true;
1985                    return ec;
1986                }
1987            }
1988
1989            if ec >= xenv.mxcost {
1990                let mut fbest = -1i64;
1991                let mut fbest1 = -1i64;
1992                let mut d = fmax;
1993                while d >= fmin {
1994                    let mut i1 = kvdf[kf(d)].min(lim1);
1995                    let mut i2 = i1 - d;
1996                    if lim2 < i2 {
1997                        i1 = lim2 + d;
1998                        i2 = lim2;
1999                    }
2000                    if fbest < i1 + i2 {
2001                        fbest = i1 + i2;
2002                        fbest1 = i1;
2003                    }
2004                    d -= 2;
2005                }
2006
2007                let mut bbest = XDL_LINE_MAX;
2008                let mut bbest1 = XDL_LINE_MAX;
2009                let mut d = bmax;
2010                while d >= bmin {
2011                    let mut i1 = off1.max(kvdb[kf(d)]);
2012                    let mut i2 = i1 - d;
2013                    if i2 < off2 {
2014                        i1 = off2 + d;
2015                        i2 = off2;
2016                    }
2017                    if i1 + i2 < bbest {
2018                        bbest = i1 + i2;
2019                        bbest1 = i1;
2020                    }
2021                    d -= 2;
2022                }
2023
2024                if (lim1 + lim2) - bbest < fbest - (off1 + off2) {
2025                    spl.i1 = fbest1;
2026                    spl.i2 = fbest - fbest1;
2027                    spl.min_lo = true;
2028                    spl.min_hi = false;
2029                } else {
2030                    spl.i1 = bbest1;
2031                    spl.i2 = bbest - bbest1;
2032                    spl.min_lo = false;
2033                    spl.min_hi = true;
2034                }
2035                return ec;
2036            }
2037
2038            ec += 1;
2039        }
2040    }
2041
2042    /// Faithful port of `xdl_recs_cmp` (divide & conquer). Marks `changed` flags.
2043    #[allow(clippy::too_many_arguments)]
2044    fn xdl_recs_cmp(
2045        xdf1: &mut XdFile<'_>,
2046        mut off1: i64,
2047        mut lim1: i64,
2048        xdf2: &mut XdFile<'_>,
2049        mut off2: i64,
2050        mut lim2: i64,
2051        kvdf: &mut [i64],
2052        kvdb: &mut [i64],
2053        koff: i64,
2054        need_min: bool,
2055        xenv: &XdAlgoEnv,
2056    ) {
2057        while off1 < lim1 && off2 < lim2 && effective_id(xdf1, off1) == effective_id(xdf2, off2) {
2058            off1 += 1;
2059            off2 += 1;
2060        }
2061        while off1 < lim1
2062            && off2 < lim2
2063            && effective_id(xdf1, lim1 - 1) == effective_id(xdf2, lim2 - 1)
2064        {
2065            lim1 -= 1;
2066            lim2 -= 1;
2067        }
2068
2069        if off1 == lim1 {
2070            while off2 < lim2 {
2071                let r = xdf2.reference_index[off2 as usize];
2072                xdf2.changed[r] = 1;
2073                off2 += 1;
2074            }
2075        } else if off2 == lim2 {
2076            while off1 < lim1 {
2077                let r = xdf1.reference_index[off1 as usize];
2078                xdf1.changed[r] = 1;
2079                off1 += 1;
2080            }
2081        } else {
2082            let mut spl = XdpSplit {
2083                i1: 0,
2084                i2: 0,
2085                min_lo: false,
2086                min_hi: false,
2087            };
2088            xdl_split(
2089                xdf1, off1, lim1, xdf2, off2, lim2, kvdf, kvdb, koff, need_min, &mut spl, xenv,
2090            );
2091            xdl_recs_cmp(
2092                xdf1, off1, spl.i1, xdf2, off2, spl.i2, kvdf, kvdb, koff, spl.min_lo, xenv,
2093            );
2094            xdl_recs_cmp(
2095                xdf1, spl.i1, lim1, xdf2, spl.i2, lim2, kvdf, kvdb, koff, spl.min_hi, xenv,
2096            );
2097        }
2098    }
2099
2100    /// `xdl_clean_mmatch`: decide whether a multimatch record should be discarded.
2101    fn clean_mmatch(action: &[u8], i: i64, mut s: i64, mut e: i64) -> bool {
2102        if i - s > XDL_SIMSCAN_WINDOW {
2103            s = i - XDL_SIMSCAN_WINDOW;
2104        }
2105        if e - i > XDL_SIMSCAN_WINDOW {
2106            e = i + XDL_SIMSCAN_WINDOW;
2107        }
2108
2109        let mut rdis0 = 0i64;
2110        let mut rpdis0 = 1i64;
2111        let mut r = 1i64;
2112        while i - r >= s {
2113            match action[(i - r) as usize] {
2114                DISCARD => rdis0 += 1,
2115                INVESTIGATE => rpdis0 += 1,
2116                _ => break, // KEEP
2117            }
2118            r += 1;
2119        }
2120        if rdis0 == 0 {
2121            return false;
2122        }
2123        let mut rdis1 = 0i64;
2124        let mut rpdis1 = 1i64;
2125        let mut r = 1i64;
2126        while i + r <= e {
2127            match action[(i + r) as usize] {
2128                DISCARD => rdis1 += 1,
2129                INVESTIGATE => rpdis1 += 1,
2130                _ => break, // KEEP
2131            }
2132            r += 1;
2133        }
2134        if rdis1 == 0 {
2135            return false;
2136        }
2137        rdis1 += rdis0;
2138        rpdis1 += rpdis0;
2139        rpdis1 * XDL_KPDIS_RUN < (rpdis1 + rdis1)
2140    }
2141
2142    /// Build the `changed` flags for both token streams using Git's xdiff engine.
2143    /// When `need_min` is true, Git's minimal diff (`--minimal`) is used.
2144    /// `ids1`/`ids2` are dense interned token ids from one [`crate::line_interner::LineInterner`]
2145    /// (equal id <=> equal token); per-id tables are sized by the largest id.
2146    /// Returns `(changed1, changed2)`, one bool per original token.
2147    pub fn changed_flags(ids1: &[u32], ids2: &[u32], need_min: bool) -> (Vec<bool>, Vec<bool>) {
2148        changed_flags_with_shared(ids1, ids2, &[], 0, need_min)
2149    }
2150
2151    /// Like [`changed_flags`] for slices cut from two larger files whose other records are
2152    /// identical on both sides.
2153    ///
2154    /// Git classifies each record by how often it occurs in the whole other file, so the
2155    /// records left out still count: `shared_counts[id]` is how many of them have token `id`
2156    /// (ids past its end have none), and `shared_records` is how many there are on each side.
2157    pub fn changed_flags_with_shared(
2158        ids1: &[u32],
2159        ids2: &[u32],
2160        shared_counts: &[u32],
2161        shared_records: usize,
2162        need_min: bool,
2163    ) -> (Vec<bool>, Vec<bool>) {
2164        XDIFF_SCRATCH.with(|cell| {
2165            changed_flags_with_shared_inner(
2166                cell,
2167                ids1,
2168                ids2,
2169                shared_counts,
2170                shared_records,
2171                need_min,
2172            )
2173        })
2174    }
2175
2176    fn changed_flags_with_shared_inner(
2177        scratch: &RefCell<XdiffScratch>,
2178        ids1: &[u32],
2179        ids2: &[u32],
2180        shared_counts: &[u32],
2181        shared_records: usize,
2182        need_min: bool,
2183    ) -> (Vec<bool>, Vec<bool>) {
2184        let nrec1 = ids1.len();
2185        let nrec2 = ids2.len();
2186
2187        // Per-token occurrence counts on each side (class len1/len2), indexed by the dense ids.
2188        let nclasses = ids1
2189            .iter()
2190            .chain(ids2)
2191            .max()
2192            .map_or(0, |&max| max as usize + 1);
2193        let mut work = scratch.borrow_mut();
2194        work.prepare_counts(nclasses);
2195        for &id in ids1 {
2196            work.count1[id as usize] += 1;
2197        }
2198        for &id in ids2 {
2199            work.count2[id as usize] += 1;
2200        }
2201        for (id, &shared) in shared_counts.iter().enumerate().take(nclasses) {
2202            work.count1[id] += shared;
2203            work.count2[id] += shared;
2204        }
2205
2206        let mut xdf1 = XdFile {
2207            ids: ids1,
2208            changed: vec![0u8; nrec1],
2209            reference_index: Vec::new(),
2210            effective_ids: Vec::new(),
2211            dstart: 0,
2212            dend: nrec1 as i64 - 1,
2213            nreff: 0,
2214        };
2215        let mut xdf2 = XdFile {
2216            ids: ids2,
2217            changed: vec![0u8; nrec2],
2218            reference_index: Vec::new(),
2219            effective_ids: Vec::new(),
2220            dstart: 0,
2221            dend: nrec2 as i64 - 1,
2222            nreff: 0,
2223        };
2224
2225        // xdl_trim_ends: trim leading/trailing matching records.
2226        let lim = nrec1.min(nrec2) as i64;
2227        let mut i = 0i64;
2228        while i < lim && ids1[i as usize] == ids2[i as usize] {
2229            i += 1;
2230        }
2231        xdf1.dstart = i;
2232        xdf2.dstart = i;
2233        let mut j = 0i64;
2234        let rem = lim - i;
2235        while j < rem && ids1[nrec1 - 1 - j as usize] == ids2[nrec2 - 1 - j as usize] {
2236            j += 1;
2237        }
2238        xdf1.dend = nrec1 as i64 - j - 1;
2239        xdf2.dend = nrec2 as i64 - j - 1;
2240
2241        // xdl_cleanup_records: classify and reduce to effective records.
2242        work.prepare_actions(nrec1, nrec2);
2243        for xdf in [&mut xdf1, &mut xdf2] {
2244            let span = usize::try_from(xdf.dend - xdf.dstart + 1).unwrap_or(0);
2245            xdf.reference_index.reserve_exact(span);
2246            xdf.effective_ids.reserve_exact(span);
2247        }
2248
2249        let mlim1 = if need_min {
2250            i64::MAX
2251        } else {
2252            let mut mlim = bogosqrt((nrec1 + shared_records) as i64);
2253            if mlim > XDL_MAX_EQLIMIT {
2254                mlim = XDL_MAX_EQLIMIT;
2255            }
2256            mlim
2257        };
2258        let mut idx = xdf1.dstart;
2259        while idx <= xdf1.dend {
2260            let id = ids1[idx as usize];
2261            let nm = i64::from(work.count2[id as usize]);
2262            work.action1[idx as usize] = if nm == 0 {
2263                DISCARD
2264            } else if nm >= mlim1 {
2265                INVESTIGATE
2266            } else {
2267                KEEP
2268            };
2269            idx += 1;
2270        }
2271
2272        let mlim2 = if need_min {
2273            i64::MAX
2274        } else {
2275            let mut mlim = bogosqrt((nrec2 + shared_records) as i64);
2276            if mlim > XDL_MAX_EQLIMIT {
2277                mlim = XDL_MAX_EQLIMIT;
2278            }
2279            mlim
2280        };
2281        let mut idx = xdf2.dstart;
2282        while idx <= xdf2.dend {
2283            let id = ids2[idx as usize];
2284            let nm = i64::from(work.count1[id as usize]);
2285            work.action2[idx as usize] = if nm == 0 {
2286                DISCARD
2287            } else if nm >= mlim2 {
2288                INVESTIGATE
2289            } else {
2290                KEEP
2291            };
2292            idx += 1;
2293        }
2294
2295        let mut idx = xdf1.dstart;
2296        while idx <= xdf1.dend {
2297            let id = ids1[idx as usize];
2298            let a = work.action1[idx as usize];
2299            if a == KEEP
2300                || (a == INVESTIGATE
2301                    && !clean_mmatch(&work.action1[..nrec1 + 1], idx, xdf1.dstart, xdf1.dend))
2302            {
2303                xdf1.reference_index.push(idx as usize);
2304                xdf1.effective_ids.push(id);
2305            } else {
2306                xdf1.changed[idx as usize] = 1;
2307            }
2308            idx += 1;
2309        }
2310        xdf1.nreff = xdf1.reference_index.len() as i64;
2311
2312        let mut idx = xdf2.dstart;
2313        while idx <= xdf2.dend {
2314            let id = ids2[idx as usize];
2315            let a = work.action2[idx as usize];
2316            if a == KEEP
2317                || (a == INVESTIGATE
2318                    && !clean_mmatch(&work.action2[..nrec2 + 1], idx, xdf2.dstart, xdf2.dend))
2319            {
2320                xdf2.reference_index.push(idx as usize);
2321                xdf2.effective_ids.push(id);
2322            } else {
2323                xdf2.changed[idx as usize] = 1;
2324            }
2325            idx += 1;
2326        }
2327        xdf2.nreff = xdf2.reference_index.len() as i64;
2328
2329        // Allocate K vectors (xdl_do_diff). koff lets us use negative diagonals.
2330        let ndiags = xdf1.nreff + xdf2.nreff + 3;
2331        let kvd_len = (2 * ndiags + 2) as usize;
2332        work.prepare_kvd(kvd_len);
2333        let kvd = &mut work.kvd[..kvd_len];
2334        // kvdf base offset = nreff2 + 1; kvdb base offset = ndiags + nreff2 + 1.
2335        let koff = xdf2.nreff + 1;
2336        let (kvdf_slice, kvdb_slice) = kvd.split_at_mut(ndiags as usize);
2337        // Both slices are indexed as [k + koff]; their length covers the diagonal range.
2338
2339        let xenv = XdAlgoEnv {
2340            mxcost: bogosqrt(ndiags).max(XDL_MAX_COST_MIN),
2341            snake_cnt: XDL_SNAKE_CNT,
2342            heur_min: XDL_HEUR_MIN_COST,
2343        };
2344
2345        let nreff1 = xdf1.nreff;
2346        let nreff2 = xdf2.nreff;
2347        xdl_recs_cmp(
2348            &mut xdf1, 0, nreff1, &mut xdf2, 0, nreff2, kvdf_slice, kvdb_slice, koff, need_min,
2349            &xenv,
2350        );
2351
2352        (bool_changed(xdf1.changed), bool_changed(xdf2.changed))
2353    }
2354
2355    /// Patience diff: match unique lines, then recurse on gaps with xdiff Myers.
2356    pub fn patience_changed_flags(lines1: &[&str], lines2: &[&str]) -> (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 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]
2690pub fn diff_slice_ops_compacted(
2691    old_lines: &[&str],
2692    new_lines: &[&str],
2693    algorithm: similar::Algorithm,
2694    indent_heuristic: bool,
2695) -> Vec<similar::DiffOp> {
2696    diff_indent_heuristic::diff_slice_ops_compacted(
2697        old_lines,
2698        new_lines,
2699        algorithm,
2700        indent_heuristic,
2701    )
2702}
2703
2704/// Compacted line-diff ops from Git's xdiff Myers engine over precomputed line ids for each side
2705/// (default blame path; faster than `similar` on large files).
2706#[must_use]
2707///
2708/// `shared_counts` and `shared_records` describe identical records of both files left out of
2709/// the slices, as for [`git_xdiff::changed_flags_with_shared`]; pass `&[]` and `0` for whole files.
2710#[allow(clippy::too_many_arguments)]
2711pub(crate) fn line_diff_ops_git_xdiff_compacted_ids(
2712    old_ids: &[u32],
2713    new_ids: &[u32],
2714    shared_counts: &[u32],
2715    shared_records: usize,
2716    old_lines: &[&str],
2717    new_lines: &[&str],
2718    indent_heuristic: bool,
2719    ws: super::WhitespaceMode,
2720) -> Vec<similar::DiffOp> {
2721    let (changed1, changed2) = git_xdiff::changed_flags_with_shared(
2722        old_ids,
2723        new_ids,
2724        shared_counts,
2725        shared_records,
2726        false,
2727    );
2728    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2729    diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
2730        &ops,
2731        old_lines,
2732        new_lines,
2733        indent_heuristic,
2734        ws,
2735    )
2736}
2737
2738/// Whether each new-side line is marked changed by Git's xdiff engine (after change compaction).
2739#[cfg(test)]
2740#[must_use]
2741pub(crate) fn xdiff_new_line_changed_flags(
2742    old_lines: &[&str],
2743    new_lines: &[&str],
2744    indent_heuristic: bool,
2745) -> Vec<bool> {
2746    let mut interner =
2747        crate::line_interner::LineInterner::with_capacity(old_lines.len() + new_lines.len());
2748    let ids1 = interner.intern_all(old_lines);
2749    let ids2 = interner.intern_all(new_lines);
2750    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
2751    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2752    let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
2753        &ops,
2754        old_lines,
2755        new_lines,
2756        indent_heuristic,
2757    );
2758    let mut flags = vec![false; new_lines.len()];
2759    for op in compacted {
2760        match op.tag() {
2761            similar::DiffTag::Insert | similar::DiffTag::Replace => {
2762                for idx in op.new_range() {
2763                    if idx < flags.len() {
2764                        flags[idx] = true;
2765                    }
2766                }
2767            }
2768            similar::DiffTag::Equal | similar::DiffTag::Delete => {}
2769        }
2770    }
2771    flags
2772}
2773
2774/// The kind of change between two sides of a diff.
2775#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2776#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2777#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
2778pub enum DiffStatus {
2779    /// File was added.
2780    Added,
2781    /// File was deleted.
2782    Deleted,
2783    /// File was modified (content or mode change).
2784    Modified,
2785    /// File was renamed (with optional content change).
2786    Renamed,
2787    /// File was copied.
2788    Copied,
2789    /// File type changed (e.g. regular → symlink).
2790    TypeChanged,
2791    /// Unmerged (conflict).
2792    Unmerged,
2793}
2794
2795impl DiffStatus {
2796    /// Single-character status letter used in raw diff output.
2797    #[must_use]
2798    pub fn letter(&self) -> char {
2799        match self {
2800            Self::Added => 'A',
2801            Self::Deleted => 'D',
2802            Self::Modified => 'M',
2803            Self::Renamed => 'R',
2804            Self::Copied => 'C',
2805            Self::TypeChanged => 'T',
2806            Self::Unmerged => 'U',
2807        }
2808    }
2809
2810    /// Status for a path present on both sides with differing content or mode.
2811    ///
2812    /// Returns [`DiffStatus::TypeChanged`] when the object type bits of the two
2813    /// modes differ (regular file, symlink, gitlink) and [`DiffStatus::Modified`]
2814    /// otherwise, so a permission-only change stays `Modified`. Every diff mode
2815    /// (tree, index, work tree) shares this rule.
2816    #[must_use]
2817    pub(crate) fn for_content_change(old_mode: u32, new_mode: u32) -> Self {
2818        if old_mode & 0o170000 != new_mode & 0o170000 {
2819            Self::TypeChanged
2820        } else {
2821            Self::Modified
2822        }
2823    }
2824}
2825
2826/// A single diff entry representing one changed path.
2827#[derive(Debug, Clone, PartialEq, Eq)]
2828#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2829pub struct DiffEntry {
2830    /// The status of this change.
2831    pub status: DiffStatus,
2832    /// Path in the "old" side (None for Added).
2833    pub old_path: Option<String>,
2834    /// Path in the "new" side (None for Deleted).
2835    pub new_path: Option<String>,
2836    /// Old file mode ([`FileMode::ABSENT`] for Added).
2837    pub old_mode: FileMode,
2838    /// New file mode ([`FileMode::ABSENT`] for Deleted).
2839    pub new_mode: FileMode,
2840    /// Old object ID (zero OID for Added).
2841    pub old_oid: ObjectId,
2842    /// New object ID (zero OID for Deleted, or for Modified index↔worktree / tree↔worktree
2843    /// entries whose content lives only on disk — raw output uses the null OID on that side).
2844    pub new_oid: ObjectId,
2845    /// Similarity score (0–100) for renames/copies.
2846    pub score: Option<u32>,
2847}
2848
2849impl DiffEntry {
2850    /// The primary path for display (new_path for adds, old_path for deletes).
2851    #[must_use]
2852    pub fn path(&self) -> &str {
2853        self.new_path
2854            .as_deref()
2855            .or(self.old_path.as_deref())
2856            .unwrap_or("")
2857    }
2858
2859    /// Return a human-oriented path display for this entry.
2860    ///
2861    /// For renames and copies this returns `old -> new`; for all other entry
2862    /// kinds this returns the primary path.
2863    #[must_use]
2864    pub fn display_path(&self) -> String {
2865        match self.status {
2866            DiffStatus::Renamed | DiffStatus::Copied => {
2867                let old = self.old_path.as_deref().unwrap_or("");
2868                let new = self.new_path.as_deref().unwrap_or("");
2869                if old.is_empty() || new.is_empty() {
2870                    self.path().to_owned()
2871                } else {
2872                    format!("{old} -> {new}")
2873                }
2874            }
2875            _ => self.path().to_owned(),
2876        }
2877    }
2878}
2879
2880/// Return the zero ObjectId.
2881#[must_use]
2882pub fn zero_oid() -> ObjectId {
2883    ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
2884        // This should never fail since we pass exactly 20 bytes
2885        panic!("internal error: failed to create zero OID");
2886    })
2887}
2888
2889/// Return the ObjectId for the empty blob object.
2890#[must_use]
2891pub fn empty_blob_oid() -> ObjectId {
2892    ObjectId::from_hex("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap_or_else(|_| {
2893        // This should never fail since the object ID literal is valid.
2894        panic!("internal error: failed to create empty blob OID");
2895    })
2896}
2897
2898// ── Tree-to-tree diff ───────────────────────────────────────────────
2899
2900/// Compare two trees and return the list of changed entries.
2901///
2902/// # Parameters
2903///
2904/// - `odb` — object database to read tree objects from.
2905/// - `old_tree_oid` — OID of the old tree (or `None` for comparison against empty).
2906/// - `new_tree_oid` — OID of the new tree (or `None` for comparison against empty).
2907/// - `prefix` — path prefix for nested tree recursion (empty string for root).
2908///
2909/// # Errors
2910///
2911/// Returns errors from object database reads.
2912pub fn diff_trees(
2913    odb: &Odb,
2914    old_tree_oid: Option<&ObjectId>,
2915    new_tree_oid: Option<&ObjectId>,
2916    prefix: &str,
2917) -> Result<Vec<DiffEntry>> {
2918    crate::trace::trace_span!("grit.diff.tree");
2919    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, false, None)
2920}
2921
2922/// Like [`diff_trees`], but when `index` matches `old_tree_oid` deleted subtrees are expanded
2923/// from sorted index entries instead of reading every tree object from the ODB.
2924pub(crate) fn diff_trees_with_index(
2925    odb: &Odb,
2926    index: &Index,
2927    old_tree_oid: Option<&ObjectId>,
2928    new_tree_oid: Option<&ObjectId>,
2929    prefix: &str,
2930    trust_index_for_old_tree: bool,
2931) -> Result<Vec<DiffEntry>> {
2932    let hint = (trust_index_for_old_tree
2933        && old_tree_oid.is_some()
2934        && index.entries_sorted
2935        && !index.entries.iter().any(|e| e.stage() != 0))
2936    .then_some(DiffTreesWalkHint {
2937        index: Some(index),
2938        old_tree_matches: true,
2939        ..DiffTreesWalkHint::default()
2940    });
2941    diff_trees_opts(
2942        odb,
2943        old_tree_oid,
2944        new_tree_oid,
2945        prefix,
2946        false,
2947        hint.as_ref(),
2948    )
2949}
2950
2951/// Like [`diff_trees`], but subtrees at directories `index` holds as sparse-directory rows are
2952/// not descended into, so no entry lies inside one. Paths of those directories themselves (a
2953/// blob replaced by a tree, say) are still reported.
2954pub(crate) fn diff_trees_outside_sparse_directories(
2955    odb: &Odb,
2956    index: &Index,
2957    old_tree_oid: Option<&ObjectId>,
2958    new_tree_oid: Option<&ObjectId>,
2959    sparse: Option<&SparseCheckout>,
2960    index_sparse: bool,
2961) -> Result<Vec<DiffEntry>> {
2962    crate::trace::trace_span!("grit.diff.tree");
2963    let hint = index
2964        .has_sparse_directory_placeholders()
2965        .then_some(DiffTreesWalkHint {
2966            index: Some(index),
2967            skip_sparse_directories: true,
2968            sparse,
2969            index_sparse,
2970            ..DiffTreesWalkHint::default()
2971        });
2972    diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, hint.as_ref())
2973}
2974
2975/// Tree diff for hard reset and similar checkouts: index-backed old tree when clean, and one
2976/// entry per wholly added or removed subtree instead of every file path.
2977pub(crate) fn diff_trees_coalesced_for_checkout(
2978    odb: &Odb,
2979    index: &Index,
2980    old_tree_oid: Option<&ObjectId>,
2981    new_tree_oid: Option<&ObjectId>,
2982    sparse: Option<&SparseCheckout>,
2983    index_sparse: bool,
2984) -> Result<Vec<DiffEntry>> {
2985    crate::trace::trace_span!("grit.diff.tree");
2986    let trust_index_for_old_tree = old_tree_oid.is_some()
2987        && index.entries_sorted
2988        && !index.entries.iter().any(|e| e.stage() != 0);
2989    let hint = DiffTreesWalkHint {
2990        index: trust_index_for_old_tree.then_some(index),
2991        old_tree_matches: trust_index_for_old_tree,
2992        coalesce_subtrees_for_checkout: true,
2993        skip_sparse_directories: index.has_sparse_directory_placeholders(),
2994        sparse,
2995        index_sparse,
2996        ..DiffTreesWalkHint::default()
2997    };
2998    diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, Some(&hint))
2999}
3000
3001/// Whether directory `dir` may contain paths selected by `pathspecs` during a tree walk.
3002fn tree_walk_directory_relevant(
3003    dir: &str,
3004    pathspecs: &[String],
3005    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3006) -> bool {
3007    if pathspecs.is_empty() {
3008        return true;
3009    }
3010    if let Some(matcher) = pathspec_matcher {
3011        return matcher.matches_entry(dir, 0o040000)
3012            || crate::pathspec::pathspec_wants_descent_for_rev_tree(dir, pathspecs);
3013    }
3014    crate::pathspec::pathspec_entry_is_relevant(dir, true, pathspecs)
3015}
3016
3017fn diff_entry_matches_pathspecs(
3018    entry: &DiffEntry,
3019    pathspecs: &[String],
3020    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3021) -> bool {
3022    let path = entry.path();
3023    if path.is_empty() {
3024        return false;
3025    }
3026    if let Some(matcher) = pathspec_matcher {
3027        return matcher.matches_change(path, entry.old_path.as_deref());
3028    }
3029    crate::pathspec::matches_pathspec_list(path, pathspecs)
3030}
3031
3032/// Like [`diff_trees`], but only paths `pathspecs` select (Git's pathspec-limited tree diff).
3033///
3034/// Directories no pathspec can reach are not read. An empty list selects every path.
3035/// When `pathspec_matcher` is set, matching uses the repository attribute stack (required for
3036/// `:(attr:...)` magic).
3037pub(crate) fn diff_trees_for_pathspecs(
3038    odb: &Odb,
3039    old_tree_oid: Option<&ObjectId>,
3040    new_tree_oid: Option<&ObjectId>,
3041    pathspecs: &[String],
3042    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3043) -> Result<Vec<DiffEntry>> {
3044    if pathspecs.is_empty() {
3045        return diff_trees(odb, old_tree_oid, new_tree_oid, "");
3046    }
3047    crate::trace::trace_span!("grit.diff.tree");
3048    let hint = DiffTreesWalkHint {
3049        pathspecs,
3050        pathspec_matcher,
3051        ..DiffTreesWalkHint::default()
3052    };
3053    let mut entries = diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, Some(&hint))?;
3054    entries.retain(|entry| diff_entry_matches_pathspecs(entry, pathspecs, pathspec_matcher));
3055    Ok(entries)
3056}
3057
3058/// Directories a tree diff may skip, and an index that can stand in for the old tree.
3059#[derive(Clone, Copy, Default)]
3060struct DiffTreesWalkHint<'a> {
3061    index: Option<&'a Index>,
3062    /// `index` holds exactly the old tree's entries, so deleted subtrees may be listed from it.
3063    old_tree_matches: bool,
3064    /// Emit one tree entry per removed or added subtree instead of every descendant path.
3065    coalesce_subtrees_for_checkout: bool,
3066    /// Do not descend into directories `index` holds as sparse-directory rows.
3067    skip_sparse_directories: bool,
3068    sparse: Option<&'a SparseCheckout>,
3069    index_sparse: bool,
3070    /// When non-empty, do not descend into directories these pathspecs cannot match inside.
3071    pathspecs: &'a [String],
3072    pathspec_matcher: Option<&'a crate::pathspec::PathspecMatcher<'a>>,
3073}
3074
3075impl DiffTreesWalkHint<'_> {
3076    /// Cone sparse checkout keeps `dir` (no trailing slash) as one opaque tree row.
3077    fn sparse_excludes_tree(&self, dir: &str) -> bool {
3078        if !self.index_sparse {
3079            return false;
3080        }
3081        let Some(sparse) = self.sparse else {
3082            return false;
3083        };
3084        let mut path = crate::repo_relpath::storage_bytes(dir).into_owned();
3085        if !path.ends_with(b"/") {
3086            path.push(b'/');
3087        }
3088        sparse.excludes_directory(&path)
3089    }
3090
3091    /// Whether the walk must not enter directory `dir` (stored form, no trailing slash).
3092    fn skips(&self, dir: &str) -> bool {
3093        if dir.is_empty() {
3094            return false;
3095        }
3096        if !self.pathspecs.is_empty()
3097            && !tree_walk_directory_relevant(dir, self.pathspecs, self.pathspec_matcher)
3098        {
3099            return true;
3100        }
3101        let Some(index) = self.index.filter(|_| self.skip_sparse_directories) else {
3102            return false;
3103        };
3104        let mut row = crate::repo_relpath::storage_bytes(dir).into_owned();
3105        row.push(b'/');
3106        index
3107            .find_entry_pos(&row, 0)
3108            .is_some_and(|pos| index.entries[pos].is_sparse_directory_placeholder())
3109    }
3110}
3111
3112/// Count path-level differences between two trees without building a [`DiffEntry`] list.
3113///
3114/// Same semantics as [`diff_trees`], but avoids allocating diff records (issue #1064 commit hot path).
3115///
3116/// # Errors
3117///
3118/// Returns errors from object database reads.
3119pub fn diff_tree_path_change_count(
3120    odb: &Odb,
3121    old_tree_oid: Option<&ObjectId>,
3122    new_tree_oid: Option<&ObjectId>,
3123    prefix: &str,
3124) -> Result<usize> {
3125    if old_tree_oid == new_tree_oid {
3126        return Ok(0);
3127    }
3128    let old_entries = match old_tree_oid {
3129        Some(oid) => read_tree(odb, oid)?,
3130        None => Vec::new(),
3131    };
3132    let new_entries = match new_tree_oid {
3133        Some(oid) => read_tree(odb, oid)?,
3134        None => Vec::new(),
3135    };
3136    let mut count = 0usize;
3137    diff_tree_entries_count(odb, &old_entries, &new_entries, prefix, false, &mut count)?;
3138    Ok(count)
3139}
3140
3141/// Diff two trees and pair deleted/added paths as renames when content is similar enough.
3142///
3143/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
3144/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
3145///
3146/// # Errors
3147///
3148/// Propagates errors from [`diff_trees`].
3149pub fn diff_trees_with_renames(
3150    odb: &Odb,
3151    old_tree_oid: Option<&ObjectId>,
3152    new_tree_oid: Option<&ObjectId>,
3153    prefix: &str,
3154    rename_threshold: u32,
3155) -> Result<Vec<DiffEntry>> {
3156    let config = ConfigSet::new();
3157    let policy = ThreadPolicy::default();
3158    diff_trees_with_renames_and_policy(
3159        odb,
3160        old_tree_oid,
3161        new_tree_oid,
3162        prefix,
3163        rename_threshold,
3164        &config,
3165        &policy,
3166    )
3167}
3168
3169/// Like [`diff_trees_with_renames`], but honors `index.threads` / embedder thread policy.
3170///
3171/// # Errors
3172///
3173/// Propagates errors from [`diff_trees`].
3174pub fn diff_trees_with_renames_and_policy(
3175    odb: &Odb,
3176    old_tree_oid: Option<&ObjectId>,
3177    new_tree_oid: Option<&ObjectId>,
3178    prefix: &str,
3179    rename_threshold: u32,
3180    config: &ConfigSet,
3181    thread_policy: &ThreadPolicy,
3182) -> Result<Vec<DiffEntry>> {
3183    Ok(diff_trees_with_renames_and_policy_result(
3184        odb,
3185        old_tree_oid,
3186        new_tree_oid,
3187        prefix,
3188        rename_threshold,
3189        config,
3190        thread_policy,
3191    )?
3192    .entries)
3193}
3194
3195/// Like [`diff_trees_with_renames_and_policy`], but also returns rename-limit skip metadata.
3196///
3197/// # Errors
3198///
3199/// Propagates errors from [`diff_trees`] and rename detection.
3200pub fn diff_trees_with_renames_and_policy_result(
3201    odb: &Odb,
3202    old_tree_oid: Option<&ObjectId>,
3203    new_tree_oid: Option<&ObjectId>,
3204    prefix: &str,
3205    rename_threshold: u32,
3206    config: &ConfigSet,
3207    thread_policy: &ThreadPolicy,
3208) -> Result<DiffEntriesResult> {
3209    diff_trees_with_renames_for_pathspecs_and_policy_result(
3210        odb,
3211        old_tree_oid,
3212        new_tree_oid,
3213        prefix,
3214        &[],
3215        None,
3216        rename_threshold,
3217        config,
3218        thread_policy,
3219    )
3220}
3221
3222/// Like [`diff_trees_with_renames_and_policy`], but limits the tree walk to `pathspecs` before
3223/// rename detection (Git's pathspec-limited tree diff).
3224///
3225/// An empty `pathspecs` list diffs every path under `prefix`.
3226///
3227/// # Errors
3228///
3229/// Propagates errors from [`diff_trees`] and [`detect_renames_with_policy`].
3230#[allow(clippy::too_many_arguments)]
3231pub(crate) fn diff_trees_with_renames_for_pathspecs_and_policy(
3232    odb: &Odb,
3233    old_tree_oid: Option<&ObjectId>,
3234    new_tree_oid: Option<&ObjectId>,
3235    prefix: &str,
3236    pathspecs: &[String],
3237    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3238    rename_threshold: u32,
3239    config: &ConfigSet,
3240    thread_policy: &ThreadPolicy,
3241) -> Result<Vec<DiffEntry>> {
3242    Ok(diff_trees_with_renames_for_pathspecs_and_policy_result(
3243        odb,
3244        old_tree_oid,
3245        new_tree_oid,
3246        prefix,
3247        pathspecs,
3248        pathspec_matcher,
3249        rename_threshold,
3250        config,
3251        thread_policy,
3252    )?
3253    .entries)
3254}
3255
3256/// Pathspec-limited tree diff with rename detection and rename-limit skip metadata.
3257#[allow(clippy::too_many_arguments)]
3258pub(crate) fn diff_trees_with_renames_for_pathspecs_and_policy_result(
3259    odb: &Odb,
3260    old_tree_oid: Option<&ObjectId>,
3261    new_tree_oid: Option<&ObjectId>,
3262    prefix: &str,
3263    pathspecs: &[String],
3264    pathspec_matcher: Option<&crate::pathspec::PathspecMatcher<'_>>,
3265    rename_threshold: u32,
3266    config: &ConfigSet,
3267    thread_policy: &ThreadPolicy,
3268) -> Result<DiffEntriesResult> {
3269    let entries = if pathspecs.is_empty() {
3270        diff_trees(odb, old_tree_oid, new_tree_oid, prefix)?
3271    } else {
3272        diff_trees_for_pathspecs(odb, old_tree_oid, new_tree_oid, pathspecs, pathspec_matcher)?
3273    };
3274    if rename_threshold <= 100 {
3275        let out = detect_renames_with_policy(
3276            odb,
3277            None,
3278            entries,
3279            rename_threshold,
3280            config,
3281            thread_policy,
3282            None,
3283            DetectRenamesOptions::default(),
3284        )?;
3285        return Ok(DiffEntriesResult {
3286            entries: out.entries,
3287            inexact_rename_skipped: out.inexact_skipped,
3288        });
3289    }
3290    Ok(DiffEntriesResult {
3291        entries,
3292        inexact_rename_skipped: None,
3293    })
3294}
3295
3296/// Return the tree OID stored in a commit object.
3297///
3298/// # Errors
3299///
3300/// Returns [`Error::InvalidRef`] when `commit_oid` is not a commit, and object read/parse
3301/// errors when it is missing or malformed.
3302pub fn tree_oid_of_commit(odb: &Odb, commit_oid: &ObjectId) -> Result<ObjectId> {
3303    odb.commit_tree(commit_oid).map_err(|err| match err {
3304        Error::UnexpectedObjectKind { .. } => {
3305            Error::InvalidRef(format!("invalid ref: {commit_oid} is not a commit"))
3306        }
3307        other => other,
3308    })
3309}
3310
3311/// Tree OID to use as one side of a tree diff when the id names a commit or a tree.
3312///
3313/// # Errors
3314///
3315/// Returns [`Error::InvalidRef`] when `oid` is not a commit or tree, and object read/parse
3316/// errors when it is missing or malformed.
3317pub(crate) fn tree_oid_for_diff_side(odb: &Odb, oid: &ObjectId) -> Result<ObjectId> {
3318    use crate::objects::{CommitRef, ObjectKind};
3319    let object = odb.read(oid)?;
3320    match object.kind {
3321        ObjectKind::Commit => Ok(CommitRef::parse(&object.data)?.tree()),
3322        ObjectKind::Tree => Ok(*oid),
3323        actual => Err(Error::InvalidRef(format!(
3324            "invalid ref: {oid} is not a commit or tree ({actual:?})"
3325        ))),
3326    }
3327}
3328
3329/// Like [`tree_oid_of_commit`], but uses the repository commit-graph cache when available.
3330///
3331/// # Errors
3332///
3333/// Returns object read/parse errors when `commit_oid` is missing or not a commit.
3334pub fn tree_oid_of_commit_in_repo(
3335    repo: &crate::repo::Repository,
3336    commit_oid: &ObjectId,
3337) -> Result<ObjectId> {
3338    if let Some(chain) = repo.odb().commit_graph_chain() {
3339        if let Some(tree) = chain.graph_commit_tree(commit_oid) {
3340            return Ok(tree);
3341        }
3342    }
3343    tree_oid_of_commit(repo.odb(), commit_oid)
3344}
3345
3346/// Diff the trees recorded at two commits (`base` is the old side, `tip` the new side).
3347///
3348/// # Errors
3349///
3350/// Propagates object read errors and tree diff failures.
3351pub fn diff_commits(odb: &Odb, base: &ObjectId, tip: &ObjectId) -> Result<Vec<DiffEntry>> {
3352    let base_tree = tree_oid_of_commit(odb, base)?;
3353    let tip_tree = tree_oid_of_commit(odb, tip)?;
3354    diff_trees(odb, Some(&base_tree), Some(&tip_tree), "")
3355}
3356
3357/// Diff the trees at two commits and pair deleted/added paths as renames when similar enough.
3358///
3359/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
3360/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
3361///
3362/// # Errors
3363///
3364/// Propagates object read errors and tree diff failures.
3365pub fn diff_commits_with_renames(
3366    odb: &Odb,
3367    base: &ObjectId,
3368    tip: &ObjectId,
3369    rename_threshold: u32,
3370) -> Result<Vec<DiffEntry>> {
3371    let config = ConfigSet::new();
3372    let policy = ThreadPolicy::default();
3373    diff_commits_with_renames_and_policy(odb, base, tip, rename_threshold, &config, &policy)
3374}
3375
3376/// Like [`diff_commits_with_renames`], but honors `index.threads` / embedder thread policy.
3377///
3378/// # Errors
3379///
3380/// Propagates object read errors and tree diff failures.
3381pub fn diff_commits_with_renames_and_policy(
3382    odb: &Odb,
3383    base: &ObjectId,
3384    tip: &ObjectId,
3385    rename_threshold: u32,
3386    config: &ConfigSet,
3387    thread_policy: &ThreadPolicy,
3388) -> Result<Vec<DiffEntry>> {
3389    let base_tree = tree_oid_of_commit(odb, base)?;
3390    let tip_tree = tree_oid_of_commit(odb, tip)?;
3391    diff_trees_with_renames_and_policy(
3392        odb,
3393        Some(&base_tree),
3394        Some(&tip_tree),
3395        "",
3396        rename_threshold,
3397        config,
3398        thread_policy,
3399    )
3400}
3401
3402/// Like [`diff_trees`], but emit entries for tree objects themselves in addition
3403/// to their recursive contents (Git's `diff-tree -t` behavior).
3404///
3405/// # Errors
3406///
3407/// Returns errors from object database reads.
3408pub fn diff_trees_show_tree_entries(
3409    odb: &Odb,
3410    old_tree_oid: Option<&ObjectId>,
3411    new_tree_oid: Option<&ObjectId>,
3412    prefix: &str,
3413) -> Result<Vec<DiffEntry>> {
3414    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, true, None)
3415}
3416
3417fn diff_subtrees_in_place(
3418    odb: &Odb,
3419    old_tree_oid: Option<&ObjectId>,
3420    new_tree_oid: Option<&ObjectId>,
3421    prefix: &str,
3422    show_trees: bool,
3423    index_hint: Option<&DiffTreesWalkHint<'_>>,
3424    result: &mut Vec<DiffEntry>,
3425) -> Result<()> {
3426    if index_hint.is_some_and(|hint| hint.skips(prefix)) {
3427        return Ok(());
3428    }
3429    if !show_trees {
3430        if let (Some(a), Some(b)) = (old_tree_oid, new_tree_oid) {
3431            if a == b {
3432                return Ok(());
3433            }
3434        }
3435    }
3436    // The ODB already budgets cached trees. Keep only active recursion frames
3437    // here so completing siblings can release trees evicted from that cache.
3438    let old_tree = old_tree_oid
3439        .map(|oid| odb.cached_tree_for_walk(oid))
3440        .transpose()?;
3441    let new_tree = new_tree_oid
3442        .map(|oid| odb.cached_tree_for_walk(oid))
3443        .transpose()?;
3444    let oid_len = odb.hash_algo().len();
3445    diff_tree_entries_opts(
3446        odb,
3447        WalkEntries::new(old_tree.as_ref(), oid_len),
3448        WalkEntries::new(new_tree.as_ref(), oid_len),
3449        prefix,
3450        show_trees,
3451        index_hint,
3452        result,
3453    )
3454}
3455
3456/// Entries of a tree from [`Odb::cached_tree_for_walk`] in stored order, borrowing names
3457/// from it.
3458enum WalkEntries<'a> {
3459    Parsed(std::slice::Iter<'a, TreeEntry>),
3460    Raw(TreeRefIter<'a>),
3461}
3462
3463impl<'a> WalkEntries<'a> {
3464    /// Iterate `tree`, or nothing when the side is absent.
3465    fn new(tree: Option<&'a SessionTree>, oid_len: usize) -> Self {
3466        match tree {
3467            Some(SessionTree::Parsed(entries)) => Self::Parsed(entries.iter()),
3468            Some(SessionTree::Raw(data)) => Self::Raw(TreeRefIter::with_oid_len(data, oid_len)),
3469            None => Self::Parsed([].iter()),
3470        }
3471    }
3472
3473    fn next_entry(&mut self) -> Result<Option<TreeEntryRef<'a>>> {
3474        match self {
3475            Self::Parsed(entries) => Ok(entries.next().map(|entry| TreeEntryRef {
3476                mode: entry.mode,
3477                name: &entry.name,
3478                oid: entry.oid,
3479            })),
3480            Self::Raw(entries) => entries.next().transpose(),
3481        }
3482    }
3483}
3484
3485fn diff_trees_opts(
3486    odb: &Odb,
3487    old_tree_oid: Option<&ObjectId>,
3488    new_tree_oid: Option<&ObjectId>,
3489    prefix: &str,
3490    show_trees: bool,
3491    index_hint: Option<&DiffTreesWalkHint<'_>>,
3492) -> Result<Vec<DiffEntry>> {
3493    let mut result = Vec::new();
3494    diff_subtrees_in_place(
3495        odb,
3496        old_tree_oid,
3497        new_tree_oid,
3498        prefix,
3499        show_trees,
3500        index_hint,
3501        &mut result,
3502    )?;
3503    Ok(result)
3504}
3505
3506#[cfg(test)]
3507use std::cell::RefCell;
3508#[cfg(test)]
3509use std::rc::Rc;
3510#[cfg(test)]
3511use std::sync::atomic::{AtomicUsize, Ordering};
3512
3513#[cfg(test)]
3514thread_local! {
3515    static DIFF_TREE_READ_HOOK: RefCell<Option<Rc<AtomicUsize>>> = const { RefCell::new(None) };
3516}
3517
3518/// Install a per-thread tree read counter for unit tests (cleared when `hook` is `None`).
3519#[cfg(test)]
3520pub(crate) fn set_diff_tree_read_hook_for_tests(hook: Option<Rc<AtomicUsize>>) {
3521    DIFF_TREE_READ_HOOK.with(|cell| *cell.borrow_mut() = hook);
3522}
3523
3524/// Read and parse a tree object from the ODB (no session cache).
3525fn read_tree_from_odb(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
3526    #[cfg(test)]
3527    if let Some(counter) = DIFF_TREE_READ_HOOK.with(|cell| cell.borrow().clone()) {
3528        counter.fetch_add(1, Ordering::Relaxed);
3529    }
3530    Ok((*odb.cached_tree_entries(oid)?).clone())
3531}
3532
3533/// Read and parse a tree object from the ODB.
3534fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
3535    read_tree_from_odb(odb, oid)
3536}
3537
3538fn diff_tree_entries_count(
3539    odb: &Odb,
3540    old: &[TreeEntry],
3541    new: &[TreeEntry],
3542    prefix: &str,
3543    show_trees: bool,
3544    count: &mut usize,
3545) -> Result<()> {
3546    let mut oi = 0;
3547    let mut ni = 0;
3548
3549    while oi < old.len() || ni < new.len() {
3550        match (old.get(oi), new.get(ni)) {
3551            (Some(o), Some(n)) => {
3552                let cmp = crate::objects::tree_entry_cmp(
3553                    &o.name,
3554                    is_tree_mode(o.mode),
3555                    &n.name,
3556                    is_tree_mode(n.mode),
3557                );
3558                match cmp {
3559                    std::cmp::Ordering::Less => {
3560                        emit_deleted_count(odb, o, prefix, show_trees, count)?;
3561                        oi += 1;
3562                    }
3563                    std::cmp::Ordering::Greater => {
3564                        emit_added_count(odb, n, prefix, show_trees, count)?;
3565                        ni += 1;
3566                    }
3567                    std::cmp::Ordering::Equal => {
3568                        if o.oid != n.oid
3569                            || crate::index::canon_mode(o.mode) != crate::index::canon_mode(n.mode)
3570                        {
3571                            let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
3572                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3573                                if show_trees {
3574                                    *count += 1;
3575                                }
3576                                *count += diff_tree_path_change_count(
3577                                    odb,
3578                                    Some(&o.oid),
3579                                    Some(&n.oid),
3580                                    &path,
3581                                )?;
3582                            } else if is_tree_mode(o.mode) != is_tree_mode(n.mode) {
3583                                emit_deleted_count(odb, o, prefix, show_trees, count)?;
3584                                emit_added_count(odb, n, prefix, show_trees, count)?;
3585                            } else {
3586                                *count += 1;
3587                            }
3588                        }
3589                        oi += 1;
3590                        ni += 1;
3591                    }
3592                }
3593            }
3594            (Some(o), None) => {
3595                emit_deleted_count(odb, o, prefix, show_trees, count)?;
3596                oi += 1;
3597            }
3598            (None, Some(n)) => {
3599                emit_added_count(odb, n, prefix, show_trees, count)?;
3600                ni += 1;
3601            }
3602            (None, None) => break,
3603        }
3604    }
3605
3606    Ok(())
3607}
3608
3609fn emit_deleted_count(
3610    odb: &Odb,
3611    entry: &TreeEntry,
3612    prefix: &str,
3613    show_trees: bool,
3614    count: &mut usize,
3615) -> Result<()> {
3616    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3617    if is_tree_mode(entry.mode) {
3618        if show_trees {
3619            *count += 1;
3620        }
3621        *count += diff_tree_path_change_count(odb, Some(&entry.oid), None, &path)?;
3622    } else {
3623        *count += 1;
3624    }
3625    Ok(())
3626}
3627
3628fn emit_added_count(
3629    odb: &Odb,
3630    entry: &TreeEntry,
3631    prefix: &str,
3632    show_trees: bool,
3633    count: &mut usize,
3634) -> Result<()> {
3635    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3636    if is_tree_mode(entry.mode) {
3637        if show_trees {
3638            *count += 1;
3639        }
3640        *count += diff_tree_path_change_count(odb, None, Some(&entry.oid), &path)?;
3641    } else {
3642        *count += 1;
3643    }
3644    Ok(())
3645}
3646
3647/// Compare two sorted streams of tree entries, recursing into subtrees.
3648fn diff_tree_entries_opts(
3649    odb: &Odb,
3650    mut old: WalkEntries<'_>,
3651    mut new: WalkEntries<'_>,
3652    prefix: &str,
3653    show_trees: bool,
3654    index_hint: Option<&DiffTreesWalkHint<'_>>,
3655    result: &mut Vec<DiffEntry>,
3656) -> Result<()> {
3657    let mut old_entry = old.next_entry()?;
3658    let mut new_entry = new.next_entry()?;
3659
3660    loop {
3661        match (&old_entry, &new_entry) {
3662            (Some(o), Some(n)) => {
3663                let cmp = crate::objects::tree_entry_cmp(
3664                    o.name,
3665                    is_tree_mode(o.mode),
3666                    n.name,
3667                    is_tree_mode(n.mode),
3668                );
3669                match cmp {
3670                    std::cmp::Ordering::Less => {
3671                        // Old entry not in new → deleted
3672                        emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3673                        old_entry = old.next_entry()?;
3674                    }
3675                    std::cmp::Ordering::Greater => {
3676                        // New entry not in old → added
3677                        emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3678                        new_entry = new.next_entry()?;
3679                    }
3680                    std::cmp::Ordering::Equal => {
3681                        // Both present — check for changes
3682                        if o.oid != n.oid
3683                            || crate::index::canon_mode(o.mode) != crate::index::canon_mode(n.mode)
3684                        {
3685                            let path = crate::repo_relpath::format_path_stored(prefix, o.name);
3686                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3687                                // Both are trees
3688                                if show_trees {
3689                                    result.push(DiffEntry {
3690                                        status: DiffStatus::Modified,
3691                                        old_path: Some(path.clone()),
3692                                        new_path: Some(path.clone()),
3693                                        old_mode: FileMode::new(crate::index::canon_mode(o.mode)),
3694                                        new_mode: FileMode::new(crate::index::canon_mode(n.mode)),
3695                                        old_oid: o.oid,
3696                                        new_oid: n.oid,
3697                                        score: None,
3698                                    });
3699                                }
3700                                diff_subtrees_in_place(
3701                                    odb,
3702                                    Some(&o.oid),
3703                                    Some(&n.oid),
3704                                    &path,
3705                                    show_trees,
3706                                    index_hint,
3707                                    result,
3708                                )?;
3709                            } else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
3710                                // Tree → blob: delete tree contents, add blob
3711                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3712                                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3713                            } else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3714                                // Blob → tree: delete blob, add tree contents
3715                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3716                                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3717                            } else {
3718                                result.push(DiffEntry {
3719                                    status: DiffStatus::for_content_change(o.mode, n.mode),
3720                                    old_path: Some(path.clone()),
3721                                    new_path: Some(path),
3722                                    old_mode: FileMode::new(crate::index::canon_mode(o.mode)),
3723                                    new_mode: FileMode::new(crate::index::canon_mode(n.mode)),
3724                                    old_oid: o.oid,
3725                                    new_oid: n.oid,
3726                                    score: None,
3727                                });
3728                            }
3729                        }
3730                        old_entry = old.next_entry()?;
3731                        new_entry = new.next_entry()?;
3732                    }
3733                }
3734            }
3735            (Some(o), None) => {
3736                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3737                old_entry = old.next_entry()?;
3738            }
3739            (None, Some(n)) => {
3740                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3741                new_entry = new.next_entry()?;
3742            }
3743            (None, None) => break,
3744        }
3745    }
3746
3747    Ok(())
3748}
3749
3750fn emit_deleted_tree_from_index(index: &Index, dir_prefix: &str, result: &mut Vec<DiffEntry>) {
3751    let scan_prefix: Vec<u8> = if dir_prefix.is_empty() {
3752        Vec::new()
3753    } else {
3754        format!("{dir_prefix}/").into_bytes()
3755    };
3756    if index.entries_sorted {
3757        let start = if scan_prefix.is_empty() {
3758            0
3759        } else {
3760            index
3761                .entries
3762                .partition_point(|e| e.path.as_slice() < scan_prefix.as_slice())
3763        };
3764        for ie in index.entries[start..].iter() {
3765            if ie.stage() != 0 {
3766                continue;
3767            }
3768            if !scan_prefix.is_empty() && !ie.path.starts_with(&scan_prefix) {
3769                break;
3770            }
3771            if scan_prefix.is_empty() && ie.path.contains(&b'/') {
3772                continue;
3773            }
3774            let path = String::from_utf8_lossy(&ie.path).into_owned();
3775            result.push(DiffEntry {
3776                status: DiffStatus::Deleted,
3777                old_path: Some(path),
3778                new_path: None,
3779                old_mode: FileMode::new(ie.mode),
3780                new_mode: FileMode::ABSENT,
3781                old_oid: ie.oid,
3782                new_oid: zero_oid(),
3783                score: None,
3784            });
3785        }
3786        return;
3787    }
3788    for ie in &index.entries {
3789        if ie.stage() != 0 {
3790            continue;
3791        }
3792        let Ok(path) = std::str::from_utf8(&ie.path) else {
3793            continue;
3794        };
3795        let under = if dir_prefix.is_empty() {
3796            !path.contains('/')
3797        } else if path == dir_prefix {
3798            false
3799        } else {
3800            path.strip_prefix(dir_prefix)
3801                .is_some_and(|rest| rest.starts_with('/'))
3802        };
3803        if !under {
3804            continue;
3805        }
3806        result.push(DiffEntry {
3807            status: DiffStatus::Deleted,
3808            old_path: Some(path.to_owned()),
3809            new_path: None,
3810            old_mode: FileMode::new(ie.mode),
3811            new_mode: FileMode::ABSENT,
3812            old_oid: ie.oid,
3813            new_oid: zero_oid(),
3814            score: None,
3815        });
3816    }
3817}
3818
3819fn emit_deleted_opts(
3820    odb: &Odb,
3821    entry: &TreeEntryRef<'_>,
3822    prefix: &str,
3823    show_trees: bool,
3824    index_hint: Option<&DiffTreesWalkHint<'_>>,
3825    result: &mut Vec<DiffEntry>,
3826) -> Result<()> {
3827    let path = crate::repo_relpath::format_path_stored(prefix, entry.name);
3828    if is_tree_mode(entry.mode) {
3829        if index_hint.is_some_and(|hint| hint.sparse_excludes_tree(&path)) {
3830            return Ok(());
3831        }
3832        if show_trees || index_hint.is_some_and(|hint| hint.coalesce_subtrees_for_checkout) {
3833            result.push(DiffEntry {
3834                status: DiffStatus::Deleted,
3835                old_path: Some(path.clone()),
3836                new_path: None,
3837                old_mode: FileMode::new(entry.mode),
3838                new_mode: FileMode::ABSENT,
3839                old_oid: entry.oid,
3840                new_oid: zero_oid(),
3841                score: None,
3842            });
3843        }
3844        if index_hint.is_some_and(|hint| hint.coalesce_subtrees_for_checkout) {
3845            return Ok(());
3846        }
3847        if let Some(index) = index_hint
3848            .filter(|hint| hint.old_tree_matches)
3849            .and_then(|hint| hint.index)
3850            .filter(|index| !index.has_sparse_directory_placeholders())
3851        {
3852            emit_deleted_tree_from_index(index, &path, result);
3853            return Ok(());
3854        }
3855        diff_subtrees_in_place(
3856            odb,
3857            Some(&entry.oid),
3858            None,
3859            &path,
3860            show_trees,
3861            index_hint,
3862            result,
3863        )?;
3864    } else {
3865        result.push(DiffEntry {
3866            status: DiffStatus::Deleted,
3867            old_path: Some(path.clone()),
3868            new_path: None,
3869            old_mode: FileMode::new(crate::index::canon_mode(entry.mode)),
3870            new_mode: FileMode::ABSENT,
3871            old_oid: entry.oid,
3872            new_oid: zero_oid(),
3873            score: None,
3874        });
3875    }
3876    Ok(())
3877}
3878
3879fn emit_added_opts(
3880    odb: &Odb,
3881    entry: &TreeEntryRef<'_>,
3882    prefix: &str,
3883    show_trees: bool,
3884    index_hint: Option<&DiffTreesWalkHint<'_>>,
3885    result: &mut Vec<DiffEntry>,
3886) -> Result<()> {
3887    let path = crate::repo_relpath::format_path_stored(prefix, entry.name);
3888    if is_tree_mode(entry.mode) {
3889        if index_hint.is_some_and(|hint| hint.sparse_excludes_tree(&path)) {
3890            result.push(DiffEntry {
3891                status: DiffStatus::Added,
3892                old_path: None,
3893                new_path: Some(crate::repo_relpath::sparse_directory_stored_path(&path)),
3894                old_mode: FileMode::ABSENT,
3895                new_mode: FileMode::new(MODE_TREE),
3896                old_oid: zero_oid(),
3897                new_oid: entry.oid,
3898                score: None,
3899            });
3900            return Ok(());
3901        }
3902        if show_trees || index_hint.is_some_and(|hint| hint.coalesce_subtrees_for_checkout) {
3903            result.push(DiffEntry {
3904                status: DiffStatus::Added,
3905                old_path: None,
3906                new_path: Some(path.clone()),
3907                old_mode: FileMode::ABSENT,
3908                new_mode: FileMode::new(entry.mode),
3909                old_oid: zero_oid(),
3910                new_oid: entry.oid,
3911                score: None,
3912            });
3913        }
3914        if index_hint.is_some_and(|hint| hint.coalesce_subtrees_for_checkout) {
3915            return Ok(());
3916        }
3917        diff_subtrees_in_place(
3918            odb,
3919            None,
3920            Some(&entry.oid),
3921            &path,
3922            show_trees,
3923            index_hint,
3924            result,
3925        )?;
3926    } else {
3927        result.push(DiffEntry {
3928            status: DiffStatus::Added,
3929            old_path: None,
3930            new_path: Some(path),
3931            old_mode: FileMode::ABSENT,
3932            new_mode: FileMode::new(crate::index::canon_mode(entry.mode)),
3933            old_oid: zero_oid(),
3934            new_oid: entry.oid,
3935            score: None,
3936        });
3937    }
3938    Ok(())
3939}
3940
3941// ── Index-to-tree diff (staged changes) ─────────────────────────────
3942
3943/// Compare the index against a tree (usually HEAD's tree).
3944///
3945/// This shows "staged" changes — what would be committed.
3946///
3947/// # Parameters
3948///
3949/// - `odb` — object database.
3950/// - `index` — the current index.
3951/// - `tree_oid` — the tree to compare against (e.g. HEAD's tree), or `None`
3952///   for comparison against an empty tree (initial commit).
3953///
3954/// # Errors
3955///
3956/// Returns errors from ODB reads.
3957///
3958/// When `ignore_submodules` is true, gitlink (`160000`) paths are omitted from the diff, matching
3959/// Git's `require_clean_work_tree(..., ignore_submodules=1)` used by `git rebase` / `git pull`.
3960pub fn diff_index_to_tree(
3961    odb: &Odb,
3962    index: &Index,
3963    tree_oid: Option<&ObjectId>,
3964    ignore_submodules: bool,
3965) -> Result<Vec<DiffEntry>> {
3966    match index_cache_tree_walk(index, tree_oid) {
3967        CacheTreeWalk::Fast {
3968            head_tree,
3969            cache_root,
3970        } => {
3971            diff_index_to_tree_with_cache_tree(odb, index, head_tree, cache_root, ignore_submodules)
3972        }
3973        CacheTreeWalk::Flatten => {
3974            diff_index_to_tree_flatten(odb, index, tree_oid, ignore_submodules)
3975        }
3976    }
3977}
3978
3979/// Diff the index against a tree and pair deleted/added paths as renames when similar enough.
3980///
3981/// `rename_threshold` is Git's `-M` percentage (50 is the usual default); pass `100` to skip
3982/// rename detection.
3983///
3984/// # Errors
3985///
3986/// Propagates errors from [`diff_index_to_tree`].
3987pub fn diff_index_to_tree_with_renames(
3988    odb: &Odb,
3989    index: &Index,
3990    tree_oid: Option<&ObjectId>,
3991    ignore_submodules: bool,
3992    rename_threshold: u32,
3993    config: &ConfigSet,
3994    thread_policy: &ThreadPolicy,
3995) -> Result<Vec<DiffEntry>> {
3996    let entries = diff_index_to_tree(odb, index, tree_oid, ignore_submodules)?;
3997    apply_rename_detection_to_diff_entries(
3998        odb,
3999        entries,
4000        None,
4001        rename_threshold,
4002        config,
4003        thread_policy,
4004    )
4005}
4006
4007/// Apply Git `-M` rename detection to a flat diff (index↔tree, tree↔tree, or index↔worktree).
4008///
4009/// Pass `work_root` when added sides may be read from the working tree (index↔worktree diffs).
4010/// Use `rename_threshold` `100` to leave entries unchanged.
4011///
4012/// # Errors
4013///
4014/// Propagates object read failures while scoring renames.
4015pub fn apply_rename_detection_to_diff_entries(
4016    odb: &Odb,
4017    entries: Vec<DiffEntry>,
4018    work_root: Option<&Path>,
4019    rename_threshold: u32,
4020    config: &ConfigSet,
4021    thread_policy: &ThreadPolicy,
4022) -> Result<Vec<DiffEntry>> {
4023    if entries.is_empty() || rename_threshold >= 100 {
4024        return Ok(entries);
4025    }
4026    if work_root.is_some() {
4027        let opts = DiffPostprocessOptions {
4028            rename_threshold,
4029            ..DiffPostprocessOptions::default()
4030        };
4031        postprocess_diff_entries(
4032            odb,
4033            entries,
4034            None,
4035            work_root,
4036            None,
4037            config,
4038            thread_policy,
4039            &opts,
4040            None,
4041        )
4042    } else {
4043        detect_renames_with_policy(
4044            odb,
4045            None,
4046            entries,
4047            rename_threshold,
4048            config,
4049            thread_policy,
4050            None,
4051            DetectRenamesOptions::default(),
4052        )
4053        .map(|r| r.entries)
4054    }
4055}
4056
4057/// Diff the index against the working tree and pair deleted/added paths as renames when similar.
4058///
4059/// Runs [`diff_index_to_worktree_with_options`], then [`apply_rename_detection_to_diff_entries`]
4060/// with `work_root` set to `work_tree`. Untracked worktree paths are not diff entries (see
4061/// [`crate::porcelain::status`]); they are never fed into rename detection here.
4062///
4063/// # Errors
4064///
4065/// Propagates errors from the index↔worktree diff or rename post-processing.
4066pub fn diff_index_to_worktree_with_renames(
4067    repo: &Repository,
4068    index: &mut Index,
4069    work_tree: &Path,
4070    options: DiffIndexToWorktreeOptions,
4071    rename_threshold: u32,
4072) -> Result<(Vec<DiffEntry>, bool)> {
4073    let config = repo.config()?.as_ref().clone();
4074    let thread_policy = repo.thread_policy();
4075    let odb = repo.odb();
4076    let (entries, changed) = diff_index_to_worktree_with_options(odb, index, work_tree, options)?;
4077    let entries = apply_rename_detection_to_diff_entries(
4078        odb,
4079        entries,
4080        Some(work_tree),
4081        rename_threshold,
4082        &config,
4083        &thread_policy,
4084    )?;
4085    Ok((entries, changed))
4086}
4087
4088enum CacheTreeWalk<'a> {
4089    Fast {
4090        head_tree: &'a ObjectId,
4091        cache_root: &'a CacheTreeNode,
4092    },
4093    Flatten,
4094}
4095
4096/// Whether the index may use the cache-tree walker (possibly with an invalid root and valid
4097/// descendant nodes after partial invalidation).
4098fn index_cache_tree_walk<'a>(
4099    index: &'a Index,
4100    tree_oid: Option<&'a ObjectId>,
4101) -> CacheTreeWalk<'a> {
4102    let Some(head_tree) = tree_oid else {
4103        return CacheTreeWalk::Flatten;
4104    };
4105    let Some(cache_root) = index.cache_tree.as_ref() else {
4106        return CacheTreeWalk::Flatten;
4107    };
4108    if index.entries.iter().any(|e| e.stage() != 0) {
4109        return CacheTreeWalk::Flatten;
4110    }
4111    CacheTreeWalk::Fast {
4112        head_tree,
4113        cache_root,
4114    }
4115}
4116
4117/// Flatten-tree index-vs-tree diff (used when the index has no cache-tree extension).
4118fn diff_index_to_tree_flatten(
4119    odb: &Odb,
4120    index: &Index,
4121    tree_oid: Option<&ObjectId>,
4122    ignore_submodules: bool,
4123) -> Result<Vec<DiffEntry>> {
4124    let sparse_index = index.has_sparse_directory_placeholders();
4125    // Flatten the tree into a sorted list of (path, mode, oid)
4126    let tree_entries = match tree_oid {
4127        Some(oid) if sparse_index => flatten_tree_with_index(odb, oid, "", Some(index))?,
4128        Some(oid) => flatten_tree(odb, oid, "")?,
4129        None => Vec::new(),
4130    };
4131
4132    // Build maps keyed by path
4133    let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
4134        std::collections::BTreeMap::new();
4135    for entry in &tree_entries {
4136        tree_map.insert(&entry.path, entry);
4137    }
4138
4139    let mut result = Vec::new();
4140    let mut stage0_paths = std::collections::BTreeSet::new();
4141    let mut unmerged_modes: std::collections::BTreeMap<String, (u8, u32)> =
4142        std::collections::BTreeMap::new();
4143
4144    // Check index entries against tree
4145    for ie in &index.entries {
4146        let path = String::from_utf8_lossy(&ie.path).to_string();
4147        if ie.stage() == 0 && ie.intent_to_add() {
4148            // Intent-to-add entries are not "staged" for diff-index / status
4149            // (matches Git: `git diff --cached` is empty for `-N` paths).
4150            continue;
4151        }
4152        if ie.stage() == 0 && ie.is_sparse_directory_placeholder() {
4153            let dir = path.strip_suffix('/').unwrap_or(path.as_str());
4154            let index_flat = flatten_tree(odb, &ie.oid, dir)?;
4155            let head_flat = match tree_oid {
4156                None => Vec::new(),
4157                Some(head_root) => match subtree_oid_at_path(odb, head_root, dir)? {
4158                    None => Vec::new(),
4159                    Some(head_sub) if head_sub == ie.oid => {
4160                        continue;
4161                    }
4162                    Some(head_sub) => flatten_tree(odb, &head_sub, dir)?,
4163                },
4164            };
4165            diff_flattened_subtrees(
4166                &head_flat,
4167                &index_flat,
4168                ignore_submodules,
4169                &mut result,
4170                &mut stage0_paths,
4171                &mut tree_map,
4172            );
4173            continue;
4174        }
4175        if ie.stage() != 0 {
4176            let rank = match ie.stage() {
4177                2 => 0u8,
4178                3 => 1u8,
4179                1 => 2u8,
4180                _ => 3u8,
4181            };
4182            match unmerged_modes.get(&path) {
4183                Some((existing_rank, _)) if *existing_rank <= rank => {}
4184                _ => {
4185                    unmerged_modes.insert(path, (rank, ie.mode));
4186                }
4187            }
4188            continue;
4189        }
4190        if ignore_submodules && ie.mode == 0o160000 {
4191            let _ = tree_map.remove(path.as_str());
4192            stage0_paths.insert(path.clone());
4193            continue;
4194        }
4195        stage0_paths.insert(path.clone());
4196        match tree_map.remove(path.as_str()) {
4197            Some(te) => {
4198                // Present in both — check for differences
4199                if te.oid != ie.oid
4200                    || crate::index::canon_mode(te.mode) != crate::index::canon_mode(ie.mode)
4201                {
4202                    let tree_mode = crate::index::canon_mode(te.mode);
4203                    let index_mode = crate::index::canon_mode(ie.mode);
4204                    result.push(DiffEntry {
4205                        status: DiffStatus::for_content_change(tree_mode, index_mode),
4206                        old_path: Some(path.clone()),
4207                        new_path: Some(path),
4208                        old_mode: FileMode::new(tree_mode),
4209                        new_mode: FileMode::new(index_mode),
4210                        old_oid: te.oid,
4211                        new_oid: ie.oid,
4212                        score: None,
4213                    });
4214                }
4215            }
4216            None => {
4217                // In index but not tree → added
4218                result.push(DiffEntry {
4219                    status: DiffStatus::Added,
4220                    old_path: None,
4221                    new_path: Some(path),
4222                    old_mode: FileMode::ABSENT,
4223                    new_mode: FileMode::new(ie.mode),
4224                    old_oid: zero_oid(),
4225                    new_oid: ie.oid,
4226                    score: None,
4227                });
4228            }
4229        }
4230    }
4231
4232    diff_index_to_tree_finish_unmerged_and_deleted(
4233        ignore_submodules,
4234        &mut tree_map,
4235        &unmerged_modes,
4236        &stage0_paths,
4237        &mut result,
4238    );
4239
4240    sort_entries_by_path(&mut result);
4241    Ok(result)
4242}
4243
4244fn diff_index_to_tree_finish_unmerged_and_deleted(
4245    ignore_submodules: bool,
4246    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
4247    unmerged_modes: &std::collections::BTreeMap<String, (u8, u32)>,
4248    stage0_paths: &std::collections::BTreeSet<String>,
4249    result: &mut Vec<DiffEntry>,
4250) {
4251    for (path, (_, mode)) in unmerged_modes {
4252        if stage0_paths.contains(path) {
4253            continue;
4254        }
4255        tree_map.remove(path.as_str());
4256        result.push(DiffEntry {
4257            status: DiffStatus::Unmerged,
4258            old_path: Some(path.clone()),
4259            new_path: Some(path.clone()),
4260            old_mode: FileMode::ABSENT,
4261            new_mode: FileMode::new(*mode),
4262            old_oid: zero_oid(),
4263            new_oid: zero_oid(),
4264            score: None,
4265        });
4266    }
4267
4268    // Remaining tree entries not in index → deleted
4269    for (path, te) in std::mem::take(tree_map) {
4270        if ignore_submodules && te.mode == 0o160000 {
4271            continue;
4272        }
4273        result.push(DiffEntry {
4274            status: DiffStatus::Deleted,
4275            old_path: Some(path.to_owned()),
4276            new_path: None,
4277            old_mode: FileMode::new(te.mode),
4278            new_mode: FileMode::ABSENT,
4279            old_oid: te.oid,
4280            new_oid: zero_oid(),
4281            score: None,
4282        });
4283    }
4284}
4285
4286/// Cache-tree-aware index-vs-tree diff: walk HEAD tree and index together and skip
4287/// subtrees whose valid cache-tree node OID matches the tree entry (Git unpack-trees).
4288fn diff_index_to_tree_with_cache_tree(
4289    odb: &Odb,
4290    index: &Index,
4291    head_tree: &ObjectId,
4292    cache_root: &CacheTreeNode,
4293    ignore_submodules: bool,
4294) -> Result<Vec<DiffEntry>> {
4295    let mut result = Vec::new();
4296    let mut idx_cursor = 0usize;
4297
4298    let mut walk = DiffIndexTreeWalk {
4299        odb,
4300        index,
4301        idx_cursor: &mut idx_cursor,
4302        ignore_submodules,
4303        result: &mut result,
4304        abort_to_flatten: false,
4305    };
4306    diff_index_tree_at(&mut walk, head_tree, Some(cache_root), b"")?;
4307    if walk.abort_to_flatten {
4308        return diff_index_to_tree_flatten(odb, index, Some(head_tree), ignore_submodules);
4309    }
4310
4311    // Index entries not consumed by the tree walk (added paths).
4312    while idx_cursor < index.entries.len() {
4313        let ie = &index.entries[idx_cursor];
4314        idx_cursor += 1;
4315        let path = String::from_utf8_lossy(&ie.path).to_string();
4316        if ie.stage() == 0 && ie.intent_to_add() {
4317            continue;
4318        }
4319        if ie.stage() != 0 {
4320            continue;
4321        }
4322        if ignore_submodules && ie.mode == 0o160000 {
4323            continue;
4324        }
4325        result.push(DiffEntry {
4326            status: DiffStatus::Added,
4327            old_path: None,
4328            new_path: Some(path),
4329            old_mode: FileMode::ABSENT,
4330            new_mode: FileMode::new(ie.mode),
4331            old_oid: zero_oid(),
4332            new_oid: ie.oid,
4333            score: None,
4334        });
4335    }
4336
4337    sort_entries_by_path(&mut result);
4338    Ok(result)
4339}
4340
4341enum IndexLevelKind<'a> {
4342    Blob {
4343        entry: &'a IndexEntry,
4344        name: &'a [u8],
4345    },
4346    Tree {
4347        name: &'a [u8],
4348    },
4349}
4350
4351/// `path` relative to the directory `dir_prefix` (raw bytes, no trailing slash), or `None` when
4352/// `path` lies outside it. The directory's own path maps to an empty slice.
4353fn index_relative_in_dir<'a>(path: &'a [u8], dir_prefix: &[u8]) -> Option<&'a [u8]> {
4354    if dir_prefix.is_empty() {
4355        return Some(path);
4356    }
4357    let rest = path.strip_prefix(dir_prefix)?;
4358    match rest.split_first() {
4359        None => Some(rest),
4360        Some((b'/', tail)) => Some(tail),
4361        Some(_) => None,
4362    }
4363}
4364
4365fn join_index_path(dir_prefix: &[u8], name: &[u8]) -> Vec<u8> {
4366    if dir_prefix.is_empty() {
4367        return name.to_vec();
4368    }
4369    let mut path = Vec::with_capacity(dir_prefix.len() + 1 + name.len());
4370    path.extend_from_slice(dir_prefix);
4371    path.push(b'/');
4372    path.extend_from_slice(name);
4373    path
4374}
4375
4376fn peek_index_level_entry<'a>(
4377    index: &'a Index,
4378    cursor: &mut usize,
4379    dir_prefix: &[u8],
4380    ignore_submodules: bool,
4381) -> Option<IndexLevelKind<'a>> {
4382    while let Some(ie) = index.entries.get(*cursor) {
4383        let rel = index_relative_in_dir(&ie.path, dir_prefix)?;
4384        if ie.stage() != 0 || ie.intent_to_add() || rel.is_empty() {
4385            *cursor += 1;
4386            continue;
4387        }
4388        if let Some(slash) = rel.iter().position(|&b| b == b'/') {
4389            return Some(IndexLevelKind::Tree {
4390                name: &rel[..slash],
4391            });
4392        }
4393        if ignore_submodules && ie.mode == 0o160000 {
4394            *cursor += 1;
4395            continue;
4396        }
4397        return Some(IndexLevelKind::Blob {
4398            entry: ie,
4399            name: rel,
4400        });
4401    }
4402    None
4403}
4404
4405fn skip_index_subtree(index: &Index, cursor: &mut usize, dir_prefix: &[u8]) {
4406    while index
4407        .entries
4408        .get(*cursor)
4409        .is_some_and(|ie| index_relative_in_dir(&ie.path, dir_prefix).is_some())
4410    {
4411        *cursor += 1;
4412    }
4413}
4414
4415/// Children of a cache-tree node up to which a linear name scan beats sorting.
4416const CACHE_CHILD_LINEAR_SCAN_MAX: usize = 16;
4417
4418/// Name lookup over one cache-tree node's subtrees.
4419///
4420/// The index extension does not promise any child order, and a wide directory is looked up
4421/// once per tree entry, so nodes with many children are sorted once per visit instead of
4422/// being scanned for every entry. The first child with a matching name wins either way.
4423struct CacheChildren<'a> {
4424    children: &'a [CacheTreeNode],
4425    sorted: Vec<&'a CacheTreeNode>,
4426}
4427
4428impl<'a> CacheChildren<'a> {
4429    fn new(node: Option<&'a CacheTreeNode>) -> Self {
4430        let children = node.map_or(&[][..], |n| n.children.as_slice());
4431        let sorted = if children.len() > CACHE_CHILD_LINEAR_SCAN_MAX {
4432            let mut sorted: Vec<&CacheTreeNode> = children.iter().collect();
4433            sorted.sort_by(|a, b| a.name.cmp(&b.name));
4434            sorted
4435        } else {
4436            Vec::new()
4437        };
4438        Self { children, sorted }
4439    }
4440
4441    fn get(&self, name: &[u8]) -> Option<&'a CacheTreeNode> {
4442        if self.sorted.is_empty() {
4443            return self.children.iter().find(|c| c.name == name);
4444        }
4445        let pos = self.sorted.partition_point(|c| c.name.as_slice() < name);
4446        self.sorted.get(pos).copied().filter(|c| c.name == name)
4447    }
4448}
4449
4450struct DiffIndexTreeWalk<'a, 'b> {
4451    odb: &'a Odb,
4452    index: &'a Index,
4453    idx_cursor: &'b mut usize,
4454    ignore_submodules: bool,
4455    result: &'b mut Vec<DiffEntry>,
4456    /// Set when a tree object has duplicate entry names; the caller falls back to flatten.
4457    abort_to_flatten: bool,
4458}
4459
4460fn tree_entries_have_duplicate_names(entries: &[TreeEntry]) -> bool {
4461    let mut seen = std::collections::BTreeSet::<&[u8]>::new();
4462    for entry in entries {
4463        if !seen.insert(entry.name.as_slice()) {
4464            return true;
4465        }
4466    }
4467    false
4468}
4469
4470/// Walk the tree `tree_oid` against the index entries under `dir_prefix` (raw path bytes).
4471fn diff_index_tree_at(
4472    walk: &mut DiffIndexTreeWalk<'_, '_>,
4473    tree_oid: &ObjectId,
4474    cache_node: Option<&CacheTreeNode>,
4475    dir_prefix: &[u8],
4476) -> Result<()> {
4477    if !dir_prefix.is_empty() {
4478        let mut path = dir_prefix.to_vec();
4479        path.push(b'/');
4480        if let Some(entry) = walk
4481            .index
4482            .entries
4483            .get(*walk.idx_cursor)
4484            .filter(|entry| entry.path == path && entry.is_sparse_directory_placeholder())
4485        {
4486            if entry.oid == *tree_oid {
4487                *walk.idx_cursor += 1;
4488                return Ok(());
4489            }
4490            let dir = String::from_utf8_lossy(dir_prefix);
4491            let head_flat = flatten_tree(walk.odb, tree_oid, dir.as_ref())?;
4492            let index_flat = flatten_tree(walk.odb, &entry.oid, dir.as_ref())?;
4493            let mut stage0_paths = std::collections::BTreeSet::new();
4494            let mut tree_map = std::collections::BTreeMap::new();
4495            diff_flattened_subtrees(
4496                &head_flat,
4497                &index_flat,
4498                walk.ignore_submodules,
4499                walk.result,
4500                &mut stage0_paths,
4501                &mut tree_map,
4502            );
4503            *walk.idx_cursor += 1;
4504            return Ok(());
4505        }
4506    }
4507    let tree_entries = read_tree(walk.odb, tree_oid)?;
4508    if tree_entries_have_duplicate_names(&tree_entries) {
4509        walk.abort_to_flatten = true;
4510        return Ok(());
4511    }
4512    let cache_children = CacheChildren::new(cache_node);
4513    let mut ti = 0usize;
4514
4515    loop {
4516        let te = tree_entries.get(ti);
4517        let ie_kind = peek_index_level_entry(
4518            walk.index,
4519            walk.idx_cursor,
4520            dir_prefix,
4521            walk.ignore_submodules,
4522        );
4523
4524        match (te, ie_kind) {
4525            (Some(t_entry), Some(ie)) => {
4526                let t_name = &t_entry.name;
4527                let (i_name, i_is_tree) = match &ie {
4528                    IndexLevelKind::Blob { name, .. } => (*name, false),
4529                    IndexLevelKind::Tree { name } => (*name, true),
4530                };
4531                let t_is_tree = is_tree_mode(t_entry.mode);
4532                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4533                match cmp {
4534                    std::cmp::Ordering::Less => {
4535                        emit_index_tree_deleted(
4536                            walk.odb,
4537                            t_entry,
4538                            dir_prefix,
4539                            walk.ignore_submodules,
4540                            walk.result,
4541                        )?;
4542                        ti += 1;
4543                    }
4544                    std::cmp::Ordering::Greater => {
4545                        emit_index_tree_added(walk, dir_prefix)?;
4546                    }
4547                    std::cmp::Ordering::Equal => {
4548                        if t_is_tree && i_is_tree {
4549                            let full_path = join_index_path(dir_prefix, t_name);
4550                            if diff_index_tree_skip_sparse_placeholder(walk, &full_path, t_entry)? {
4551                                ti += 1;
4552                                continue;
4553                            }
4554                            let child_cache = cache_children.get(t_name);
4555                            if child_cache
4556                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
4557                            {
4558                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4559                                ti += 1;
4560                                continue;
4561                            }
4562                            diff_index_tree_at(walk, &t_entry.oid, child_cache, &full_path)?;
4563                        } else if t_is_tree != i_is_tree {
4564                            emit_index_tree_deleted(
4565                                walk.odb,
4566                                t_entry,
4567                                dir_prefix,
4568                                walk.ignore_submodules,
4569                                walk.result,
4570                            )?;
4571                            emit_index_tree_added(walk, dir_prefix)?;
4572                        } else {
4573                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
4574                                ti += 1;
4575                                continue;
4576                            };
4577                            *walk.idx_cursor += 1;
4578                            if t_entry.oid != ie.oid
4579                                || crate::index::canon_mode(t_entry.mode)
4580                                    != crate::index::canon_mode(ie.mode)
4581                            {
4582                                let path = String::from_utf8_lossy(&ie.path).into_owned();
4583                                let tree_mode = crate::index::canon_mode(t_entry.mode);
4584                                let index_mode = crate::index::canon_mode(ie.mode);
4585                                walk.result.push(DiffEntry {
4586                                    status: DiffStatus::for_content_change(tree_mode, index_mode),
4587                                    old_path: Some(path.clone()),
4588                                    new_path: Some(path),
4589                                    old_mode: FileMode::new(tree_mode),
4590                                    new_mode: FileMode::new(index_mode),
4591                                    old_oid: t_entry.oid,
4592                                    new_oid: ie.oid,
4593                                    score: None,
4594                                });
4595                            }
4596                        }
4597                        ti += 1;
4598                    }
4599                }
4600            }
4601            (Some(t_entry), None) => {
4602                emit_index_tree_deleted(
4603                    walk.odb,
4604                    t_entry,
4605                    dir_prefix,
4606                    walk.ignore_submodules,
4607                    walk.result,
4608                )?;
4609                ti += 1;
4610            }
4611            (None, Some(_)) => {
4612                emit_index_tree_added(walk, dir_prefix)?;
4613            }
4614            (None, None) => break,
4615        }
4616    }
4617
4618    Ok(())
4619}
4620
4621fn diff_index_tree_skip_sparse_placeholder(
4622    walk: &mut DiffIndexTreeWalk<'_, '_>,
4623    full_path: &[u8],
4624    t_entry: &TreeEntry,
4625) -> Result<bool> {
4626    let Some(entry) = walk.index.entries.get(*walk.idx_cursor) else {
4627        return Ok(false);
4628    };
4629    if !entry.is_sparse_directory_placeholder() {
4630        return Ok(false);
4631    }
4632    let mut dir = full_path.to_vec();
4633    if !dir.ends_with(b"/") {
4634        dir.push(b'/');
4635    }
4636    if entry.path != dir || entry.oid != t_entry.oid {
4637        return Ok(false);
4638    }
4639    *walk.idx_cursor += 1;
4640    Ok(true)
4641}
4642
4643fn emit_index_tree_deleted(
4644    odb: &Odb,
4645    entry: &TreeEntry,
4646    dir_prefix: &[u8],
4647    ignore_submodules: bool,
4648    result: &mut Vec<DiffEntry>,
4649) -> Result<()> {
4650    if ignore_submodules && entry.mode == 0o160000 {
4651        return Ok(());
4652    }
4653    let path = String::from_utf8_lossy(&join_index_path(dir_prefix, &entry.name)).into_owned();
4654    if is_tree_mode(entry.mode) {
4655        for fe in flatten_tree(odb, &entry.oid, &path)? {
4656            if ignore_submodules && fe.mode == 0o160000 {
4657                continue;
4658            }
4659            result.push(DiffEntry {
4660                status: DiffStatus::Deleted,
4661                old_path: Some(fe.path.clone()),
4662                new_path: None,
4663                old_mode: FileMode::new(fe.mode),
4664                new_mode: FileMode::ABSENT,
4665                old_oid: fe.oid,
4666                new_oid: zero_oid(),
4667                score: None,
4668            });
4669        }
4670    } else {
4671        result.push(DiffEntry {
4672            status: DiffStatus::Deleted,
4673            old_path: Some(path),
4674            new_path: None,
4675            old_mode: FileMode::new(entry.mode),
4676            new_mode: FileMode::ABSENT,
4677            old_oid: entry.oid,
4678            new_oid: zero_oid(),
4679            score: None,
4680        });
4681    }
4682    Ok(())
4683}
4684
4685fn emit_index_tree_added(walk: &mut DiffIndexTreeWalk<'_, '_>, dir_prefix: &[u8]) -> Result<()> {
4686    let ie_kind = peek_index_level_entry(
4687        walk.index,
4688        walk.idx_cursor,
4689        dir_prefix,
4690        walk.ignore_submodules,
4691    )
4692    .ok_or_else(|| Error::IndexError("missing index entry during tree walk".to_owned()))?;
4693    match ie_kind {
4694        IndexLevelKind::Blob { entry: ie, .. } => {
4695            let path = String::from_utf8_lossy(&ie.path).into_owned();
4696            *walk.idx_cursor += 1;
4697            walk.result.push(DiffEntry {
4698                status: DiffStatus::Added,
4699                old_path: None,
4700                new_path: Some(path),
4701                old_mode: FileMode::ABSENT,
4702                new_mode: FileMode::new(ie.mode),
4703                old_oid: zero_oid(),
4704                new_oid: ie.oid,
4705                score: None,
4706            });
4707        }
4708        IndexLevelKind::Tree { name } => {
4709            let full_path = join_index_path(dir_prefix, name);
4710            let mut path_slash = full_path.clone();
4711            path_slash.push(b'/');
4712            if let Some(entry) =
4713                walk.index.entries.get(*walk.idx_cursor).filter(|entry| {
4714                    entry.path == path_slash && entry.is_sparse_directory_placeholder()
4715                })
4716            {
4717                let dir = String::from_utf8_lossy(if dir_prefix.is_empty() {
4718                    name
4719                } else {
4720                    &full_path
4721                });
4722                emit_flattened_subtree_as_added(
4723                    walk.odb,
4724                    &entry.oid,
4725                    dir.as_ref(),
4726                    walk.ignore_submodules,
4727                    walk.result,
4728                )?;
4729                *walk.idx_cursor += 1;
4730                return Ok(());
4731            }
4732            let start = *walk.idx_cursor;
4733            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4734            for ie in &walk.index.entries[start..*walk.idx_cursor] {
4735                if ie.stage() != 0 || ie.intent_to_add() {
4736                    continue;
4737                }
4738                if walk.ignore_submodules && ie.mode == 0o160000 {
4739                    continue;
4740                }
4741                let path = String::from_utf8_lossy(&ie.path).into_owned();
4742                walk.result.push(DiffEntry {
4743                    status: DiffStatus::Added,
4744                    old_path: None,
4745                    new_path: Some(path),
4746                    old_mode: FileMode::ABSENT,
4747                    new_mode: FileMode::new(ie.mode),
4748                    old_oid: zero_oid(),
4749                    new_oid: ie.oid,
4750                    score: None,
4751                });
4752            }
4753        }
4754    }
4755    Ok(())
4756}
4757
4758/// Paths and rows to reconcile an index with a target tree during hard reset.
4759#[derive(Default)]
4760pub(crate) struct HardResetIndexPlan {
4761    pub remove_paths: Vec<Vec<u8>>,
4762    pub updates: Vec<(Vec<u8>, u32, ObjectId)>,
4763    pub insert_entries: Vec<IndexEntry>,
4764}
4765
4766/// Plan index changes to match `target_tree` using a cache-tree-aware lockstep walk (raw paths).
4767///
4768/// Never builds a flattened tree map; when the cache-tree extension is absent, walks the full tree
4769/// against the index instead of calling [`diff_index_to_tree_flatten`].
4770pub(crate) fn plan_hard_reset_index_align(
4771    odb: &Odb,
4772    index: &Index,
4773    target_tree: &ObjectId,
4774    sparse: Option<&SparseCheckout>,
4775    index_sparse: bool,
4776) -> Result<HardResetIndexPlan> {
4777    let mut plan = HardResetIndexPlan::default();
4778    let mut idx_cursor = 0usize;
4779    let cache_root = index.cache_tree.as_ref();
4780    let use_cache = cache_root.is_some()
4781        && index
4782            .entries
4783            .iter()
4784            .all(|entry| entry.stage() == 0 && !entry.intent_to_add());
4785    let cache_node = if use_cache { cache_root } else { None };
4786    let retry = {
4787        let mut walk = HardResetAlignWalk {
4788            odb,
4789            index,
4790            idx_cursor: &mut idx_cursor,
4791            ignore_submodules: false,
4792            sparse,
4793            index_sparse,
4794            plan: &mut plan,
4795            retry_without_cache: false,
4796        };
4797        hard_reset_align_index_tree_at(&mut walk, target_tree, cache_node, b"")?;
4798        walk.retry_without_cache
4799    };
4800    if retry {
4801        idx_cursor = 0;
4802        plan = HardResetIndexPlan::default();
4803        let mut walk = HardResetAlignWalk {
4804            odb,
4805            index,
4806            idx_cursor: &mut idx_cursor,
4807            ignore_submodules: false,
4808            sparse,
4809            index_sparse,
4810            plan: &mut plan,
4811            retry_without_cache: false,
4812        };
4813        hard_reset_align_index_tree_at(&mut walk, target_tree, None, b"")?;
4814    }
4815    while idx_cursor < index.entries.len() {
4816        let ie = &index.entries[idx_cursor];
4817        idx_cursor += 1;
4818        if ie.stage() != 0 || ie.intent_to_add() {
4819            continue;
4820        }
4821        plan.remove_paths.push(ie.path.clone());
4822    }
4823    Ok(plan)
4824}
4825
4826/// Apply a [`HardResetIndexPlan`] and invalidate cache-tree nodes for touched paths.
4827pub(crate) fn apply_hard_reset_index_plan(index: &mut Index, plan: HardResetIndexPlan) {
4828    let change_count = plan
4829        .remove_paths
4830        .len()
4831        .saturating_add(plan.updates.len())
4832        .saturating_add(plan.insert_entries.len());
4833    const BULK_HARD_RESET_INDEX_MIN: usize = 64;
4834    let bulk = change_count >= BULK_HARD_RESET_INDEX_MIN;
4835    if bulk {
4836        index.drop_derived_index_extensions();
4837    }
4838    for (path, mode, oid) in plan.updates {
4839        if let Some(pos) = index.find_entry_pos(&path, 0) {
4840            let entry = &mut index.entries[pos];
4841            let content_unchanged = entry.oid == oid && entry.mode == mode;
4842            entry.mode = mode;
4843            entry.oid = oid;
4844            if !content_unchanged {
4845                entry.size = 0;
4846            }
4847            if !bulk {
4848                index.invalidate_cache_tree_for_path(&path);
4849            }
4850        }
4851    }
4852    index.defer_untracked_cache_invalidation = bulk;
4853    index.remove_prefixes_paths_and_insert(
4854        &std::collections::BTreeSet::new(),
4855        plan.remove_paths.iter().map(|p| p.as_slice()),
4856        plan.insert_entries,
4857    );
4858    index.defer_untracked_cache_invalidation = false;
4859    if !bulk {
4860        for path in &plan.remove_paths {
4861            index.invalidate_cache_tree_for_path(path);
4862        }
4863    }
4864    index.sort();
4865}
4866
4867/// Rebuild the cache-tree extension after hard-reset index alignment, reusing valid seed
4868/// subtrees from the lockstep tree/index walk.
4869pub(crate) fn rebuild_hard_reset_cache_tree(
4870    odb: &Odb,
4871    index: &mut Index,
4872    target_tree: &ObjectId,
4873) -> Result<()> {
4874    let stage0_count = index
4875        .entries
4876        .iter()
4877        .filter(|entry| entry.stage() == 0 && !entry.intent_to_add())
4878        .count();
4879    let seed_root = index.cache_tree.as_ref();
4880    let mut idx_cursor = 0usize;
4881    let root = rebuild_hard_reset_cache_tree_at(
4882        odb,
4883        index,
4884        &mut idx_cursor,
4885        target_tree,
4886        seed_root,
4887        b"",
4888        Vec::new(),
4889    )?;
4890    if usize::try_from(root.entry_count).ok() == Some(stage0_count) {
4891        index.set_cache_tree(root);
4892        Ok(())
4893    } else {
4894        Err(Error::IndexError(
4895            "hard reset cache-tree entry count does not match index".into(),
4896        ))
4897    }
4898}
4899
4900fn rebuild_hard_reset_cache_tree_at(
4901    odb: &Odb,
4902    index: &Index,
4903    idx_cursor: &mut usize,
4904    tree_oid: &ObjectId,
4905    seed_node: Option<&CacheTreeNode>,
4906    dir_prefix: &[u8],
4907    name: Vec<u8>,
4908) -> Result<CacheTreeNode> {
4909    if !dir_prefix.is_empty() {
4910        let mut path = dir_prefix.to_vec();
4911        path.push(b'/');
4912        if let Some(entry) = index.entries.get(*idx_cursor).filter(|entry| {
4913            entry.path == path && entry.is_sparse_directory_placeholder() && entry.oid == *tree_oid
4914        }) {
4915            let _ = entry;
4916            *idx_cursor += 1;
4917            return Ok(CacheTreeNode::valid(name, 1, *tree_oid, Vec::new()));
4918        }
4919    }
4920    let tree_entries = read_tree(odb, tree_oid)?;
4921    let cache_children = CacheChildren::new(seed_node);
4922    let mut entry_count: i32 = 0;
4923    let mut children: Vec<CacheTreeNode> = Vec::new();
4924    let mut ti = 0usize;
4925
4926    loop {
4927        let te = tree_entries.get(ti);
4928        let ie_kind = peek_index_level_entry(index, idx_cursor, dir_prefix, false);
4929        match (te, ie_kind) {
4930            (Some(t_entry), Some(ie)) => {
4931                let t_name = &t_entry.name;
4932                let (i_name, i_is_tree) = match &ie {
4933                    IndexLevelKind::Blob { name, .. } => (*name, false),
4934                    IndexLevelKind::Tree { name } => (*name, true),
4935                };
4936                let t_is_tree = is_tree_mode(t_entry.mode);
4937                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4938                if cmp != std::cmp::Ordering::Equal {
4939                    return Err(Error::IndexError(
4940                        "hard reset cache-tree rebuild: index out of sync with target tree".into(),
4941                    ));
4942                }
4943                if t_is_tree && i_is_tree {
4944                    let full_path = join_index_path(dir_prefix, t_name);
4945                    if hard_reset_skip_sparse_cache_rebuild(index, idx_cursor, &full_path, t_entry)?
4946                    {
4947                        entry_count = entry_count.saturating_add(1);
4948                        ti += 1;
4949                        continue;
4950                    }
4951                    let child = if let Some(child_cache) = cache_children
4952                        .get(t_name)
4953                        .filter(|node| node.is_valid() && node.oid == Some(t_entry.oid))
4954                    {
4955                        skip_index_subtree(index, idx_cursor, &full_path);
4956                        let mut reused = (*child_cache).clone();
4957                        reused.name = t_name.clone();
4958                        reused
4959                    } else {
4960                        rebuild_hard_reset_cache_tree_at(
4961                            odb,
4962                            index,
4963                            idx_cursor,
4964                            &t_entry.oid,
4965                            cache_children.get(t_name),
4966                            &full_path,
4967                            t_name.clone(),
4968                        )?
4969                    };
4970                    entry_count = entry_count.saturating_add(child.entry_count.max(0));
4971                    children.push(child);
4972                } else if t_is_tree != i_is_tree {
4973                    return Err(Error::IndexError(
4974                        "hard reset cache-tree rebuild: tree/index type mismatch".into(),
4975                    ));
4976                } else {
4977                    *idx_cursor += 1;
4978                    entry_count = entry_count.saturating_add(1);
4979                }
4980                ti += 1;
4981            }
4982            (None, None) => break,
4983            _ => {
4984                return Err(Error::IndexError(
4985                    "hard reset cache-tree rebuild: index out of sync with target tree".into(),
4986                ));
4987            }
4988        }
4989    }
4990    children.sort_by(|a, b| a.name.cmp(&b.name));
4991    Ok(CacheTreeNode::valid(name, entry_count, *tree_oid, children))
4992}
4993
4994fn hard_reset_skip_sparse_cache_rebuild(
4995    index: &Index,
4996    idx_cursor: &mut usize,
4997    full_path: &[u8],
4998    t_entry: &TreeEntry,
4999) -> Result<bool> {
5000    let Some(entry) = index.entries.get(*idx_cursor) else {
5001        return Ok(false);
5002    };
5003    if !entry.is_sparse_directory_placeholder() {
5004        return Ok(false);
5005    }
5006    let mut dir = full_path.to_vec();
5007    if !dir.ends_with(b"/") {
5008        dir.push(b'/');
5009    }
5010    if entry.path != dir || entry.oid != t_entry.oid {
5011        return Ok(false);
5012    }
5013    *idx_cursor += 1;
5014    Ok(true)
5015}
5016
5017struct HardResetAlignWalk<'a, 'b> {
5018    odb: &'a Odb,
5019    index: &'a Index,
5020    idx_cursor: &'b mut usize,
5021    ignore_submodules: bool,
5022    sparse: Option<&'a SparseCheckout>,
5023    index_sparse: bool,
5024    plan: &'b mut HardResetIndexPlan,
5025    retry_without_cache: bool,
5026}
5027
5028fn hard_reset_align_index_tree_at(
5029    walk: &mut HardResetAlignWalk<'_, '_>,
5030    tree_oid: &ObjectId,
5031    cache_node: Option<&CacheTreeNode>,
5032    dir_prefix: &[u8],
5033) -> Result<()> {
5034    let tree_entries = read_tree(walk.odb, tree_oid)?;
5035    if tree_entries_have_duplicate_names(&tree_entries) {
5036        walk.retry_without_cache = true;
5037        return Ok(());
5038    }
5039    let cache_children = CacheChildren::new(cache_node);
5040    let mut ti = 0usize;
5041
5042    loop {
5043        let te = tree_entries.get(ti);
5044        let ie_kind = peek_index_level_entry(
5045            walk.index,
5046            walk.idx_cursor,
5047            dir_prefix,
5048            walk.ignore_submodules,
5049        );
5050
5051        match (te, ie_kind) {
5052            (Some(t_entry), Some(ie)) => {
5053                let t_name = &t_entry.name;
5054                let (i_name, i_is_tree) = match &ie {
5055                    IndexLevelKind::Blob { name, .. } => (*name, false),
5056                    IndexLevelKind::Tree { name } => (*name, true),
5057                };
5058                let t_is_tree = is_tree_mode(t_entry.mode);
5059                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
5060                match cmp {
5061                    std::cmp::Ordering::Less => {
5062                        hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
5063                        ti += 1;
5064                    }
5065                    std::cmp::Ordering::Greater => {
5066                        hard_reset_plan_index_only(walk, dir_prefix)?;
5067                    }
5068                    std::cmp::Ordering::Equal => {
5069                        if t_is_tree && i_is_tree {
5070                            let full_path = join_index_path(dir_prefix, t_name);
5071                            if hard_reset_skip_sparse_placeholder(walk, &full_path, t_entry)? {
5072                                ti += 1;
5073                                continue;
5074                            }
5075                            let child_cache = cache_children.get(t_name);
5076                            if child_cache
5077                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
5078                            {
5079                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
5080                                ti += 1;
5081                                continue;
5082                            }
5083                            hard_reset_align_index_tree_at(
5084                                walk,
5085                                &t_entry.oid,
5086                                child_cache,
5087                                &full_path,
5088                            )?;
5089                        } else if t_is_tree != i_is_tree {
5090                            hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
5091                            hard_reset_plan_index_only(walk, dir_prefix)?;
5092                        } else {
5093                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
5094                                ti += 1;
5095                                continue;
5096                            };
5097                            *walk.idx_cursor += 1;
5098                            if t_entry.oid != ie.oid
5099                                || crate::index::canon_mode(t_entry.mode)
5100                                    != crate::index::canon_mode(ie.mode)
5101                            {
5102                                walk.plan.updates.push((
5103                                    ie.path.clone(),
5104                                    crate::index::canon_mode(t_entry.mode),
5105                                    t_entry.oid,
5106                                ));
5107                            }
5108                        }
5109                        ti += 1;
5110                    }
5111                }
5112            }
5113            (Some(t_entry), None) => {
5114                hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
5115                ti += 1;
5116            }
5117            (None, Some(_)) => {
5118                hard_reset_plan_index_only(walk, dir_prefix)?;
5119            }
5120            (None, None) => break,
5121        }
5122    }
5123    Ok(())
5124}
5125
5126fn hard_reset_skip_sparse_placeholder(
5127    walk: &mut HardResetAlignWalk<'_, '_>,
5128    full_path: &[u8],
5129    t_entry: &TreeEntry,
5130) -> Result<bool> {
5131    let Some(ie) = walk.index.entries.get(*walk.idx_cursor) else {
5132        return Ok(false);
5133    };
5134    if !ie.is_sparse_directory_placeholder() {
5135        return Ok(false);
5136    }
5137    let mut dir = full_path.to_vec();
5138    if !dir.ends_with(b"/") {
5139        dir.push(b'/');
5140    }
5141    if ie.path != dir || ie.oid != t_entry.oid {
5142        return Ok(false);
5143    }
5144    *walk.idx_cursor += 1;
5145    Ok(true)
5146}
5147
5148fn hard_reset_plan_index_only(
5149    walk: &mut HardResetAlignWalk<'_, '_>,
5150    dir_prefix: &[u8],
5151) -> Result<()> {
5152    let ie_kind = peek_index_level_entry(
5153        walk.index,
5154        walk.idx_cursor,
5155        dir_prefix,
5156        walk.ignore_submodules,
5157    )
5158    .ok_or_else(|| Error::IndexError("missing index entry during hard reset align".into()))?;
5159    match ie_kind {
5160        IndexLevelKind::Blob { entry: ie, .. } => {
5161            walk.plan.remove_paths.push(ie.path.clone());
5162            *walk.idx_cursor += 1;
5163        }
5164        IndexLevelKind::Tree { name } => {
5165            let full_path = join_index_path(dir_prefix, name);
5166            let start = *walk.idx_cursor;
5167            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
5168            for ie in &walk.index.entries[start..*walk.idx_cursor] {
5169                if ie.stage() != 0 || ie.intent_to_add() {
5170                    continue;
5171                }
5172                walk.plan.remove_paths.push(ie.path.clone());
5173            }
5174        }
5175    }
5176    Ok(())
5177}
5178
5179fn hard_reset_plan_tree_only(
5180    walk: &mut HardResetAlignWalk<'_, '_>,
5181    entry: &TreeEntry,
5182    dir_prefix: &[u8],
5183) -> Result<()> {
5184    if walk.ignore_submodules && entry.mode == 0o160000 {
5185        return Ok(());
5186    }
5187    let path = join_index_path(dir_prefix, &entry.name);
5188    if is_tree_mode(entry.mode) {
5189        if hard_reset_sparse_excludes_tree(walk, &path) {
5190            walk.plan
5191                .insert_entries
5192                .push(hard_reset_sparse_directory_row(path, entry.oid));
5193        } else {
5194            hard_reset_append_entries_from_tree(
5195                walk.odb,
5196                &entry.oid,
5197                &path,
5198                &mut walk.plan.insert_entries,
5199            )?;
5200        }
5201    } else {
5202        walk.plan
5203            .insert_entries
5204            .push(hard_reset_index_tree_row(path, entry.mode, entry.oid));
5205    }
5206    Ok(())
5207}
5208
5209fn hard_reset_sparse_excludes_tree(walk: &HardResetAlignWalk<'_, '_>, path: &[u8]) -> bool {
5210    if !walk.index_sparse {
5211        return false;
5212    }
5213    let Some(sparse) = walk.sparse else {
5214        return false;
5215    };
5216    let mut dir = path.to_vec();
5217    if !dir.ends_with(b"/") {
5218        dir.push(b'/');
5219    }
5220    sparse.excludes_directory(&dir)
5221}
5222
5223fn hard_reset_sparse_directory_row(mut path: Vec<u8>, oid: ObjectId) -> IndexEntry {
5224    if !path.ends_with(b"/") {
5225        path.push(b'/');
5226    }
5227    let mut row = hard_reset_index_tree_row(path, MODE_TREE, oid);
5228    row.set_skip_worktree(true);
5229    row
5230}
5231
5232fn hard_reset_index_tree_row(path: Vec<u8>, mode: u32, oid: ObjectId) -> IndexEntry {
5233    IndexEntry {
5234        ctime_sec: 0,
5235        ctime_nsec: 0,
5236        mtime_sec: 0,
5237        mtime_nsec: 0,
5238        dev: 0,
5239        ino: 0,
5240        mode: crate::index::canon_mode(mode),
5241        uid: 0,
5242        gid: 0,
5243        size: 0,
5244        oid,
5245        flags: path.len().min(0xFFF) as u16,
5246        flags_extended: None,
5247        path,
5248        base_index_pos: 0,
5249    }
5250}
5251
5252fn hard_reset_append_entries_from_tree(
5253    odb: &Odb,
5254    tree_oid: &ObjectId,
5255    prefix: &[u8],
5256    out: &mut Vec<IndexEntry>,
5257) -> Result<()> {
5258    for te in read_tree(odb, tree_oid)? {
5259        let mut path = prefix.to_vec();
5260        if !path.is_empty() {
5261            path.push(b'/');
5262        }
5263        path.extend_from_slice(&te.name);
5264        if is_tree_mode(te.mode) {
5265            hard_reset_append_entries_from_tree(odb, &te.oid, &path, out)?;
5266        } else {
5267            out.push(hard_reset_index_tree_row(path, te.mode, te.oid));
5268        }
5269    }
5270    Ok(())
5271}
5272
5273// ── Index-to-worktree diff (unstaged changes) ───────────────────────
5274
5275/// Compare the index against the working tree.
5276///
5277/// This shows "unstaged" changes — modifications not yet staged.
5278///
5279/// Entries with [`IndexEntry::assume_unchanged`] or [`IndexEntry::skip_worktree`] are treated as
5280/// matching the work tree without examining the filesystem (Git `CE_VALID` / skip-worktree).
5281///
5282/// # Parameters
5283///
5284/// - `odb` — object database (for hashing worktree files).
5285/// - `index` — the current index.
5286/// - `work_tree` — path to the working tree root.
5287/// - `ignore_submodule_untracked` — when true, gitlink entries are not dirty solely from untracked
5288///   files inside the submodule (matches `git status -uno`).
5289/// - `simplify_gitlinks` — when true, nested gitlink entries only compare the submodule checkout
5290///   HEAD to the recorded OID (ignore dirty work trees inside nested submodules). Used when
5291///   computing `submodule_porcelain_flags` so untracked files under a nested submodule do not set
5292///   the parent submodule's `modified` bit (Git `DIRTY_SUBMODULE_MODIFIED`; t7506).
5293///
5294/// # Errors
5295///
5296/// Returns errors from I/O or hashing.
5297pub fn diff_index_to_worktree(
5298    odb: &Odb,
5299    index: &mut Index,
5300    work_tree: &Path,
5301    ignore_submodule_untracked: bool,
5302    simplify_gitlinks: bool,
5303) -> Result<Vec<DiffEntry>> {
5304    diff_index_to_worktree_with_options(
5305        odb,
5306        index,
5307        work_tree,
5308        DiffIndexToWorktreeOptions {
5309            ignore_submodule_untracked,
5310            simplify_gitlinks,
5311            ..DiffIndexToWorktreeOptions::default()
5312        },
5313    )
5314    .map(|(entries, _)| entries)
5315}
5316
5317/// Predicate over stored index path bytes for [`DiffIndexToWorktreeOptions::skip_index_path`].
5318pub type IndexPathSkipPredicate = std::sync::Arc<dyn Fn(&[u8]) -> bool + Send + Sync>;
5319
5320/// Additional inputs for [`diff_index_to_worktree_with_options`].
5321#[derive(Clone)]
5322pub struct DiffIndexToWorktreeOptions {
5323    /// Optional index mtime pair `(sec, nsec)` sampled when the index was read.
5324    ///
5325    /// When provided, entries with matching stat data are still considered dirty candidates if
5326    /// their recorded mtime is "racy" (at or after this timestamp), matching Git's
5327    /// `is_racy_timestamp` behavior.
5328    pub index_mtime: Option<(u32, u32)>,
5329    /// When true, gitlink entries are not dirty solely from untracked files inside the submodule.
5330    pub ignore_submodule_untracked: bool,
5331    /// When true, nested gitlink entries only compare the submodule checkout HEAD to the recorded OID.
5332    pub simplify_gitlinks: bool,
5333    /// When true, a populated gitlink checkout whose `.git` indirection cannot resolve to a HEAD
5334    /// is returned as an error instead of a normal modified gitlink.
5335    pub error_on_broken_gitlinks: bool,
5336    /// Repository git directory used to load config (separate git dir / linked worktrees).
5337    ///
5338    /// When unset, defaults to `work_tree/.git`, which is wrong for gitfile layouts.
5339    pub repository_git_dir: Option<std::path::PathBuf>,
5340    /// When true, adopt refreshed stat data for unchanged blobs during the same directory scan
5341    /// as the worktree diff (one lstat per tracked path).
5342    pub refresh_index_stat_in_pass: bool,
5343    /// When set, used instead of loading config from [`Self::repository_git_dir`].
5344    pub config: Option<std::sync::Arc<crate::config::ConfigSet>>,
5345    /// Cap parallel stat workers (`Some(1)` forces serial), also applied to hashing the
5346    /// racily clean entries the stat refresh verifies. When `None`, honors
5347    /// `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
5348    pub stat_parallel_threads: Option<usize>,
5349    /// CLI / embedder thread override applied to stat preload and hashing in this diff.
5350    pub thread_policy: Option<crate::parallel::ThreadPolicy>,
5351    /// Shared per-operation attribute/conversion context (see [`crate::worktree_rules::WorktreeRules`]).
5352    pub worktree_rules:
5353        Option<std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
5354    /// Status-only shortcuts (default `false`).
5355    ///
5356    /// When true, a tracked file whose size differs from its index entry is reported as
5357    /// modified without reading it, with a null worktree `new_oid`, and intent-to-add
5358    /// entries are not hashed. Content diffs must leave this off so a content change always
5359    /// carries a null worktree `new_oid` (`old_oid != new_oid`).
5360    pub for_status: bool,
5361    /// Report a tracked file as modified without reading it when its size differs from the
5362    /// index entry and its clean conversion is the identity (default `false`, implied by
5363    /// [`Self::for_status`]).
5364    ///
5365    /// The entry carries a null worktree `new_oid`, so `old_oid != new_oid` still marks a
5366    /// content change. Set this when the caller reads the worktree file afterwards anyway (a
5367    /// patch or diffstat), so hashing it here would only repeat that read.
5368    pub size_change_proves_modified: bool,
5369    /// When true (default), query `core.fsmonitor` before scanning the worktree.
5370    pub refresh_fsmonitor: bool,
5371    /// Injectable subprocess runner for fsmonitor hooks; defaults to the system runner.
5372    pub command_runner: Option<std::sync::Arc<dyn crate::command_runner::CommandRunner>>,
5373    /// When set, used as the hook protocol v1 query boundary; otherwise [`crate::environment::wall_clock_unix_timestamp_nanos`] is sampled once per diff.
5374    pub fsmonitor_query_boundary: Option<crate::fsmonitor::FsmonitorQueryBoundary>,
5375    /// When set, only index paths in this set are compared against the work tree.
5376    ///
5377    /// Used by branch switch to verify dirty paths only where the tree-to-tree diff would
5378    /// update the worktree, avoiding a full tracked scan on large indexes. Callers must
5379    /// refuse the operation when the index has unmerged entries; this filter does not
5380    /// substitute for a full unmerged scan.
5381    pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
5382    /// When set, index paths for which this returns true are omitted from the worktree diff.
5383    pub skip_index_path: Option<IndexPathSkipPredicate>,
5384    /// When true with staging materialization, compute blob OIDs without writing loose objects.
5385    pub hash_blobs_without_write: bool,
5386    /// When set, long loops abort with [`crate::error::Error::Cancelled`] once the flag is raised
5387    /// (see [`crate::progress::cancel_watch_arc`] and [`crate::progress::poll_cancel_watch`]).
5388    pub cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
5389    /// When set, work-tree access failures during status emit [`crate::diagnostics::Warning`]
5390    /// values here instead of aborting the diff.
5391    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
5392    /// When true with [`Self::diagnostics`], permission-denied worktree paths warn and are omitted
5393    /// from the diff (not reported as deleted). Used by content diffs and stash; status sets
5394    /// [`Self::for_status`] instead.
5395    pub skip_inaccessible_paths: bool,
5396}
5397
5398impl Default for DiffIndexToWorktreeOptions {
5399    fn default() -> Self {
5400        Self {
5401            index_mtime: None,
5402            ignore_submodule_untracked: false,
5403            simplify_gitlinks: false,
5404            error_on_broken_gitlinks: false,
5405            repository_git_dir: None,
5406            refresh_index_stat_in_pass: false,
5407            config: None,
5408            stat_parallel_threads: None,
5409            thread_policy: None,
5410            worktree_rules: None,
5411            for_status: false,
5412            size_change_proves_modified: false,
5413            refresh_fsmonitor: true,
5414            command_runner: None,
5415            fsmonitor_query_boundary: None,
5416            restrict_to_paths: None,
5417            skip_index_path: None,
5418            hash_blobs_without_write: false,
5419            cancel_watch: None,
5420            diagnostics: None,
5421            skip_inaccessible_paths: false,
5422        }
5423    }
5424}
5425
5426/// Optional worktree access policy for [`diff_tree_to_worktree_with_git_dir_and_rules`]'s slow path.
5427#[derive(Clone, Default)]
5428pub struct DiffTreeToWorktreeExtras {
5429    /// When true with [`Self::diagnostics`], skip permission-denied paths with a warning.
5430    pub skip_inaccessible_paths: bool,
5431    /// Warning sink paired with [`Self::skip_inaccessible_paths`].
5432    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
5433    /// Limit the walk to these index paths (expanded pathspecs).
5434    pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
5435}
5436
5437#[inline]
5438fn worktree_diff_skips_inaccessible_paths(
5439    for_status: bool,
5440    skip_inaccessible_paths: bool,
5441    materialize_dirty_blobs: bool,
5442) -> bool {
5443    (for_status || skip_inaccessible_paths) && !materialize_dirty_blobs
5444}
5445
5446#[inline]
5447fn poll_diff_cancel_watch(
5448    watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
5449) -> crate::error::Result<()> {
5450    if let Some(flag) = watch {
5451        crate::progress::poll_watch(flag)?;
5452    }
5453    Ok(())
5454}
5455
5456#[inline]
5457fn poll_diff_cancel(
5458    options: &DiffIndexToWorktreeOptions,
5459    owner_poll: Option<&std::sync::Arc<crate::progress::StackCancelPoll>>,
5460) -> crate::error::Result<()> {
5461    if let Some(poll) = owner_poll {
5462        poll.poll_owner()?;
5463    } else {
5464        poll_diff_cancel_watch(options.cancel_watch.as_ref())?;
5465    }
5466    Ok(())
5467}
5468
5469/// One tracked file the parallel pass must hash, queued once per stat-dirty index entry.
5470///
5471/// A stale index can queue every entry, so the job names its entry by position instead of
5472/// owning copies of it, and the workers rebuild paths from the shared index.
5473struct StagingHashJob {
5474    entry_index: usize,
5475    /// Worktree mode after [`crate::index::WorktreeModePolicy`] against the entry's mode.
5476    worktree_mode: u32,
5477    /// `None` when the path has default attributes, which is the common case.
5478    file_attrs: Option<Box<crate::crlf::FileAttrs>>,
5479}
5480
5481impl StagingHashJob {
5482    fn new(entry_index: usize, worktree_mode: u32, file_attrs: crate::crlf::FileAttrs) -> Self {
5483        let file_attrs =
5484            (file_attrs != crate::crlf::FileAttrs::default()).then(|| Box::new(file_attrs));
5485        Self {
5486            entry_index,
5487            worktree_mode,
5488            file_attrs,
5489        }
5490    }
5491}
5492
5493/// Where the parallel pass finds the index entries and files its jobs name.
5494struct StagingHashSource<'a> {
5495    index: &'a Index,
5496    work_tree: &'a Path,
5497    precompose_unicode: bool,
5498    ignorecase: bool,
5499}
5500
5501#[allow(clippy::too_many_arguments)]
5502fn parallel_process_blob_worktree_jobs(
5503    odb: &Odb,
5504    source: &StagingHashSource<'_>,
5505    jobs: &[StagingHashJob],
5506    thread_policy: &crate::parallel::ThreadPolicy,
5507    config: &crate::config::ConfigSet,
5508    materialize_dirty_blobs: bool,
5509    hash_blobs_without_write: bool,
5510    for_status: bool,
5511    skip_inaccessible_paths: bool,
5512    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5513    conv: &crate::crlf::ConversionConfig,
5514    filter_process: Option<&crate::filter_process::FilterProcessState>,
5515    _icase_collisions: Option<&crate::path_icase::IcaseCheckoutCollisionMap>,
5516    result: &mut Vec<DiffEntry>,
5517) -> Result<()> {
5518    use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
5519
5520    if jobs.is_empty() {
5521        return Ok(());
5522    }
5523    let mut par_opts = thread_policy.resolve(ParallelOp::IndexRefresh, config);
5524    par_opts.min_items = 0;
5525    par_opts.min_total_bytes = None;
5526    let est_bytes = jobs.len().saturating_mul(8192);
5527    let store_dirty_blobs = (materialize_dirty_blobs && !hash_blobs_without_write)
5528        .then(|| odb.bulk_stage_write_options());
5529    let stream_from = if store_dirty_blobs.is_some() {
5530        odb.big_file_threshold()?
5531    } else {
5532        hash_stream_threshold(odb)?
5533    };
5534    let default_attrs = crate::crlf::FileAttrs::default();
5535
5536    // Each job yields only the worktree side's oid when the entry changed; most jobs on a
5537    // stale index find clean files, so diff entries are built afterwards for the few changes.
5538    let new_oids = try_par_map::<StagingHashJob, Option<ObjectId>, Error, _>(
5539        jobs,
5540        par_opts,
5541        est_bytes,
5542        |job| {
5543            let entry = &source.index.entries[job.entry_index];
5544            let rel_path = crate::repo_relpath::storage_string(&entry.path);
5545            let modified = |new_oid| Ok(Some(new_oid));
5546            let file_path = index_entry_worktree_abs_bytes(
5547                source.work_tree,
5548                &entry.path,
5549                source.precompose_unicode,
5550                source.ignorecase,
5551            );
5552            let content = match read_regular_worktree_blob(
5553                odb,
5554                &file_path,
5555                conv,
5556                job.file_attrs.as_deref().unwrap_or(&default_attrs),
5557                &rel_path,
5558                Some(entry),
5559                filter_process,
5560                stream_from,
5561            ) {
5562                Ok(content) => content,
5563                Err(err)
5564                    if status_skips_inaccessible_worktree_error(
5565                        &err,
5566                        for_status,
5567                        skip_inaccessible_paths,
5568                        materialize_dirty_blobs,
5569                    ) =>
5570                {
5571                    warn_and_skip_status_inaccessible(diagnostics, rel_path.as_str(), &err);
5572                    return Ok(None);
5573                }
5574                Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
5575                    return modified(zero_oid());
5576                }
5577                Err(err) => return Err(err),
5578            };
5579            let eff_oid = content.object_id(odb, store_dirty_blobs)?;
5580            if eff_oid != entry.oid || job.worktree_mode != entry.mode {
5581                let new_oid = if eff_oid != entry.oid && !materialize_dirty_blobs {
5582                    zero_oid()
5583                } else {
5584                    eff_oid
5585                };
5586                modified(new_oid)
5587            } else {
5588                Ok(None)
5589            }
5590        },
5591    )
5592    .map_err(|e| match e {
5593        ParallelHashError::Task(err) => err,
5594    })?;
5595
5596    let mut changed: Vec<(usize, u32, ObjectId)> = jobs
5597        .iter()
5598        .zip(new_oids)
5599        .filter_map(|(job, new_oid)| new_oid.map(|oid| (job.entry_index, job.worktree_mode, oid)))
5600        .collect();
5601    changed.sort_by_key(|(idx, _, _)| *idx);
5602    result.extend(
5603        changed
5604            .into_iter()
5605            .map(|(entry_index, worktree_mode, new_oid)| {
5606                let entry = &source.index.entries[entry_index];
5607                let rel_path = crate::repo_relpath::storage_string(&entry.path);
5608                worktree_content_change_entry(entry, &rel_path, worktree_mode, new_oid)
5609            }),
5610    );
5611    Ok(())
5612}
5613
5614/// Index-vs-worktree entry for a tracked path whose content or mode differs from `ie`.
5615fn worktree_content_change_entry(
5616    ie: &IndexEntry,
5617    path: &str,
5618    worktree_mode: u32,
5619    new_oid: ObjectId,
5620) -> DiffEntry {
5621    DiffEntry {
5622        status: DiffStatus::for_content_change(ie.mode, worktree_mode),
5623        old_path: Some(path.to_owned()),
5624        new_path: Some(path.to_owned()),
5625        old_mode: FileMode::new(ie.mode),
5626        new_mode: FileMode::new(worktree_mode),
5627        old_oid: ie.oid,
5628        new_oid,
5629        score: None,
5630    }
5631}
5632
5633/// Whether a failed read of a tracked file's content reports the path as modified (with an
5634/// unknown worktree oid) instead of failing the whole scan.
5635///
5636/// A file that cannot be opened (no permission, held exclusively by another process) cannot
5637/// be proven to match the index, so it counts as changed, as in Git. Callers that need the
5638/// bytes themselves (`materialize_dirty_blobs`, i.e. staging) still get the error.
5639fn unreadable_worktree_blob_is_modified(err: &Error, materialize_dirty_blobs: bool) -> bool {
5640    !materialize_dirty_blobs
5641        && matches!(
5642            err,
5643            Error::WorktreePathIo {
5644                operation: crate::worktree::WorktreePathOperation::Read,
5645                ..
5646            }
5647        )
5648}
5649
5650fn warn_worktree_access_failed(
5651    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5652    path: &str,
5653    err: &std::io::Error,
5654) {
5655    if let Some(handle) = diagnostics {
5656        crate::diagnostics::warn(
5657            handle,
5658            crate::diagnostics::Warning::WorktreeAccessFailed {
5659                path: path.to_owned(),
5660                detail: err.to_string(),
5661            },
5662        );
5663    }
5664}
5665
5666fn blob_disk_lookup_io_result(
5667    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5668    path: &str,
5669    err: std::io::Error,
5670    for_status: bool,
5671    skip_inaccessible_paths: bool,
5672    materialize_dirty_blobs: bool,
5673) -> Result<()> {
5674    if worktree_diff_skips_inaccessible_paths(
5675        for_status,
5676        skip_inaccessible_paths,
5677        materialize_dirty_blobs,
5678    ) && io_err_is_permission_denied(&err)
5679    {
5680        warn_worktree_access_failed(diagnostics, path, &err);
5681        return Ok(());
5682    }
5683    Err(Error::WorktreePathIo {
5684        operation: crate::worktree::WorktreePathOperation::Metadata,
5685        path: path.to_owned(),
5686        source: err,
5687    })
5688}
5689
5690fn io_err_is_permission_denied(err: &std::io::Error) -> bool {
5691    err.kind() == std::io::ErrorKind::PermissionDenied
5692}
5693
5694fn status_skips_inaccessible_worktree_error(
5695    err: &Error,
5696    for_status: bool,
5697    skip_inaccessible_paths: bool,
5698    materialize_dirty_blobs: bool,
5699) -> bool {
5700    if !worktree_diff_skips_inaccessible_paths(
5701        for_status,
5702        skip_inaccessible_paths,
5703        materialize_dirty_blobs,
5704    ) {
5705        return false;
5706    }
5707    match err {
5708        Error::Io(e) => io_err_is_permission_denied(e),
5709        Error::WorktreePathIo {
5710            operation: crate::worktree::WorktreePathOperation::Metadata,
5711            source,
5712            ..
5713        } => io_err_is_permission_denied(source),
5714        _ => false,
5715    }
5716}
5717
5718fn warn_and_skip_status_inaccessible(
5719    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5720    path: &str,
5721    err: &Error,
5722) -> bool {
5723    let detail = match err {
5724        Error::Io(e) => e.to_string(),
5725        Error::WorktreePathIo { source, .. } => source.to_string(),
5726        _ => return false,
5727    };
5728    if let Some(handle) = diagnostics {
5729        crate::diagnostics::warn(
5730            handle,
5731            crate::diagnostics::Warning::WorktreeAccessFailed {
5732                path: path.to_owned(),
5733                detail,
5734            },
5735        );
5736    }
5737    true
5738}
5739
5740enum WorktreeSymlinkMetadata {
5741    Found(fs::Metadata),
5742    Absent,
5743    Inaccessible,
5744}
5745
5746fn probe_worktree_symlink_metadata(
5747    file_path: &Path,
5748    path_str: &str,
5749    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5750    for_status: bool,
5751    skip_inaccessible_paths: bool,
5752    materialize_dirty_blobs: bool,
5753) -> Result<WorktreeSymlinkMetadata> {
5754    match fs::symlink_metadata(file_path) {
5755        Ok(meta) => Ok(WorktreeSymlinkMetadata::Found(meta)),
5756        Err(e)
5757            if e.kind() == std::io::ErrorKind::NotFound
5758                || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5759        {
5760            Ok(WorktreeSymlinkMetadata::Absent)
5761        }
5762        Err(e) => {
5763            if worktree_diff_skips_inaccessible_paths(
5764                for_status,
5765                skip_inaccessible_paths,
5766                materialize_dirty_blobs,
5767            ) && io_err_is_permission_denied(&e)
5768            {
5769                warn_worktree_access_failed(diagnostics, path_str, &e);
5770                Ok(WorktreeSymlinkMetadata::Inaccessible)
5771            } else {
5772                Err(Error::WorktreePathIo {
5773                    operation: crate::worktree::WorktreePathOperation::Metadata,
5774                    path: path_str.to_owned(),
5775                    source: e,
5776                })
5777            }
5778        }
5779    }
5780}
5781
5782fn symlink_metadata_worktree_diff(
5783    file_path: &Path,
5784    path_str: &str,
5785    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5786    for_status: bool,
5787    skip_inaccessible_paths: bool,
5788    materialize_dirty_blobs: bool,
5789) -> Result<Option<fs::Metadata>> {
5790    Ok(
5791        match probe_worktree_symlink_metadata(
5792            file_path,
5793            path_str,
5794            diagnostics,
5795            for_status,
5796            skip_inaccessible_paths,
5797            materialize_dirty_blobs,
5798        )? {
5799            WorktreeSymlinkMetadata::Found(meta) => Some(meta),
5800            WorktreeSymlinkMetadata::Absent | WorktreeSymlinkMetadata::Inaccessible => None,
5801        },
5802    )
5803}
5804
5805/// Whether status may report `ie` modified because the worktree size (already munged to the
5806/// index's 32-bit form) differs from the cached one, without reading the file (Git
5807/// `compare_stat_data`).
5808///
5809/// Only sound when clean conversion is the identity: a line-ending rule, filter, encoding or
5810/// `ident` can turn a file of another size into exactly the indexed blob. The
5811/// `conversion_is_identity` check runs only once the sizes differ, so clean entries never
5812/// resolve attributes here.
5813fn status_size_change_proves_modified(
5814    ie: &IndexEntry,
5815    worktree_size: u32,
5816    for_status: bool,
5817    conversion_is_identity: impl FnOnce() -> bool,
5818) -> bool {
5819    for_status && !ie.stat_cache_unset() && worktree_size != ie.size && conversion_is_identity()
5820}
5821
5822/// Report `ie` modified when [`status_size_change_proves_modified`] holds; returns whether it
5823/// did. The worktree side uses the null OID because the file was not hashed.
5824#[allow(clippy::too_many_arguments)]
5825fn try_push_status_modified_on_size_mismatch(
5826    odb: &Odb,
5827    ie: &IndexEntry,
5828    path_str_ref: &str,
5829    worktree_size: u32,
5830    worktree_mode: u32,
5831    raw_worktree_len: u64,
5832    for_status: bool,
5833    conversion_is_identity: impl FnOnce() -> bool,
5834    result: &mut Vec<DiffEntry>,
5835) -> Result<bool> {
5836    let identity = conversion_is_identity();
5837    let size_differs = worktree_size != ie.size;
5838    if !size_differs || !identity {
5839        return Ok(false);
5840    }
5841    let threshold = odb.big_file_threshold()?;
5842    let big_file = raw_worktree_len > threshold;
5843    let proves_modified = if big_file {
5844        true
5845    } else {
5846        status_size_change_proves_modified(ie, worktree_size, for_status, || identity)
5847    };
5848    if !proves_modified {
5849        return Ok(false);
5850    }
5851    result.push(worktree_content_change_entry(
5852        ie,
5853        path_str_ref,
5854        worktree_mode,
5855        zero_oid(),
5856    ));
5857    Ok(true)
5858}
5859
5860#[allow(clippy::too_many_arguments)]
5861fn push_index_blob_worktree_diff(
5862    odb: &Odb,
5863    ie: &IndexEntry,
5864    path_str_ref: &str,
5865    file_path: &Path,
5866    meta: fs::Metadata,
5867    index_mtime: Option<(u32, u32)>,
5868    mode_policy: crate::index::WorktreeModePolicy,
5869    stat_policy: crate::index::StatMatchPolicy,
5870    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
5871    conv: &crate::crlf::ConversionConfig,
5872    materialize_dirty_blobs: bool,
5873    hash_blobs_without_write: bool,
5874    for_status: bool,
5875    size_change_proves_modified: bool,
5876    skip_inaccessible_paths: bool,
5877    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5878    filter_process: Option<&crate::filter_process::FilterProcessState>,
5879    icase_collisions: Option<&crate::path_icase::IcaseCheckoutCollisionMap>,
5880    result: &mut Vec<DiffEntry>,
5881) -> Result<()> {
5882    if meta.is_dir() {
5883        if file_path.join(".git").exists() {
5884            let head = read_submodule_head_oid(file_path).unwrap_or_else(zero_oid);
5885            let path_owned = path_str_ref.to_owned();
5886            result.push(DiffEntry {
5887                status: DiffStatus::TypeChanged,
5888                old_path: Some(path_owned.clone()),
5889                new_path: Some(path_owned),
5890                old_mode: FileMode::new(ie.mode),
5891                new_mode: FileMode::new(0o160000),
5892                old_oid: ie.oid,
5893                new_oid: head,
5894                score: None,
5895            });
5896            return Ok(());
5897        }
5898        result.push(DiffEntry {
5899            status: DiffStatus::Deleted,
5900            old_path: Some(path_str_ref.to_owned()),
5901            new_path: None,
5902            old_mode: FileMode::new(ie.mode),
5903            new_mode: FileMode::ABSENT,
5904            old_oid: ie.oid,
5905            new_oid: zero_oid(),
5906            score: None,
5907        });
5908        return Ok(());
5909    }
5910
5911    let worktree_mode =
5912        mode_policy.index_mode_for_worktree(mode_from_metadata(&meta), Some(ie.mode));
5913    let stat_same = stat_matches_with_policy(ie, &meta, stat_policy);
5914    if stat_same && worktree_mode != ie.mode {
5915        result.push(worktree_content_change_entry(
5916            ie,
5917            path_str_ref,
5918            worktree_mode,
5919            ie.oid,
5920        ));
5921        return Ok(());
5922    }
5923
5924    if stat_same
5925        && index_entry_skips_worktree_content_hash_after_stat_match(ie, index_mtime, stat_policy)
5926    {
5927        return Ok(());
5928    }
5929
5930    let file_attrs = file_attrs_for(path_str_ref);
5931    if !icase_collisions.is_some_and(|map| map.diff_requires_worktree_hash(ie.path.as_slice()))
5932        && try_push_status_modified_on_size_mismatch(
5933            odb,
5934            ie,
5935            path_str_ref,
5936            crate::index::munge_index_size(meta.len()),
5937            worktree_mode,
5938            meta.len(),
5939            size_change_proves_modified,
5940            || crate::crlf::git_conversion_is_identity(conv, &file_attrs),
5941            result,
5942        )?
5943    {
5944        return Ok(());
5945    }
5946
5947    let store = (materialize_dirty_blobs && !hash_blobs_without_write)
5948        .then(|| odb.bulk_stage_write_options());
5949    let stream_from = if store.is_some() {
5950        odb.big_file_threshold()?
5951    } else {
5952        hash_stream_threshold(odb)?
5953    };
5954    let content = match worktree_blob_content_from(
5955        odb,
5956        file_path,
5957        &meta,
5958        conv,
5959        &file_attrs,
5960        path_str_ref,
5961        Some(ie),
5962        filter_process,
5963        stream_from,
5964    ) {
5965        Ok(content) => content,
5966        Err(err)
5967            if status_skips_inaccessible_worktree_error(
5968                &err,
5969                for_status,
5970                skip_inaccessible_paths,
5971                materialize_dirty_blobs,
5972            ) =>
5973        {
5974            warn_and_skip_status_inaccessible(diagnostics, path_str_ref, &err);
5975            return Ok(());
5976        }
5977        Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
5978            result.push(worktree_content_change_entry(
5979                ie,
5980                path_str_ref,
5981                worktree_mode,
5982                zero_oid(),
5983            ));
5984            return Ok(());
5985        }
5986        Err(err) => return Err(err),
5987    };
5988    let eff_oid = content.object_id(odb, store)?;
5989
5990    if eff_oid != ie.oid || worktree_mode != ie.mode {
5991        // `git diff-files --raw` uses the null OID for the worktree side when content differs.
5992        // Staging (`diff_index_to_worktree_for_staging`) materializes blobs and needs the real OID.
5993        let new_oid = if eff_oid != ie.oid && !materialize_dirty_blobs {
5994            zero_oid()
5995        } else {
5996            eff_oid
5997        };
5998        result.push(worktree_content_change_entry(
5999            ie,
6000            path_str_ref,
6001            worktree_mode,
6002            new_oid,
6003        ));
6004    }
6005    Ok(())
6006}
6007
6008/// Compare tracked index entries with the worktree, using link-target bytes for symlinks.
6009///
6010/// Symlink targets bypass regular-file filters and line-ending conversion. Parallel and
6011/// serial scans use the same file-type classification, including directory replacements
6012/// and regular-file placeholders checked out with `core.symlinks=false`.
6013///
6014/// # Errors
6015///
6016/// Returns errors from I/O or hashing.
6017pub fn diff_index_to_worktree_with_options(
6018    odb: &Odb,
6019    index: &mut Index,
6020    work_tree: &Path,
6021    options: DiffIndexToWorktreeOptions,
6022) -> Result<(Vec<DiffEntry>, bool)> {
6023    let default_git_dir = work_tree.join(".git");
6024    let git_dir = options
6025        .repository_git_dir
6026        .clone()
6027        .unwrap_or(default_git_dir);
6028    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false, None)
6029        .map(|(entries, changed, _)| (entries, changed))
6030}
6031
6032/// Like [`diff_index_to_worktree_with_options`], with an owner-thread progress poll hook for
6033/// long stat scans (used by [`crate::porcelain::status::status`]).
6034pub(crate) fn diff_index_to_worktree_with_owner_poll(
6035    odb: &Odb,
6036    index: &mut Index,
6037    work_tree: &Path,
6038    options: DiffIndexToWorktreeOptions,
6039    owner_poll: Option<std::sync::Arc<crate::progress::StackCancelPoll>>,
6040) -> Result<(Vec<DiffEntry>, bool)> {
6041    let default_git_dir = work_tree.join(".git");
6042    let git_dir = options
6043        .repository_git_dir
6044        .clone()
6045        .unwrap_or(default_git_dir);
6046    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false, owner_poll)
6047        .map(|(entries, changed, _)| (entries, changed))
6048}
6049
6050/// Like [`diff_index_to_worktree_with_options`], but loads config from `repository_git_dir`
6051/// (the main repository for linked worktrees) and persists each hashed worktree blob into `odb`
6052/// so staging can update the index without re-reading file contents.
6053/// Symlinks store their target bytes. With [`DiffIndexToWorktreeOptions::hash_blobs_without_write`],
6054/// returns the same blob IDs without writing objects, for staging previews.
6055pub fn diff_index_to_worktree_for_staging(
6056    odb: &Odb,
6057    repository_git_dir: &Path,
6058    index: &mut Index,
6059    work_tree: &Path,
6060    options: DiffIndexToWorktreeOptions,
6061) -> Result<(
6062    Vec<DiffEntry>,
6063    bool,
6064    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
6065)> {
6066    diff_index_to_worktree_inner(
6067        odb,
6068        index,
6069        work_tree,
6070        repository_git_dir,
6071        options,
6072        true,
6073        None,
6074    )
6075}
6076
6077/// First pass of [`diff_index_to_worktree_inner`]: gitlinks and intent-to-add paths only.
6078#[allow(clippy::too_many_arguments)]
6079fn worktree_diff_pass1_index_entry(
6080    odb: &Odb,
6081    work_tree: &Path,
6082    ie: &IndexEntry,
6083    path_str_ref: &str,
6084    simplify_gitlinks: bool,
6085    ignore_submodule_untracked: bool,
6086    error_on_broken_gitlinks: bool,
6087    materialize_dirty_blobs: bool,
6088    filter_fp: Option<&crate::filter_process::FilterProcessState>,
6089    conv: &crate::crlf::ConversionConfig,
6090    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
6091    precompose_unicode: bool,
6092    ignorecase: bool,
6093    result: &mut Vec<DiffEntry>,
6094) -> Result<()> {
6095    if ie.skip_worktree() || ie.assume_unchanged() {
6096        return Ok(());
6097    }
6098    let is_intent_to_add = ie.intent_to_add();
6099
6100    if ie.mode == 0o160000 {
6101        let sub_dir =
6102            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
6103        match fs::symlink_metadata(&sub_dir) {
6104            Err(e)
6105                if e.kind() == std::io::ErrorKind::NotFound
6106                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
6107            {
6108                if !simplify_gitlinks {
6109                    let path_owned = path_str_ref.to_owned();
6110                    result.push(DiffEntry {
6111                        status: DiffStatus::Deleted,
6112                        old_path: Some(path_owned.clone()),
6113                        new_path: Some(path_owned),
6114                        old_mode: FileMode::new(ie.mode),
6115                        new_mode: FileMode::ABSENT,
6116                        old_oid: ie.oid,
6117                        new_oid: zero_oid(),
6118                        score: None,
6119                    });
6120                }
6121                return Ok(());
6122            }
6123            Err(e) => return Err(Error::Io(e)),
6124            Ok(meta) if !meta.is_dir() => {
6125                let worktree_mode = mode_from_metadata(&meta);
6126                let file_attrs = file_attrs_for(path_str_ref);
6127                let worktree_oid = worktree_file_oid(
6128                    odb,
6129                    &sub_dir,
6130                    &meta,
6131                    conv,
6132                    &file_attrs,
6133                    path_str_ref,
6134                    None,
6135                    materialize_dirty_blobs,
6136                    filter_fp,
6137                )?;
6138                let path_owned = path_str_ref.to_owned();
6139                result.push(DiffEntry {
6140                    status: DiffStatus::TypeChanged,
6141                    old_path: Some(path_owned.clone()),
6142                    new_path: Some(path_owned),
6143                    old_mode: FileMode::new(ie.mode),
6144                    new_mode: FileMode::new(worktree_mode),
6145                    old_oid: ie.oid,
6146                    new_oid: worktree_oid,
6147                    score: None,
6148                });
6149                return Ok(());
6150            }
6151            Ok(_) => {}
6152        }
6153        let sub_head_oid = read_submodule_head_oid(&sub_dir);
6154        let ref_matches = if let Some(oid) = sub_head_oid {
6155            oid == ie.oid
6156        } else {
6157            if error_on_broken_gitlinks && submodule_embedded_git_dir(&sub_dir).is_some() {
6158                return Err(Error::Config(
6159                    format!("could not read submodule HEAD for '{path_str_ref}'").into(),
6160                ));
6161            }
6162            uninitialized_gitlink_worktree_matches_index(&sub_dir)
6163        };
6164        if simplify_gitlinks {
6165            if !ref_matches {
6166                let path_owned = path_str_ref.to_owned();
6167                let new_oid = sub_head_oid.unwrap_or_else(zero_oid);
6168                result.push(DiffEntry {
6169                    status: DiffStatus::Modified,
6170                    old_path: Some(path_owned.clone()),
6171                    new_path: Some(path_owned),
6172                    old_mode: FileMode::new(ie.mode),
6173                    new_mode: FileMode::new(ie.mode),
6174                    old_oid: ie.oid,
6175                    new_oid,
6176                    score: None,
6177                });
6178            }
6179            return Ok(());
6180        }
6181        if sub_head_oid.is_some() && submodule_head_object_broken(&sub_dir) {
6182            return Err(Error::Config(
6183                format!("'git status --porcelain=2' failed in submodule {path_str_ref}").into(),
6184            ));
6185        }
6186        let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
6187        if ignore_submodule_untracked {
6188            flags.untracked = false;
6189        }
6190        let inner_dirty = flags.modified || flags.untracked;
6191        if !ref_matches || inner_dirty {
6192            let path_owned = path_str_ref.to_owned();
6193            let new_oid = if !ref_matches {
6194                sub_head_oid.unwrap_or_else(zero_oid)
6195            } else {
6196                zero_oid()
6197            };
6198            result.push(DiffEntry {
6199                status: DiffStatus::Modified,
6200                old_path: Some(path_owned.clone()),
6201                new_path: Some(path_owned),
6202                old_mode: FileMode::new(ie.mode),
6203                new_mode: FileMode::new(ie.mode),
6204                old_oid: ie.oid,
6205                new_oid,
6206                score: None,
6207            });
6208        }
6209        return Ok(());
6210    }
6211
6212    let file_path =
6213        index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
6214    let path_match = crate::repo_relpath::as_utf8_for_matching(path_str_ref);
6215
6216    if is_intent_to_add {
6217        match fs::symlink_metadata(&file_path) {
6218            Ok(meta) => {
6219                let file_attrs = file_attrs_for(path_match.as_ref());
6220                let worktree_oid = worktree_file_oid(
6221                    odb,
6222                    &file_path,
6223                    &meta,
6224                    conv,
6225                    &file_attrs,
6226                    path_match.as_ref(),
6227                    None,
6228                    materialize_dirty_blobs,
6229                    filter_fp,
6230                )?;
6231                let worktree_mode = mode_from_metadata(&meta);
6232                result.push(DiffEntry {
6233                    status: DiffStatus::Added,
6234                    old_path: None,
6235                    new_path: Some(path_str_ref.to_owned()),
6236                    old_mode: FileMode::ABSENT,
6237                    new_mode: FileMode::new(worktree_mode),
6238                    old_oid: zero_oid(),
6239                    new_oid: worktree_oid,
6240                    score: None,
6241                });
6242            }
6243            Err(e)
6244                if e.kind() == std::io::ErrorKind::NotFound
6245                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
6246            {
6247                result.push(DiffEntry {
6248                    status: DiffStatus::Deleted,
6249                    old_path: Some(path_str_ref.to_owned()),
6250                    new_path: None,
6251                    old_mode: FileMode::new(ie.mode),
6252                    new_mode: FileMode::ABSENT,
6253                    old_oid: ie.oid,
6254                    new_oid: zero_oid(),
6255                    score: None,
6256                });
6257            }
6258            Err(e) => return Err(Error::Io(e)),
6259        }
6260    }
6261    Ok(())
6262}
6263
6264#[inline]
6265fn index_entry_needs_worktree_diff_pass1(ie: &IndexEntry) -> bool {
6266    ie.mode == crate::index::MODE_GITLINK || ie.intent_to_add()
6267}
6268
6269fn diff_index_to_worktree_inner(
6270    odb: &Odb,
6271    index: &mut Index,
6272    work_tree: &Path,
6273    repository_git_dir: &Path,
6274    options: DiffIndexToWorktreeOptions,
6275    materialize_dirty_blobs: bool,
6276    owner_poll: Option<std::sync::Arc<crate::progress::StackCancelPoll>>,
6277) -> Result<(
6278    Vec<DiffEntry>,
6279    bool,
6280    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
6281)> {
6282    use crate::config::ConfigSet;
6283    use crate::crlf;
6284    use std::collections::BTreeMap;
6285
6286    let mut staging_stat_cache = BTreeMap::new();
6287    poll_diff_cancel(&options, owner_poll.as_ref())?;
6288    let index_mtime = index_mtime_for_diff(index, options.index_mtime);
6289
6290    let ignore_submodule_untracked = options.ignore_submodule_untracked;
6291    let simplify_gitlinks = options.simplify_gitlinks;
6292    // Staging stores the worktree blob, so it always needs the file's object id.
6293    let size_change_proves_modified =
6294        !materialize_dirty_blobs && (options.for_status || options.size_change_proves_modified);
6295
6296    let rules_lock = options
6297        .worktree_rules
6298        .as_ref()
6299        .map(|shared| {
6300            shared
6301                .lock()
6302                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
6303        })
6304        .transpose()?;
6305    let config = if let Some(rules) = rules_lock.as_ref() {
6306        rules.config().clone()
6307    } else if let Some(c) = options.config.as_ref() {
6308        c.as_ref().clone()
6309    } else {
6310        ConfigSet::load(
6311            &crate::environment::Environment::empty(),
6312            Some(repository_git_dir),
6313            true,
6314        )
6315        .unwrap_or_else(|_| ConfigSet::new())
6316    };
6317    let conv = rules_lock
6318        .as_ref()
6319        .map(|r| r.conversion().clone())
6320        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
6321    let legacy_attrs = if rules_lock.is_none() {
6322        Some(crlf::load_gitattributes_parsed(work_tree))
6323    } else {
6324        None
6325    };
6326    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
6327        if let Some(rules) = rules_lock.as_ref() {
6328            rules.file_attrs(path, false)
6329        } else if let Some(parsed) = legacy_attrs.as_ref() {
6330            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
6331        } else {
6332            crlf::FileAttrs::default()
6333        }
6334    };
6335    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
6336    let precompose_unicode = config
6337        .get_bool("core.precomposeunicode")
6338        .and_then(|r| r.ok())
6339        .unwrap_or(false);
6340    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
6341    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
6342    let ignorecase = config
6343        .get_bool("core.ignorecase")
6344        .and_then(|r| r.ok())
6345        .unwrap_or(false);
6346    let icase_collisions = ignorecase
6347        .then(|| crate::path_icase::IcaseCheckoutCollisionMap::from_index(work_tree, index, true));
6348    let hash_blobs_without_write = options.hash_blobs_without_write;
6349
6350    let mut result = Vec::new();
6351    let refresh_index_stat_in_pass = options.refresh_index_stat_in_pass;
6352    let mut index_stat_refresh_changed = false;
6353    let mut fsmonitor_valid_updated = false;
6354    let mut honor_fsmonitor_valid = false;
6355    if options.refresh_fsmonitor {
6356        let default_runner = crate::command_runner::system_command_runner();
6357        let runner = options
6358            .command_runner
6359            .as_deref()
6360            .unwrap_or(default_runner.as_ref());
6361        let boundary = options.fsmonitor_query_boundary.unwrap_or_else(|| {
6362            crate::fsmonitor::FsmonitorQueryBoundary(
6363                crate::environment::wall_clock_unix_timestamp_nanos(),
6364            )
6365        });
6366        let refresh_opts = crate::fsmonitor::FsmonitorRefreshOptions::with_query_boundary(boundary);
6367        let refresh = crate::fsmonitor::refresh_index(
6368            index,
6369            repository_git_dir,
6370            work_tree,
6371            &config,
6372            Some(runner),
6373            &refresh_opts,
6374        )?;
6375        if refresh.index_changed {
6376            index_stat_refresh_changed = true;
6377        }
6378        honor_fsmonitor_valid = refresh.honor_valid_bits;
6379    }
6380    let use_fsmonitor_stat_skip = honor_fsmonitor_valid;
6381    let mut unmerged_base: std::collections::BTreeMap<String, (u8, IndexEntry)> =
6382        std::collections::BTreeMap::new();
6383
6384    let error_on_broken_gitlinks = options.error_on_broken_gitlinks;
6385    if options.restrict_to_paths.is_none() {
6386        for ie in &index.entries {
6387            poll_diff_cancel(&options, owner_poll.as_ref())?;
6388            if ie.stage() != 0 {
6389                let path = String::from_utf8_lossy(&ie.path).to_string();
6390                let rank = match ie.stage() {
6391                    2 => 0u8,
6392                    3 => 1u8,
6393                    1 => 2u8,
6394                    _ => 3u8,
6395                };
6396                match unmerged_base.get(&path) {
6397                    Some((existing_rank, _)) if *existing_rank <= rank => {}
6398                    _ => {
6399                        unmerged_base.insert(path, (rank, ie.clone()));
6400                    }
6401                }
6402            }
6403        }
6404    }
6405
6406    if let Some(limit) = options.restrict_to_paths.as_ref() {
6407        let mut cursor = 0;
6408        for path in limit.iter() {
6409            let Some(pos) = index.find_stage0_pos_from(path.as_bytes(), &mut cursor) else {
6410                continue;
6411            };
6412            let ie = &index.entries[pos];
6413            if options
6414                .skip_index_path
6415                .as_ref()
6416                .is_some_and(|skip| skip(&ie.path))
6417            {
6418                continue;
6419            }
6420            if !index_entry_needs_worktree_diff_pass1(ie) {
6421                continue;
6422            }
6423            worktree_diff_pass1_index_entry(
6424                odb,
6425                work_tree,
6426                ie,
6427                path.as_str(),
6428                simplify_gitlinks,
6429                ignore_submodule_untracked,
6430                error_on_broken_gitlinks,
6431                materialize_dirty_blobs,
6432                filter_fp,
6433                &conv,
6434                &file_attrs_for,
6435                precompose_unicode,
6436                ignorecase,
6437                &mut result,
6438            )?;
6439        }
6440    } else {
6441        for ie in &index.entries {
6442            poll_diff_cancel(&options, owner_poll.as_ref())?;
6443            if ie.stage() != 0 {
6444                continue;
6445            }
6446            if options
6447                .skip_index_path
6448                .as_ref()
6449                .is_some_and(|skip| skip(&ie.path))
6450            {
6451                continue;
6452            }
6453            if !index_entry_needs_worktree_diff_pass1(ie) {
6454                continue;
6455            }
6456            let path_stored = crate::repo_relpath::storage_string(&ie.path);
6457            worktree_diff_pass1_index_entry(
6458                odb,
6459                work_tree,
6460                ie,
6461                &path_stored,
6462                simplify_gitlinks,
6463                ignore_submodule_untracked,
6464                error_on_broken_gitlinks,
6465                materialize_dirty_blobs,
6466                filter_fp,
6467                &conv,
6468                &file_attrs_for,
6469                precompose_unicode,
6470                ignorecase,
6471                &mut result,
6472            )?;
6473        }
6474    }
6475
6476    use crate::worktree_scan::{
6477        collect_blob_lookup_batches_with_literal_paths, group_blob_entries_by_dir,
6478        stat_scan_thread_count, visit_blob_lookup_batches, BlobDiskLookup, BlobScanSparseMode,
6479        WorktreeBlobScanOptions, PARALLEL_STAT_MIN_ENTRIES,
6480    };
6481    let preload_index = config
6482        .get_bool("core.preloadIndex")
6483        .and_then(|r| r.ok())
6484        .unwrap_or(true);
6485    let blob_scan = WorktreeBlobScanOptions {
6486        preload_index,
6487        stat_parallel_threads: options.stat_parallel_threads,
6488        ..WorktreeBlobScanOptions::default()
6489    };
6490    let blob_dirs = if let Some(limit) = options.restrict_to_paths.as_ref() {
6491        if let Some(skip) = options.skip_index_path.as_ref() {
6492            let mut positions = Vec::new();
6493            let mut cursor = 0;
6494            for path in limit.iter() {
6495                let Some(pos) = index.find_stage0_pos_from(path.as_bytes(), &mut cursor) else {
6496                    continue;
6497                };
6498                let ie = &index.entries[pos];
6499                if skip(&ie.path) {
6500                    continue;
6501                }
6502                if !crate::worktree_scan::index_blob_scan_eligible(ie, index, honor_fsmonitor_valid)
6503                {
6504                    continue;
6505                }
6506                positions.push(pos);
6507            }
6508            crate::worktree_scan::plan_blob_entries_by_dir_for_positions(
6509                index,
6510                &positions,
6511                honor_fsmonitor_valid,
6512            )
6513        } else {
6514            crate::worktree_scan::group_blob_entries_by_dir_for_paths(index, limit)
6515        }
6516    } else if let Some(skip) = options.skip_index_path.as_ref() {
6517        let positions: Vec<usize> = index
6518            .entries
6519            .iter()
6520            .enumerate()
6521            .filter(|(_, ie)| {
6522                ie.stage() == 0
6523                    && !skip(&ie.path)
6524                    && crate::worktree_scan::index_blob_scan_eligible(
6525                        ie,
6526                        index,
6527                        honor_fsmonitor_valid,
6528                    )
6529            })
6530            .map(|(pos, _)| pos)
6531            .collect();
6532        crate::worktree_scan::plan_blob_entries_by_dir_for_positions(
6533            index,
6534            &positions,
6535            honor_fsmonitor_valid,
6536        )
6537    } else {
6538        group_blob_entries_by_dir(index, honor_fsmonitor_valid)
6539    };
6540    let work_tree_owned = work_tree.to_path_buf();
6541    let dir_abs = move |dir: &[u8]| {
6542        if dir.is_empty() {
6543            work_tree_owned.clone()
6544        } else {
6545            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
6546        }
6547    };
6548    let work_tree_for_files = work_tree.to_path_buf();
6549    let file_abs = move |rel: &[u8]| {
6550        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
6551    };
6552    let blob_count = blob_dirs.len();
6553    let thread_policy = options.thread_policy.unwrap_or_default();
6554    let hash_threads = stat_scan_thread_count(&thread_policy, &config, blob_scan, blob_count);
6555    let parallel_blob_work = blob_count >= PARALLEL_STAT_MIN_ENTRIES && hash_threads > 1;
6556    let mut staging_hash_jobs: Vec<StagingHashJob> = Vec::new();
6557    // Entries the stat refresh just hashed and found matching their recorded blob.
6558    let mut refresh_content_verified = vec![false; index.entries.len()];
6559    // Parallel preload must lstat ancestor directories without following symlinks. When a
6560    // tracked directory is replaced by a symlink, indexed paths under it are deletions even if
6561    // the link target still holds a file at the same relative path with an unchanged stat.
6562    // This is independent of `core.symlinks` (checkout support); detection must stay enabled.
6563    let sparse = if parallel_blob_work {
6564        BlobScanSparseMode::StatusDiff {
6565            stat_policy,
6566            index_mtime,
6567            mode_policy,
6568            for_status: options.for_status,
6569            refresh_index_stat_in_pass,
6570            parallel_blob_work,
6571            honor_fsmonitor_stat_skip: use_fsmonitor_stat_skip,
6572        }
6573    } else {
6574        BlobScanSparseMode::Dense
6575    };
6576    let blob_stat_batches = collect_blob_lookup_batches_with_literal_paths(
6577        index,
6578        &blob_dirs,
6579        blob_scan,
6580        &thread_policy,
6581        &config,
6582        dir_abs,
6583        file_abs,
6584        !precompose_unicode && !ignorecase,
6585        || {
6586            let mut dir_symlinks = SymlinkDirCache::default();
6587            move |rel_path: &[u8]| {
6588                dir_symlinks.has_symlink_in_path_bytes(
6589                    work_tree,
6590                    rel_path,
6591                    precompose_unicode,
6592                    ignorecase,
6593                )
6594            }
6595        },
6596        sparse,
6597        options.cancel_watch.clone(),
6598        owner_poll.clone(),
6599    )?;
6600    // Size-changed entries ask whether their clean conversion is the identity in both the stat
6601    // refresh and the visit below; resolve each one's attributes once.
6602    let identity_by_entry = std::cell::RefCell::new(std::collections::HashMap::new());
6603    let conversion_is_identity = |entry_index: usize, path: &str| -> bool {
6604        *identity_by_entry
6605            .borrow_mut()
6606            .entry(entry_index)
6607            .or_insert_with(|| crlf::git_conversion_is_identity(&conv, &file_attrs_for(path)))
6608    };
6609    let refresh_checks = if refresh_index_stat_in_pass {
6610        let par_opts =
6611            refresh_hash_parallel_options(&thread_policy, &config, options.stat_parallel_threads);
6612        refresh_content_checks(
6613            odb,
6614            index,
6615            &blob_stat_batches,
6616            index_mtime,
6617            stat_policy,
6618            &|entry_index, ie, stat| {
6619                !(use_fsmonitor_stat_skip && crate::fsmonitor::entry_skips_worktree_stat(ie, index))
6620                    && worktree_diff_refreshes_entry(
6621                        ie,
6622                        stat,
6623                        index_mtime,
6624                        stat_policy,
6625                        options.for_status,
6626                        || {
6627                            conversion_is_identity(
6628                                entry_index,
6629                                &crate::repo_relpath::storage_string(&ie.path),
6630                            )
6631                        },
6632                    )
6633            },
6634            &WorktreeHashContext {
6635                work_tree,
6636                precompose_unicode,
6637                ignorecase,
6638                conv: &conv,
6639                file_attrs_for: &file_attrs_for,
6640                filter_process: filter_fp,
6641            },
6642            par_opts,
6643            options.cancel_watch.as_ref(),
6644        )?
6645    } else {
6646        RefreshChecks::default()
6647    };
6648    poll_diff_cancel(&options, owner_poll.as_ref())?;
6649    visit_blob_lookup_batches(blob_stat_batches, |entry_index, lookup| {
6650        poll_diff_cancel(&options, owner_poll.as_ref())?;
6651        let path_owned = crate::repo_relpath::storage_string(&index.entries[entry_index].path);
6652        let path_str_ref = path_owned.as_str();
6653        if use_fsmonitor_stat_skip {
6654            let ie_skip = &index.entries[entry_index];
6655            if crate::fsmonitor::entry_skips_worktree_stat(ie_skip, index) {
6656                return Ok(());
6657            }
6658        }
6659        match lookup {
6660            BlobDiskLookup::Missing => {
6661                if icase_collisions.as_ref().is_some_and(|map| {
6662                    map.preserve_missing_collision_loser(&index.entries[entry_index].path)
6663                }) {
6664                    return Ok(());
6665                }
6666                let ie = &index.entries[entry_index];
6667                result.push(DiffEntry {
6668                    status: DiffStatus::Deleted,
6669                    old_path: Some(path_str_ref.to_owned()),
6670                    new_path: None,
6671                    old_mode: FileMode::new(ie.mode),
6672                    new_mode: FileMode::ABSENT,
6673                    old_oid: ie.oid,
6674                    new_oid: zero_oid(),
6675                    score: None,
6676                });
6677            }
6678            BlobDiskLookup::Io(e) => {
6679                blob_disk_lookup_io_result(
6680                    options.diagnostics.as_ref(),
6681                    path_str_ref,
6682                    e,
6683                    options.for_status,
6684                    options.skip_inaccessible_paths,
6685                    materialize_dirty_blobs,
6686                )?;
6687            }
6688            BlobDiskLookup::Present(stat) => {
6689                if materialize_dirty_blobs {
6690                    staging_stat_cache.insert(path_str_ref.to_owned(), stat.fields);
6691                }
6692                if refresh_index_stat_in_pass
6693                    && worktree_diff_refreshes_entry(
6694                        &index.entries[entry_index],
6695                        &stat,
6696                        index_mtime,
6697                        stat_policy,
6698                        options.for_status,
6699                        || conversion_is_identity(entry_index, path_str_ref),
6700                    )
6701                {
6702                    let content_matches = RefreshContent::matches(
6703                        refresh_checks.get(entry_index),
6704                        &index.entries[entry_index].oid,
6705                    );
6706                    let refresh = refresh_index_blob_stat_from_scan(
6707                        &mut index.entries[entry_index],
6708                        &stat,
6709                        index_mtime,
6710                        stat_policy,
6711                        content_matches,
6712                    );
6713                    if refresh.index_changed {
6714                        index_stat_refresh_changed = true;
6715                    }
6716                    if refresh.content_verified {
6717                        refresh_content_verified[entry_index] = true;
6718                        let path = index.entries[entry_index].path.clone();
6719                        let oid = index.entries[entry_index].oid;
6720                        index.mark_content_verified(&path, oid);
6721                    }
6722                }
6723                let ie = &index.entries[entry_index];
6724                let worktree_mode =
6725                    mode_policy.index_mode_for_worktree(stat.git_mode, Some(ie.mode));
6726                // The stat refresh already hashed this file and found other content, so do not
6727                // read it again. Staging hashes again because it needs the blob stored.
6728                if !materialize_dirty_blobs
6729                    && !stat.is_dir
6730                    && stat.git_mode != crate::index::MODE_SYMLINK
6731                    && RefreshContent::differs(refresh_checks.get(entry_index), &ie.oid)
6732                {
6733                    result.push(worktree_content_change_entry(
6734                        ie,
6735                        path_str_ref,
6736                        worktree_mode,
6737                        zero_oid(),
6738                    ));
6739                    return Ok(());
6740                }
6741                // Streaming jobs read regular files. Links and directories need the shared
6742                // classifier; use the physical type so core.symlinks=false placeholders
6743                // keep the regular-file fast path even with an effective symlink mode.
6744                if parallel_blob_work && !stat.is_dir && stat.git_mode != crate::index::MODE_SYMLINK
6745                {
6746                    let stat_same = stat_matches_blob_stat(ie, &stat, stat_policy);
6747                    if stat_same
6748                        && worktree_mode == ie.mode
6749                        && index_entry_skips_worktree_content_hash_after_stat_match(
6750                            ie,
6751                            index_mtime,
6752                            stat_policy,
6753                        )
6754                    {
6755                        if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(index, entry_index) {
6756                            fsmonitor_valid_updated = true;
6757                        }
6758                        return Ok(());
6759                    }
6760                    if stat_same && worktree_mode != ie.mode {
6761                        result.push(worktree_content_change_entry(
6762                            ie,
6763                            path_str_ref,
6764                            worktree_mode,
6765                            ie.oid,
6766                        ));
6767                        return Ok(());
6768                    }
6769                    if !icase_collisions.as_ref().is_some_and(|map| {
6770                        map.diff_requires_worktree_hash(&index.entries[entry_index].path)
6771                    }) && try_push_status_modified_on_size_mismatch(
6772                        odb,
6773                        ie,
6774                        path_str_ref,
6775                        stat.worktree_size(),
6776                        worktree_mode,
6777                        stat.raw_size,
6778                        size_change_proves_modified,
6779                        || conversion_is_identity(entry_index, path_str_ref),
6780                        &mut result,
6781                    )? {
6782                        return Ok(());
6783                    }
6784                    if refresh_content_verified[entry_index] {
6785                        if worktree_mode == ie.mode {
6786                            if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(
6787                                index,
6788                                entry_index,
6789                            ) {
6790                                fsmonitor_valid_updated = true;
6791                            }
6792                        } else {
6793                            result.push(worktree_content_change_entry(
6794                                ie,
6795                                path_str_ref,
6796                                worktree_mode,
6797                                ie.oid,
6798                            ));
6799                        }
6800                    } else {
6801                        staging_hash_jobs.push(StagingHashJob::new(
6802                            entry_index,
6803                            worktree_mode,
6804                            file_attrs_for(path_str_ref),
6805                        ));
6806                    }
6807                } else {
6808                    // The scan's lstat already proves an unchanged, non-racy entry clean, and the
6809                    // stat refresh above may have just verified a racy entry by content; only
6810                    // re-stat paths that `push_index_blob_worktree_diff` has to inspect.
6811                    let same_file_type = (worktree_mode == crate::index::MODE_SYMLINK)
6812                        == (ie.mode == crate::index::MODE_SYMLINK);
6813                    if !stat.is_dir
6814                        && worktree_mode == ie.mode
6815                        && ((refresh_content_verified[entry_index] && same_file_type)
6816                            || (stat_matches_blob_stat(ie, &stat, stat_policy)
6817                                && index_entry_skips_worktree_content_hash_after_stat_match(
6818                                    ie,
6819                                    index_mtime,
6820                                    stat_policy,
6821                                )))
6822                    {
6823                        if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(index, entry_index) {
6824                            fsmonitor_valid_updated = true;
6825                        }
6826                        return Ok(());
6827                    }
6828                    let file_path = index_entry_worktree_abs_bytes(
6829                        work_tree,
6830                        &index.entries[entry_index].path,
6831                        precompose_unicode,
6832                        ignorecase,
6833                    );
6834                    let Some(meta) = symlink_metadata_worktree_diff(
6835                        &file_path,
6836                        path_str_ref,
6837                        options.diagnostics.as_ref(),
6838                        options.for_status,
6839                        options.skip_inaccessible_paths,
6840                        materialize_dirty_blobs,
6841                    )?
6842                    else {
6843                        return Ok(());
6844                    };
6845                    push_index_blob_worktree_diff(
6846                        odb,
6847                        ie,
6848                        path_str_ref,
6849                        &file_path,
6850                        meta,
6851                        index_mtime,
6852                        mode_policy,
6853                        stat_policy,
6854                        &file_attrs_for,
6855                        &conv,
6856                        materialize_dirty_blobs,
6857                        hash_blobs_without_write,
6858                        options.for_status,
6859                        size_change_proves_modified,
6860                        options.skip_inaccessible_paths,
6861                        options.diagnostics.as_ref(),
6862                        filter_fp,
6863                        icase_collisions.as_ref(),
6864                        &mut result,
6865                    )?;
6866                }
6867            }
6868        }
6869        Ok(())
6870    })?;
6871    if parallel_blob_work && !staging_hash_jobs.is_empty() {
6872        parallel_process_blob_worktree_jobs(
6873            odb,
6874            &StagingHashSource {
6875                index,
6876                work_tree,
6877                precompose_unicode,
6878                ignorecase,
6879            },
6880            &staging_hash_jobs,
6881            &thread_policy,
6882            &config,
6883            materialize_dirty_blobs,
6884            hash_blobs_without_write,
6885            options.for_status,
6886            options.skip_inaccessible_paths,
6887            options.diagnostics.as_ref(),
6888            &conv,
6889            filter_fp,
6890            icase_collisions.as_ref(),
6891            &mut result,
6892        )?;
6893    }
6894
6895    for (path, (_, base_entry)) in unmerged_base {
6896        let file_path = index_entry_worktree_abs(work_tree, &path, precompose_unicode, ignorecase);
6897        let wt_meta = symlink_metadata_worktree_diff(
6898            &file_path,
6899            &path,
6900            options.diagnostics.as_ref(),
6901            options.for_status,
6902            options.skip_inaccessible_paths,
6903            materialize_dirty_blobs,
6904        )?;
6905
6906        let new_mode = wt_meta.as_ref().map_or_else(
6907            || FileMode::ABSENT,
6908            |meta| FileMode::new(mode_from_metadata(meta)),
6909        );
6910        result.push(DiffEntry {
6911            status: DiffStatus::Unmerged,
6912            old_path: Some(path.clone()),
6913            new_path: Some(path.clone()),
6914            old_mode: FileMode::ABSENT,
6915            new_mode,
6916            old_oid: zero_oid(),
6917            new_oid: zero_oid(),
6918            score: None,
6919        });
6920
6921        if !options.for_status {
6922            if let Some(meta) = wt_meta {
6923                let file_attrs = file_attrs_for(&path);
6924                let wt_oid = worktree_file_oid(
6925                    odb,
6926                    &file_path,
6927                    &meta,
6928                    &conv,
6929                    &file_attrs,
6930                    &path,
6931                    Some(&base_entry),
6932                    materialize_dirty_blobs,
6933                    filter_fp,
6934                )?;
6935                let wt_mode = mode_from_metadata(&meta);
6936                if wt_oid != base_entry.oid || wt_mode != base_entry.mode {
6937                    result.push(DiffEntry {
6938                        status: DiffStatus::for_content_change(base_entry.mode, wt_mode),
6939                        old_path: Some(path.clone()),
6940                        new_path: Some(path),
6941                        old_mode: FileMode::new(base_entry.mode),
6942                        new_mode: FileMode::new(wt_mode),
6943                        old_oid: base_entry.oid,
6944                        new_oid: wt_oid,
6945                        score: None,
6946                    });
6947                }
6948            }
6949        }
6950    }
6951
6952    if fsmonitor_valid_updated {
6953        index.fsmonitor_validity_changed();
6954        index_stat_refresh_changed = true;
6955    }
6956
6957    Ok((result, index_stat_refresh_changed, staging_stat_cache))
6958}
6959
6960/// Per-worker cache answering "is any ancestor directory of this index path a symlink?".
6961///
6962/// It remembers the last chain of ancestors verified to be real directories, plus the first
6963/// ancestor found not to be one. Index paths arrive in sorted order, so consecutive queries
6964/// share most of their leading directories and only the differing components are probed.
6965/// Each scan worker owns its own cache: there is no locking and no per-directory allocation.
6966#[derive(Default)]
6967pub(crate) struct SymlinkDirCache {
6968    /// Leading directory (raw index bytes, no trailing `/`) whose every component is a real
6969    /// directory on disk.
6970    real_dirs: Vec<u8>,
6971    /// First ancestor found not to be a real directory, and whether it is a symlink. Every
6972    /// path under it shares that answer.
6973    blocked: Option<(Vec<u8>, bool)>,
6974    /// Single-walk check for directories several components below `real_dirs`.
6975    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
6976    chain: Option<crate::worktree_scan::RealDirChain>,
6977    /// Filesystem queries made so far.
6978    #[cfg(test)]
6979    fs_queries: usize,
6980}
6981
6982/// Whether `path` is `dir` itself or lies under it (`dir` has no trailing `/`).
6983fn path_within_dir(path: &[u8], dir: &[u8]) -> bool {
6984    path.starts_with(dir) && path.get(dir.len()).is_none_or(|&b| b == b'/')
6985}
6986
6987/// Length of the longest leading run of whole components shared by two directory paths.
6988fn common_dir_prefix_len(a: &[u8], b: &[u8]) -> usize {
6989    let same = a.iter().zip(b).take_while(|(x, y)| x == y).count();
6990    let ends_component = |p: &[u8]| p.get(same).is_none_or(|&c| c == b'/');
6991    if ends_component(a) && ends_component(b) {
6992        return same;
6993    }
6994    a[..same].iter().rposition(|&c| c == b'/').unwrap_or(0)
6995}
6996
6997pub(crate) fn index_entry_worktree_abs(
6998    work_tree: &Path,
6999    index_relpath: &str,
7000    precompose_unicode: bool,
7001    ignorecase: bool,
7002) -> PathBuf {
7003    index_entry_worktree_abs_bytes(
7004        work_tree,
7005        index_relpath.as_bytes(),
7006        precompose_unicode,
7007        ignorecase,
7008    )
7009}
7010
7011pub(crate) fn index_entry_worktree_abs_bytes(
7012    work_tree: &Path,
7013    index_relpath: &[u8],
7014    precompose_unicode: bool,
7015    ignorecase: bool,
7016) -> PathBuf {
7017    if precompose_unicode || ignorecase {
7018        if let Ok(s) = std::str::from_utf8(index_relpath) {
7019            if precompose_unicode {
7020                return crate::unicode_normalization::resolve_worktree_path_for_staging(
7021                    work_tree, s, true,
7022                )
7023                .abs;
7024            }
7025            return crate::path_icase::worktree_path_for_index_entry(work_tree, s, ignorecase);
7026        }
7027    }
7028    crate::index::worktree_path_from_index_rel(work_tree, index_relpath)
7029}
7030
7031/// Effective index-file mtime for racy-git checks.
7032///
7033/// Uses an explicit override when provided, otherwise [`Index::source_mtime`] from the last
7034/// on-disk read.
7035#[must_use]
7036pub fn index_mtime_for_diff(index: &Index, explicit: Option<(u32, u32)>) -> Option<(u32, u32)> {
7037    explicit.or(index.source_mtime)
7038}
7039
7040impl SymlinkDirCache {
7041    /// Whether any parent directory of `rel_path` (raw index path bytes) is a symlink.
7042    ///
7043    /// Assumes the worktree does not change during the scan this cache serves. A missing
7044    /// ancestor, or one that is a file, is not a symlink and hides everything below it.
7045    pub(crate) fn has_symlink_in_path_bytes(
7046        &mut self,
7047        work_tree: &Path,
7048        rel_path: &[u8],
7049        precompose_unicode: bool,
7050        ignorecase: bool,
7051    ) -> bool {
7052        use crate::worktree_scan::{ancestor_kind, AncestorKind};
7053
7054        let Some(dir_len) = rel_path.iter().rposition(|&b| b == b'/') else {
7055            return false;
7056        };
7057        let dir = &rel_path[..dir_len];
7058        if let Some((blocked, is_symlink)) = &self.blocked {
7059            if path_within_dir(dir, blocked) {
7060                return *is_symlink;
7061            }
7062        }
7063        let verified = common_dir_prefix_len(dir, &self.real_dirs);
7064        self.real_dirs.truncate(verified);
7065        #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
7066        if !precompose_unicode
7067            && !ignorecase
7068            && dir[verified..]
7069                .split(|&b| b == b'/')
7070                .filter(|component| !component.is_empty())
7071                .nth(1)
7072                .is_some()
7073        {
7074            let chain = match &mut self.chain {
7075                Some(chain) if chain.work_tree() == work_tree => chain,
7076                slot => slot.insert(crate::worktree_scan::RealDirChain::open(work_tree)),
7077            };
7078            #[cfg(test)]
7079            {
7080                self.fs_queries += 1;
7081            }
7082            if chain.confirms(dir) {
7083                self.real_dirs.clear();
7084                self.real_dirs.extend_from_slice(dir);
7085                return false;
7086            }
7087        }
7088        let mut start = verified;
7089        while start < dir.len() {
7090            if dir[start] == b'/' {
7091                start += 1;
7092                continue;
7093            }
7094            let end = dir[start..]
7095                .iter()
7096                .position(|&b| b == b'/')
7097                .map_or(dir.len(), |i| start + i);
7098            let prefix = &dir[..end];
7099            let abs =
7100                index_entry_worktree_abs_bytes(work_tree, prefix, precompose_unicode, ignorecase);
7101            #[cfg(test)]
7102            {
7103                self.fs_queries += 1;
7104            }
7105            match ancestor_kind(&abs) {
7106                AncestorKind::Directory => {
7107                    let known = self.real_dirs.len();
7108                    self.real_dirs.extend_from_slice(&prefix[known..]);
7109                }
7110                AncestorKind::Symlink => {
7111                    self.blocked = Some((prefix.to_vec(), true));
7112                    return true;
7113                }
7114                AncestorKind::Other => {
7115                    self.blocked = Some((prefix.to_vec(), false));
7116                    return false;
7117                }
7118            }
7119            start = end;
7120        }
7121        false
7122    }
7123}
7124
7125/// Whether an index entry's cached mtime is at or after the on-disk index file's mtime.
7126///
7127/// Git labels such entries racy: stat data can look clean even though the worktree changed
7128/// within the same second the index was written. Callers pass the index file mtime sampled when
7129/// the index was read (before any rewrite). When `index_mtime` is `None`, returns `false`.
7130#[must_use]
7131pub fn entry_is_racy(ie: &IndexEntry, index_mtime: Option<(u32, u32)>) -> bool {
7132    entry_is_racy_with_policy(ie, index_mtime, crate::index::StatMatchPolicy::default())
7133}
7134
7135/// Like [`entry_is_racy`], honoring `core.usenanosec` for sub-second racy detection.
7136///
7137/// Sub-second racy detection applies only when stat equality also compares the fractions
7138/// (`core.usenanosec` with `core.checkstat=default`); otherwise a same-second, same-size edit
7139/// would be neither racy nor a stat mismatch.
7140#[must_use]
7141pub fn entry_is_racy_with_policy(
7142    ie: &IndexEntry,
7143    index_mtime: Option<(u32, u32)>,
7144    policy: crate::index::StatMatchPolicy,
7145) -> bool {
7146    let Some((index_mtime_sec, index_mtime_nsec)) = index_mtime else {
7147        return false;
7148    };
7149    if index_mtime_sec == 0 {
7150        return false;
7151    }
7152    if policy.use_nanosec && policy.check_stat {
7153        index_mtime_sec < ie.mtime_sec
7154            || (index_mtime_sec == ie.mtime_sec && index_mtime_nsec <= ie.mtime_nsec)
7155    } else {
7156        index_mtime_sec <= ie.mtime_sec
7157    }
7158}
7159
7160/// Clear racily-clean cache lines on entries whose blob no longer matches the worktree.
7161///
7162/// Before Git writes the index, it walks stage-0 file and symlink entries and sets the cached
7163/// size to zero when stat data still matches the worktree but content does not match the
7164/// recorded OID and the entry timestamp is racy relative to `index_mtime`. That forces the
7165/// next diff or status pass to re-hash the path instead of trusting stat alone.
7166///
7167/// Skip-worktree, assume-unchanged, and intent-to-add entries are left unchanged, and so are
7168/// entries this process just hashed from the worktree to their current OID (Git skips its
7169/// `CE_UPTODATE` entries the same way). When `index_mtime` is `None` (no prior on-disk index),
7170/// nothing is smudged.
7171///
7172/// Content equality uses `git_dir` config and worktree attributes with [`hash_worktree_file`]
7173/// so the repository hash algorithm and clean (CRLF/filter) conversion match index OIDs.
7174///
7175/// Returns `true` when at least one entry was smudged.
7176pub fn smudge_racily_clean_entries(
7177    odb: &Odb,
7178    git_dir: &Path,
7179    index: &mut Index,
7180    work_tree: &Path,
7181    index_mtime: Option<(u32, u32)>,
7182    config: Option<&ConfigSet>,
7183    filter_process: Option<&crate::filter_process::FilterProcessState>,
7184) -> bool {
7185    use crate::crlf;
7186    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
7187
7188    let Some(index_mtime) = index_mtime else {
7189        return false;
7190    };
7191
7192    let config = config.cloned().unwrap_or_else(|| {
7193        ConfigSet::load(
7194            &crate::environment::Environment::empty(),
7195            Some(git_dir),
7196            true,
7197        )
7198        .unwrap_or_default()
7199    });
7200    let conv = crlf::ConversionConfig::from_config(&config);
7201    let attrs = crlf::load_gitattributes(work_tree);
7202    let verified = std::mem::take(&mut index.content_verified);
7203
7204    let mut changed = false;
7205    for ie in &mut index.entries {
7206        let marked_verified = ie.take_content_verified();
7207        if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
7208            continue;
7209        }
7210        if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
7211            continue;
7212        }
7213        if ie.size == 0 {
7214            continue;
7215        }
7216        if !entry_is_racy(ie, Some(index_mtime)) {
7217            continue;
7218        }
7219        if marked_verified || verified.get(&ie.path) == Some(&ie.oid) {
7220            continue;
7221        }
7222        let Ok(path) = std::str::from_utf8(&ie.path) else {
7223            continue;
7224        };
7225        let abs = work_tree.join(path);
7226        let Ok(meta) = fs::symlink_metadata(&abs) else {
7227            continue;
7228        };
7229        if !stat_matches(ie, &meta) {
7230            continue;
7231        }
7232        let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
7233        let content_matches = hash_worktree_file(
7234            odb,
7235            &abs,
7236            &meta,
7237            &conv,
7238            &file_attrs,
7239            path,
7240            Some(ie),
7241            filter_process,
7242        )
7243        .map(|oid| oid == ie.oid)
7244        .unwrap_or(false);
7245        if content_matches {
7246            continue;
7247        }
7248        ie.size = 0;
7249        changed = true;
7250    }
7251    changed
7252}
7253/// Quick stat check: does the index entry's cached stat data match the file?
7254/// Returns true when the file at `ie`'s path differs from the index entry (mode or blob).
7255///
7256/// Used by commands such as `git mv` to detect "dirty" paths under sparse checkout.
7257/// Symlinks and submodules are compared in a Git-compatible way.
7258///
7259/// `ignore_submodule_untracked` mirrors [`diff_index_to_worktree`]'s same flag for gitlinks.
7260pub fn worktree_differs_from_index_entry(
7261    odb: &Odb,
7262    repository_git_dir: &Path,
7263    work_tree: &Path,
7264    ie: &IndexEntry,
7265    ignore_submodule_untracked: bool,
7266    config: Option<&ConfigSet>,
7267    filter_process: Option<&crate::filter_process::FilterProcessState>,
7268) -> Result<bool> {
7269    WorktreeEntryComparer::new(repository_git_dir, work_tree, config).differs(
7270        odb,
7271        ie,
7272        ignore_submodule_untracked,
7273        filter_process,
7274    )
7275}
7276
7277/// Repeated [`worktree_differs_from_index_entry`] checks against one worktree.
7278///
7279/// Loads config-derived settings and the root `.gitattributes` once instead of per entry.
7280pub(crate) struct WorktreeEntryComparer<'a> {
7281    work_tree: &'a Path,
7282    config: ConfigSet,
7283    mode_policy: crate::index::WorktreeModePolicy,
7284    precompose_unicode: bool,
7285    ignorecase: bool,
7286    conv: std::cell::OnceCell<crate::crlf::ConversionConfig>,
7287    attrs: std::cell::OnceCell<Vec<crate::crlf::AttrRule>>,
7288}
7289
7290impl<'a> WorktreeEntryComparer<'a> {
7291    /// Capture settings for `work_tree`; `config` falls back to the repository config files.
7292    pub(crate) fn new(
7293        repository_git_dir: &Path,
7294        work_tree: &'a Path,
7295        config: Option<&ConfigSet>,
7296    ) -> Self {
7297        let config = config.cloned().unwrap_or_else(|| {
7298            ConfigSet::load(
7299                &crate::environment::Environment::empty(),
7300                Some(repository_git_dir),
7301                true,
7302            )
7303            .unwrap_or_else(|_| ConfigSet::new())
7304        });
7305        let flag =
7306            |key: &str, default: bool| config.get_bool(key).and_then(|r| r.ok()).unwrap_or(default);
7307        Self {
7308            work_tree,
7309            mode_policy: crate::index::WorktreeModePolicy::from_config(&config),
7310            precompose_unicode: flag("core.precomposeunicode", false),
7311            ignorecase: flag("core.ignorecase", false),
7312            config,
7313            conv: std::cell::OnceCell::new(),
7314            attrs: std::cell::OnceCell::new(),
7315        }
7316    }
7317
7318    /// Same contract as [`worktree_differs_from_index_entry`].
7319    pub(crate) fn differs(
7320        &self,
7321        odb: &Odb,
7322        ie: &IndexEntry,
7323        ignore_submodule_untracked: bool,
7324        filter_process: Option<&crate::filter_process::FilterProcessState>,
7325    ) -> Result<bool> {
7326        use crate::crlf;
7327
7328        let work_tree = self.work_tree;
7329        let path_stored = crate::repo_relpath::storage_string(&ie.path);
7330        let path_str_ref = crate::repo_relpath::as_utf8_for_matching(&path_stored);
7331        let file_path = index_entry_worktree_abs_bytes(
7332            work_tree,
7333            &ie.path,
7334            self.precompose_unicode,
7335            self.ignorecase,
7336        );
7337
7338        if ie.mode == 0o160000 {
7339            let sub_head_oid = read_submodule_head(&file_path);
7340            let ref_matches = match sub_head_oid {
7341                Some(oid) => oid == ie.oid,
7342                None => uninitialized_gitlink_worktree_matches_index(&file_path),
7343            };
7344            let mut flags = submodule_porcelain_flags(work_tree, path_str_ref.as_ref(), ie.oid);
7345            if ignore_submodule_untracked {
7346                flags.untracked = false;
7347            }
7348            return Ok(!ref_matches || flags.modified || flags.untracked);
7349        }
7350
7351        let meta = match fs::symlink_metadata(&file_path) {
7352            Ok(m) => m,
7353            Err(e)
7354                if e.kind() == std::io::ErrorKind::NotFound
7355                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
7356            {
7357                return Ok(true);
7358            }
7359            Err(e) => return Err(Error::Io(e)),
7360        };
7361
7362        if meta.is_dir() {
7363            return Ok(true);
7364        }
7365
7366        if !self
7367            .mode_policy
7368            .worktree_mode_matches(mode_from_metadata(&meta), ie.mode)
7369        {
7370            return Ok(true);
7371        }
7372
7373        let conv = self
7374            .conv
7375            .get_or_init(|| crlf::ConversionConfig::from_config(&self.config));
7376        let attrs = self
7377            .attrs
7378            .get_or_init(|| crlf::load_gitattributes(work_tree));
7379        let file_attrs = crlf::get_file_attrs(attrs, path_str_ref.as_ref(), false, &self.config);
7380        let worktree_oid = hash_worktree_file(
7381            odb,
7382            &file_path,
7383            &meta,
7384            conv,
7385            &file_attrs,
7386            path_str_ref.as_ref(),
7387            Some(ie),
7388            filter_process,
7389        )?;
7390
7391        let mut eff_oid = worktree_oid;
7392        if eff_oid != ie.oid {
7393            if let Ok(raw) = fs::read(&file_path) {
7394                let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
7395                if raw_oid == ie.oid {
7396                    eff_oid = ie.oid;
7397                }
7398            }
7399        }
7400
7401        Ok(eff_oid != ie.oid)
7402    }
7403}
7404
7405/// Inputs for [`classify_worktree_entry_for_add`].
7406pub struct WorktreeAddRefreshParams<'a> {
7407    /// Object database used to verify blob OIDs when stat data is inconclusive.
7408    pub odb: &'a Odb,
7409    /// Index entry being compared.
7410    pub ie: &'a IndexEntry,
7411    /// `lstat` metadata for the worktree path.
7412    pub meta: &'a fs::Metadata,
7413    /// Absolute worktree path to the file.
7414    pub abs_path: &'a Path,
7415    /// Repository-relative path string.
7416    pub rel_path: &'a str,
7417    /// CRLF / filter conversion settings.
7418    pub conv: &'a crate::crlf::ConversionConfig,
7419    /// Per-path attribute state.
7420    pub file_attrs: &'a crate::crlf::FileAttrs,
7421    /// On-disk index mtime when the index was loaded; `None` skips racy checks.
7422    pub index_mtime: Option<(u32, u32)>,
7423    /// Mode that would be written, after [`crate::index::WorktreeModePolicy`] against `ie.mode`
7424    /// and any `--chmod` hint.
7425    pub staged_mode: u32,
7426    /// Repository-scoped filter-process registry (when staging through a [`crate::repo::Repository`]).
7427    pub filter_process: Option<&'a crate::filter_process::FilterProcessState>,
7428}
7429
7430/// Outcome of comparing a tracked index entry to its worktree path for staging refresh.
7431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7432pub enum WorktreeAddRefresh {
7433    /// Index already reflects the worktree; caller may skip staging.
7434    UpToDate,
7435    /// Executable bit differs while blob content is unchanged.
7436    ModeOnly {
7437        /// Mode to store in the index.
7438        mode: u32,
7439    },
7440    /// Blob OID and mode are unchanged; only cached stat fields should be refreshed.
7441    StatOnly,
7442    /// Content, type, or mode/blob mismatch requires a full restage (read, convert, hash).
7443    NeedsRestage,
7444}
7445
7446/// Whether stat-only fast paths may skip re-hashing during staging.
7447#[must_use]
7448pub(crate) fn index_stat_cache_trustworthy(index_mtime: Option<(u32, u32)>) -> bool {
7449    match index_mtime {
7450        None => false,
7451        Some((0, _)) => false,
7452        Some(_) => true,
7453    }
7454}
7455
7456/// When true, after worktree metadata has been read and matches the index entry, blob content
7457/// hashing may be skipped (Git `diff-files` / `update-index --refresh` style).
7458///
7459/// Does not permit skipping worktree observation; fsmonitor-valid entries use a separate path.
7460/// Independent of `core.filemode`: callers compare the executable bit separately.
7461///
7462/// A zero cached size is trusted only for the empty blob; any other blob with size zero was
7463/// smudged by a racy index write and must be re-hashed (Git `ce_match_stat_basic`).
7464#[must_use]
7465pub(crate) fn index_entry_skips_worktree_content_hash_after_stat_match(
7466    ie: &IndexEntry,
7467    index_mtime: Option<(u32, u32)>,
7468    stat_policy: crate::index::StatMatchPolicy,
7469) -> bool {
7470    if ie.stat_cache_unset() || (ie.size == 0 && !ie.oid.is_empty_blob()) {
7471        return false;
7472    }
7473    if !index_stat_cache_trustworthy(index_mtime) {
7474        return false;
7475    }
7476    !entry_is_racy_with_policy(ie, index_mtime, stat_policy)
7477}
7478
7479/// Decide whether staging must re-read and re-hash a tracked path.
7480///
7481/// Mirrors the fast paths in [`diff_index_to_worktree`] and Git's `refresh_index` /
7482/// `add_file_to_index`: trust matching stat when the entry is not racy and
7483/// `index_stat_cache_trustworthy` allows it; re-hash when stat matches but the entry is racy
7484/// or when stat differs; apply CRLF-aware hashing like staging.
7485///
7486/// Returns [`WorktreeAddRefresh::UpToDate`] when the index entry already matches the worktree and
7487/// no restaging is required; other variants describe mode/stat-only updates or a full restage.
7488///
7489/// # Errors
7490///
7491/// Returns [`Error::Io`] when the worktree file cannot be read for content verification.
7492pub fn classify_worktree_entry_for_add(
7493    params: &WorktreeAddRefreshParams<'_>,
7494) -> Result<WorktreeAddRefresh> {
7495    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
7496
7497    let ie = params.ie;
7498    let meta = params.meta;
7499    let staged_mode = params.staged_mode;
7500
7501    if ie.intent_to_add() {
7502        return Ok(WorktreeAddRefresh::NeedsRestage);
7503    }
7504
7505    let racy = entry_is_racy(ie, params.index_mtime);
7506    let stat_same = stat_matches(ie, meta);
7507    let trust_stat = index_stat_cache_trustworthy(params.index_mtime);
7508
7509    if ie.mode == MODE_SYMLINK {
7510        // With `core.symlinks=false` the policy keeps a plain file holding the link target as
7511        // a symlink entry, so it is compared by content like a real link.
7512        if !meta.file_type().is_symlink() && staged_mode != MODE_SYMLINK {
7513            return Ok(WorktreeAddRefresh::NeedsRestage);
7514        }
7515        if stat_same && !racy && trust_stat && staged_mode == ie.mode {
7516            return Ok(WorktreeAddRefresh::UpToDate);
7517        }
7518        if stat_same && !racy && trust_stat && staged_mode != ie.mode {
7519            return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
7520        }
7521        return worktree_effective_oid_matches_index(params);
7522    }
7523
7524    if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE {
7525        return Ok(WorktreeAddRefresh::NeedsRestage);
7526    }
7527
7528    if meta.file_type().is_symlink() || meta.is_dir() {
7529        return Ok(WorktreeAddRefresh::NeedsRestage);
7530    }
7531
7532    if stat_same && !racy && trust_stat {
7533        if staged_mode == ie.mode {
7534            return Ok(WorktreeAddRefresh::UpToDate);
7535        }
7536        return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
7537    }
7538
7539    worktree_effective_oid_matches_index(params)
7540}
7541
7542fn worktree_effective_oid_matches_index(
7543    params: &WorktreeAddRefreshParams<'_>,
7544) -> Result<WorktreeAddRefresh> {
7545    let WorktreeAddRefreshParams {
7546        odb,
7547        ie,
7548        meta,
7549        abs_path,
7550        rel_path,
7551        conv,
7552        file_attrs,
7553        staged_mode,
7554        ..
7555    } = params;
7556    let worktree_oid = hash_worktree_file(
7557        odb,
7558        abs_path,
7559        meta,
7560        conv,
7561        file_attrs,
7562        rel_path,
7563        Some(ie),
7564        params.filter_process,
7565    )?;
7566    let mut eff_oid = worktree_oid;
7567    if eff_oid != ie.oid {
7568        if let Ok(raw) = fs::read(abs_path) {
7569            let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
7570            if raw_oid == ie.oid {
7571                eff_oid = ie.oid;
7572            }
7573        }
7574    }
7575    if eff_oid != ie.oid {
7576        return Ok(WorktreeAddRefresh::NeedsRestage);
7577    }
7578    if *staged_mode != ie.mode {
7579        return Ok(WorktreeAddRefresh::ModeOnly { mode: *staged_mode });
7580    }
7581    if stat_matches(ie, meta) {
7582        Ok(WorktreeAddRefresh::UpToDate)
7583    } else {
7584        Ok(WorktreeAddRefresh::StatOnly)
7585    }
7586}
7587
7588/// Whether worktree metadata matches cached index stat data (Git `ie_match_stat` / `match_stat_data`).
7589#[must_use]
7590pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
7591    stat_matches_with_policy(ie, meta, crate::index::StatMatchPolicy::default())
7592}
7593
7594/// Whether compact worktree stat from a directory scan matches the index row.
7595pub(crate) fn stat_matches_blob_stat(
7596    ie: &IndexEntry,
7597    stat: &crate::worktree_scan::BlobDiskStat,
7598    policy: crate::index::StatMatchPolicy,
7599) -> bool {
7600    stat.fields.matches_index_entry(ie, policy)
7601}
7602
7603/// Like [`stat_matches`], honoring `core.trustctime`, `core.checkstat`, and `core.usenanosec`.
7604#[must_use]
7605pub fn stat_matches_with_policy(
7606    ie: &IndexEntry,
7607    meta: &fs::Metadata,
7608    policy: crate::index::StatMatchPolicy,
7609) -> bool {
7610    if crate::index::munge_index_size(meta.len()) != ie.size {
7611        return false;
7612    }
7613    #[cfg(unix)]
7614    {
7615        use std::os::unix::fs::MetadataExt;
7616        if meta.mtime() as u32 != ie.mtime_sec {
7617            return false;
7618        }
7619        if policy.use_nanosec && policy.check_stat && meta.mtime_nsec() as u32 != ie.mtime_nsec {
7620            return false;
7621        }
7622        if policy.trust_ctime && policy.check_stat {
7623            if meta.ctime() as u32 != ie.ctime_sec {
7624                return false;
7625            }
7626            if policy.use_nanosec && meta.ctime_nsec() as u32 != ie.ctime_nsec {
7627                return false;
7628            }
7629        }
7630        if policy.check_stat && meta.ino() as u32 != ie.ino {
7631            return false;
7632        }
7633    }
7634    #[cfg(not(unix))]
7635    {
7636        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = crate::index::metadata_times(meta);
7637        if mtime_sec != ie.mtime_sec {
7638            return false;
7639        }
7640        if policy.use_nanosec && policy.check_stat && mtime_nsec != ie.mtime_nsec {
7641            return false;
7642        }
7643        if policy.trust_ctime && policy.check_stat {
7644            if ctime_sec != ie.ctime_sec {
7645                return false;
7646            }
7647            if policy.use_nanosec && ctime_nsec != ie.ctime_nsec {
7648                return false;
7649            }
7650        }
7651    }
7652    true
7653}
7654
7655/// Refresh cached stat data for stage-0 file/symlink entries whose worktree content still matches
7656/// the recorded OID but whose on-disk stat went stale.
7657///
7658/// This mirrors Git's `refresh_index` / `refresh_cache_ent`: an entry is only marked clean (stat
7659/// adopted from the worktree) after its content is re-verified against the index OID. A genuinely
7660/// modified entry keeps its stale stat so `diff-files` / `status` continue to report it. Operations
7661/// that rewrite the worktree (`status`, `reset --mixed`, `stash`) call this before writing the
7662/// index so a subsequent `git diff-files` sees refreshed entries as clean.
7663///
7664/// Gitlinks, sparse (`skip_worktree`), `assume_unchanged` and intent-to-add entries are skipped.
7665/// The blob comparison is a raw-content hash, so a CRLF-smudged match is conservatively missed
7666/// (the entry simply stays stat-dirty and is re-hashed next time — never the reverse).
7667///
7668/// `index_mtime` is the on-disk index file's `(mtime_sec, mtime_nsec)` (see
7669/// `entry_is_racy` / Git `is_racy_timestamp`); pass `None` when unknown — racy detection is
7670/// then skipped, which is conservative for tree-built indexes whose zeroed stat never matches.
7671///
7672/// `stat_parallel_threads` overrides parallel stat workers (`Some(1)` forces serial); when `None`,
7673/// honors `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
7674///
7675/// Returns `Ok(true)` when at least one entry was refreshed or invalidated, so callers can write
7676/// the index opportunistically (Git only persists a refresh that changed something).
7677pub fn refresh_index_stat_content_verified(
7678    odb: &Odb,
7679    git_dir: &Path,
7680    index: &mut Index,
7681    work_tree: &Path,
7682    index_mtime: Option<(u32, u32)>,
7683    config: Option<&ConfigSet>,
7684    _stat_parallel_threads: Option<usize>,
7685) -> Result<bool> {
7686    let _ = _stat_parallel_threads;
7687    refresh_index_stat_content_verified_with_rules(
7688        odb,
7689        git_dir,
7690        index,
7691        work_tree,
7692        index_mtime,
7693        config,
7694        _stat_parallel_threads,
7695        None,
7696    )
7697}
7698
7699/// Like [`refresh_index_stat_content_verified`], resolving attributes and conversion through a
7700/// shared [`crate::worktree_rules::WorktreeRules`] when one is supplied (its config snapshot then
7701/// takes precedence over `config`).
7702///
7703/// # Errors
7704///
7705/// Propagates I/O errors from the directory-grouped worktree scan.
7706#[allow(clippy::too_many_arguments)]
7707pub fn refresh_index_stat_content_verified_with_rules(
7708    odb: &Odb,
7709    git_dir: &Path,
7710    index: &mut Index,
7711    work_tree: &Path,
7712    index_mtime: Option<(u32, u32)>,
7713    config: Option<&ConfigSet>,
7714    stat_parallel_threads: Option<usize>,
7715    worktree_rules: Option<&std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
7716) -> Result<bool> {
7717    use crate::config::ConfigSet;
7718    use crate::crlf;
7719    use crate::worktree_scan::{
7720        collect_blob_lookup_batches, group_blob_entries_by_dir, visit_blob_lookup_batches,
7721        BlobDiskLookup, BlobScanSparseMode, WorktreeBlobScanOptions,
7722    };
7723
7724    let index_mtime = index_mtime_for_diff(index, index_mtime);
7725    let rules_lock = worktree_rules
7726        .map(|shared| {
7727            shared
7728                .lock()
7729                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
7730        })
7731        .transpose()?;
7732    let config = rules_lock
7733        .as_ref()
7734        .map(|r| r.config().clone())
7735        .unwrap_or_else(|| {
7736            config.cloned().unwrap_or_else(|| {
7737                ConfigSet::load(
7738                    &crate::environment::Environment::empty(),
7739                    Some(git_dir),
7740                    true,
7741                )
7742                .unwrap_or_default()
7743            })
7744        });
7745    let preload_index = config
7746        .get_bool("core.preloadIndex")
7747        .and_then(|r| r.ok())
7748        .unwrap_or(true);
7749    let blob_scan = WorktreeBlobScanOptions {
7750        preload_index,
7751        stat_parallel_threads,
7752        ..WorktreeBlobScanOptions::default()
7753    };
7754    let conv = rules_lock
7755        .as_ref()
7756        .map(|r| r.conversion().clone())
7757        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
7758    let legacy_attrs = if rules_lock.is_none() {
7759        Some(crlf::load_gitattributes_parsed(work_tree))
7760    } else {
7761        None
7762    };
7763    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
7764        if let Some(rules) = rules_lock.as_ref() {
7765            rules.file_attrs(path, false)
7766        } else if let Some(parsed) = legacy_attrs.as_ref() {
7767            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
7768        } else {
7769            crlf::FileAttrs::default()
7770        }
7771    };
7772    let precompose_unicode = config
7773        .get_bool("core.precomposeunicode")
7774        .and_then(|r| r.ok())
7775        .unwrap_or(false);
7776    let ignorecase = config
7777        .get_bool("core.ignorecase")
7778        .and_then(|r| r.ok())
7779        .unwrap_or(false);
7780    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
7781
7782    let blob_dirs = group_blob_entries_by_dir(index, false);
7783    let work_tree_owned = work_tree.to_path_buf();
7784    let dir_abs = move |dir: &[u8]| {
7785        if dir.is_empty() {
7786            work_tree_owned.clone()
7787        } else {
7788            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
7789        }
7790    };
7791    let work_tree_for_files = work_tree.to_path_buf();
7792    let file_abs = move |rel: &[u8]| {
7793        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
7794    };
7795    let mut changed = false;
7796    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
7797    let thread_policy = crate::parallel::ThreadPolicy::default();
7798    // Symlink ancestor detection must run even when core.symlinks is false.
7799    let refresh_batches = collect_blob_lookup_batches(
7800        index,
7801        &blob_dirs,
7802        blob_scan,
7803        &thread_policy,
7804        &config,
7805        dir_abs,
7806        file_abs,
7807        || {
7808            let mut dir_symlinks = SymlinkDirCache::default();
7809            move |rel_path: &[u8]| {
7810                dir_symlinks.has_symlink_in_path_bytes(
7811                    work_tree,
7812                    rel_path,
7813                    precompose_unicode,
7814                    ignorecase,
7815                )
7816            }
7817        },
7818        BlobScanSparseMode::StatRefresh {
7819            stat_policy,
7820            index_mtime,
7821        },
7822    )?;
7823    let par_opts = refresh_hash_parallel_options(&thread_policy, &config, stat_parallel_threads);
7824    let refresh_checks = refresh_content_checks(
7825        odb,
7826        index,
7827        &refresh_batches,
7828        index_mtime,
7829        stat_policy,
7830        &|_, _, _| true,
7831        &WorktreeHashContext {
7832            work_tree,
7833            precompose_unicode,
7834            ignorecase,
7835            conv: &conv,
7836            file_attrs_for: &file_attrs_for,
7837            filter_process: filter_fp,
7838        },
7839        par_opts,
7840        None,
7841    )?;
7842    visit_blob_lookup_batches(refresh_batches, |entry_index, lookup| {
7843        let BlobDiskLookup::Present(stat) = lookup else {
7844            return Ok(());
7845        };
7846        let content_matches = RefreshContent::matches(
7847            refresh_checks.get(entry_index),
7848            &index.entries[entry_index].oid,
7849        );
7850        let refresh = refresh_index_blob_stat_from_scan(
7851            &mut index.entries[entry_index],
7852            &stat,
7853            index_mtime,
7854            stat_policy,
7855            content_matches,
7856        );
7857        if refresh.index_changed {
7858            changed = true;
7859        }
7860        if refresh.content_verified {
7861            let path = index.entries[entry_index].path.clone();
7862            let oid = index.entries[entry_index].oid;
7863            index.mark_content_verified(&path, oid);
7864        }
7865        Ok(())
7866    })?;
7867    Ok(changed)
7868}
7869
7870/// Result of refreshing one index entry's cached stat during a worktree scan.
7871struct BlobStatRefreshOutcome {
7872    /// Whether the in-memory index entry was updated (stat fields or invalidation).
7873    index_changed: bool,
7874    /// Blob content was read and matches the recorded OID (skip a second hash in the same pass).
7875    content_verified: bool,
7876}
7877
7878/// Whether the stat refresh may adopt worktree stat data for `ie` at all.
7879fn refresh_index_entry_eligible(ie: &IndexEntry) -> bool {
7880    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
7881    ie.stage() == 0
7882        && !ie.skip_worktree()
7883        && !ie.assume_unchanged()
7884        && !ie.intent_to_add()
7885        && matches!(ie.mode, MODE_REGULAR | MODE_EXECUTABLE | MODE_SYMLINK)
7886}
7887
7888/// Whether refreshing `ie` from the scanned `stat` must first prove by content that the
7889/// worktree still holds `ie.oid`: the stat data changed, or it matches but is racy.
7890fn refresh_needs_content_check(
7891    ie: &IndexEntry,
7892    stat: &crate::worktree_scan::BlobDiskStat,
7893    index_mtime: Option<(u32, u32)>,
7894    stat_policy: crate::index::StatMatchPolicy,
7895) -> bool {
7896    refresh_index_entry_eligible(ie)
7897        && !stat.is_dir
7898        && (!stat_matches_blob_stat(ie, stat, stat_policy)
7899            || entry_is_racy_with_policy(ie, index_mtime, stat_policy))
7900}
7901
7902/// Whether a worktree diff with `refresh_index_stat_in_pass` refreshes `ie` from `stat`.
7903///
7904/// Status leaves out entries whose stat data already decides the answer: clean and not racy,
7905/// or a changed size that [`status_size_change_proves_modified`] accepts (reported as modified
7906/// without reading the file). A size change under a non-identity clean conversion is refreshed:
7907/// when the cleaned content still matches, the entry adopts the new stat, so later scans trust it.
7908pub(crate) fn worktree_diff_refreshes_entry(
7909    ie: &IndexEntry,
7910    stat: &crate::worktree_scan::BlobDiskStat,
7911    index_mtime: Option<(u32, u32)>,
7912    stat_policy: crate::index::StatMatchPolicy,
7913    for_status: bool,
7914    conversion_is_identity: impl FnOnce() -> bool,
7915) -> bool {
7916    if !for_status {
7917        return true;
7918    }
7919    if stat_matches_blob_stat(ie, stat, stat_policy) {
7920        return entry_is_racy_with_policy(ie, index_mtime, stat_policy);
7921    }
7922    !status_size_change_proves_modified(
7923        ie,
7924        stat.worktree_size(),
7925        for_status,
7926        conversion_is_identity,
7927    )
7928}
7929
7930/// Pool options for the stat refresh's content checks.
7931///
7932/// `thread_policy` and config pick the worker count; `stat_parallel_threads` caps it the same
7933/// way it caps the stat scan (`Some(0)` or `Some(1)` keeps hashing on the calling thread).
7934/// There is no byte threshold: opening each file, not hashing its bytes, dominates for the
7935/// small files a checkout leaves racy.
7936fn refresh_hash_parallel_options(
7937    thread_policy: &crate::parallel::ThreadPolicy,
7938    config: &ConfigSet,
7939    stat_parallel_threads: Option<usize>,
7940) -> crate::parallel::ParallelOptions {
7941    let policy = crate::parallel::ThreadPolicy {
7942        min_items_override: thread_policy.min_items_override.or(Some(0)),
7943        min_total_bytes_override: thread_policy.min_total_bytes_override.or(Some(0)),
7944        ..*thread_policy
7945    };
7946    let mut par_opts = policy.resolve(crate::parallel::ParallelOp::IndexRefresh, config);
7947    if let Some(cap) = stat_parallel_threads {
7948        par_opts.threads = par_opts
7949            .threads
7950            .min(std::num::NonZeroUsize::new(cap).unwrap_or(std::num::NonZeroUsize::MIN));
7951    }
7952    par_opts.min_items = 0;
7953    par_opts.min_total_bytes = None;
7954    par_opts
7955}
7956
7957/// Worktree layout and attribute context for hashing index entries during a scan.
7958struct WorktreeHashContext<'a> {
7959    work_tree: &'a Path,
7960    precompose_unicode: bool,
7961    ignorecase: bool,
7962    conv: &'a crate::crlf::ConversionConfig,
7963    file_attrs_for: &'a dyn Fn(&str) -> crate::crlf::FileAttrs,
7964    filter_process: Option<&'a crate::filter_process::FilterProcessState>,
7965}
7966
7967/// Run every content check the stat refresh needs before the serial visit of `batches`.
7968///
7969/// Racily clean entries are the common case right after a checkout writes the index: every
7970/// file then shares the index's timestamp second. Hashing them here, in parallel when
7971/// `par_opts` and the total size allow it, keeps that first scan from reading files one by one.
7972///
7973/// Returns a check for each scanned entry that `include` (given the entry index) admits and
7974/// [`refresh_needs_content_check`] selects. Read and filter failures are
7975/// [`RefreshContent::Failed`], so those entries stay unverified.
7976#[allow(clippy::too_many_arguments)]
7977fn refresh_content_checks(
7978    odb: &Odb,
7979    index: &Index,
7980    batches: &[Vec<(usize, crate::worktree_scan::BlobDiskLookup)>],
7981    index_mtime: Option<(u32, u32)>,
7982    stat_policy: crate::index::StatMatchPolicy,
7983    include: &dyn Fn(usize, &IndexEntry, &crate::worktree_scan::BlobDiskStat) -> bool,
7984    ctx: &WorktreeHashContext<'_>,
7985    par_opts: crate::parallel::ParallelOptions,
7986    cancel_watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
7987) -> Result<RefreshChecks> {
7988    use crate::progress::{poll_watch, CANCEL_POLL_STRIDE};
7989    use crate::worktree_scan::BlobDiskLookup;
7990
7991    /// Attributes are resolved on the calling thread: the shared rules are not `Sync`.
7992    struct ContentCheck {
7993        entry_index: usize,
7994        worktree_mode: u32,
7995        /// Position of the entry's attributes in `attrs`.
7996        attrs_index: usize,
7997    }
7998
7999    let mut attrs = DistinctFileAttrs::default();
8000    let mut jobs = Vec::new();
8001    let mut total_bytes = 0usize;
8002    let mut cancel_stride = 0usize;
8003    for (entry_index, lookup) in batches.iter().flatten() {
8004        cancel_stride = cancel_stride.wrapping_add(1);
8005        if cancel_stride.is_multiple_of(CANCEL_POLL_STRIDE) {
8006            if let Some(w) = cancel_watch {
8007                poll_watch(w)?;
8008            }
8009        }
8010        let BlobDiskLookup::Present(stat) = lookup else {
8011            continue;
8012        };
8013        let ie = &index.entries[*entry_index];
8014        if !refresh_needs_content_check(ie, stat, index_mtime, stat_policy)
8015            || !include(*entry_index, ie, stat)
8016        {
8017            continue;
8018        }
8019        total_bytes = total_bytes.saturating_add(ie.size as usize);
8020        let file_attrs = (ctx.file_attrs_for)(&crate::repo_relpath::storage_string(&ie.path));
8021        jobs.push(ContentCheck {
8022            entry_index: *entry_index,
8023            worktree_mode: stat.git_mode,
8024            attrs_index: attrs.intern(file_attrs),
8025        });
8026    }
8027    if jobs.is_empty() {
8028        return Ok(RefreshChecks::default());
8029    }
8030    let attrs = attrs.0;
8031    let mut checks = vec![None; index.entries.len()];
8032    let (work_tree, precompose_unicode, ignorecase) =
8033        (ctx.work_tree, ctx.precompose_unicode, ctx.ignorecase);
8034    let (conv, filter_process) = (ctx.conv, ctx.filter_process);
8035    let entries = index.entries.as_slice();
8036    let watch_for_hash = cancel_watch.cloned();
8037    let hash_one = |job: &ContentCheck| -> Result<RefreshContent> {
8038        if job.entry_index.is_multiple_of(CANCEL_POLL_STRIDE) {
8039            if let Some(w) = watch_for_hash.as_ref() {
8040                poll_watch(w)?;
8041            }
8042        }
8043        let ie = &entries[job.entry_index];
8044        let file_path =
8045            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
8046        Ok(RefreshContent::from(hash_worktree_file_with_mode(
8047            odb,
8048            &file_path,
8049            job.worktree_mode,
8050            conv,
8051            &attrs[job.attrs_index],
8052            &crate::repo_relpath::storage_string(&ie.path),
8053            Some(ie),
8054            filter_process,
8055        )))
8056    };
8057    let matches = if par_opts.threads.get() > 1 && jobs.len() > 1 {
8058        crate::parallel::try_par_map_force(&jobs, par_opts, hash_one).map_err(|e| match e {
8059            crate::parallel::ParallelHashError::Task(err) => err,
8060        })?
8061    } else {
8062        let mut out = Vec::with_capacity(jobs.len());
8063        let mut serial_stride = 0usize;
8064        for job in &jobs {
8065            serial_stride = serial_stride.wrapping_add(1);
8066            if serial_stride.is_multiple_of(CANCEL_POLL_STRIDE) {
8067                if let Some(w) = cancel_watch {
8068                    poll_watch(w)?;
8069                }
8070            }
8071            out.push(hash_one(job)?);
8072        }
8073        out
8074    };
8075    for (job, content) in jobs.iter().zip(matches) {
8076        checks[job.entry_index] = Some(content);
8077    }
8078    Ok(RefreshChecks(checks))
8079}
8080
8081/// The distinct attribute sets of the entries a scan hashes, so each entry stores an index
8082/// instead of its own [`FileAttrs`](crate::crlf::FileAttrs) (a few hundred bytes).
8083///
8084/// Nearly every path of a repository resolves to the same few sets. Lookups compare against
8085/// at most [`Self::SEARCH_LIMIT`] sets, the most recent first, so a tree with many distinct
8086/// sets stores some twice instead of scanning a long list for every entry.
8087#[derive(Default)]
8088struct DistinctFileAttrs(Vec<crate::crlf::FileAttrs>);
8089
8090impl DistinctFileAttrs {
8091    const SEARCH_LIMIT: usize = 16;
8092
8093    /// Index of `attrs` in the list, appending it when no recent set is equal.
8094    fn intern(&mut self, attrs: crate::crlf::FileAttrs) -> usize {
8095        let len = self.0.len();
8096        let found = self
8097            .0
8098            .iter()
8099            .rev()
8100            .take(Self::SEARCH_LIMIT)
8101            .position(|seen| *seen == attrs);
8102        if let Some(back) = found {
8103            return len - 1 - back;
8104        }
8105        self.0.push(attrs);
8106        len
8107    }
8108}
8109
8110/// Results of [`refresh_content_checks`], by index entry position.
8111///
8112/// Empty when no entry needed a check (a clean, non-racy work tree), so the common case
8113/// allocates nothing.
8114#[derive(Default)]
8115struct RefreshChecks(Vec<Option<RefreshContent>>);
8116
8117impl RefreshChecks {
8118    /// The check that ran for the entry at `entry_index`, if any.
8119    fn get(&self, entry_index: usize) -> Option<RefreshContent> {
8120        self.0.get(entry_index).copied().flatten()
8121    }
8122}
8123
8124/// What the stat refresh learned by hashing one entry's worktree file.
8125#[derive(Clone, Copy)]
8126enum RefreshContent {
8127    /// The file could not be read or clean-converted.
8128    Failed,
8129    /// The cleaned file hashes to this blob id.
8130    Hashed(ObjectId),
8131}
8132
8133impl From<Result<ObjectId>> for RefreshContent {
8134    fn from(hashed: Result<ObjectId>) -> Self {
8135        hashed.map_or(Self::Failed, Self::Hashed)
8136    }
8137}
8138
8139impl RefreshContent {
8140    /// Whether `check` proved the worktree still holds `oid`.
8141    fn matches(check: Option<Self>, oid: &ObjectId) -> bool {
8142        matches!(check, Some(Self::Hashed(hashed)) if hashed == *oid)
8143    }
8144
8145    /// Whether `check` proved the worktree holds other content than `oid`.
8146    fn differs(check: Option<Self>, oid: &ObjectId) -> bool {
8147        matches!(check, Some(Self::Hashed(hashed)) if hashed != *oid)
8148    }
8149}
8150
8151/// Adopt the scanned `stat` for `ie` where that cannot hide a worktree change.
8152///
8153/// `content_matches` is the [`refresh_content_checks`] result for this entry; it is only
8154/// consulted when [`refresh_needs_content_check`] holds. A racy entry whose content no longer
8155/// matches has its stat cache invalidated so later scans re-hash it.
8156fn refresh_index_blob_stat_from_scan(
8157    ie: &mut IndexEntry,
8158    stat: &crate::worktree_scan::BlobDiskStat,
8159    index_mtime: Option<(u32, u32)>,
8160    stat_policy: crate::index::StatMatchPolicy,
8161    content_matches: bool,
8162) -> BlobStatRefreshOutcome {
8163    const UNCHANGED: BlobStatRefreshOutcome = BlobStatRefreshOutcome {
8164        index_changed: false,
8165        content_verified: false,
8166    };
8167    if !refresh_index_entry_eligible(ie) || stat.is_dir {
8168        return UNCHANGED;
8169    }
8170    let cached = crate::index::IndexStatFields::of_entry(ie);
8171    let stat_same = cached.equivalent_to(&stat.fields, stat_policy);
8172    let stat_matches = stat_matches_blob_stat(ie, stat, stat_policy);
8173    if !stat_matches || entry_is_racy_with_policy(ie, index_mtime, stat_policy) {
8174        if !content_matches {
8175            if stat_matches {
8176                invalidate_index_stat_cache(ie);
8177                return BlobStatRefreshOutcome {
8178                    index_changed: true,
8179                    content_verified: false,
8180                };
8181            }
8182            return UNCHANGED;
8183        }
8184        if !stat_same {
8185            stat.fields.store_in(ie);
8186        }
8187        return BlobStatRefreshOutcome {
8188            index_changed: !stat_same,
8189            content_verified: true,
8190        };
8191    }
8192    if stat_same {
8193        return BlobStatRefreshOutcome {
8194            index_changed: false,
8195            content_verified: true,
8196        };
8197    }
8198    stat.fields.store_in(ie);
8199    BlobStatRefreshOutcome {
8200        index_changed: true,
8201        content_verified: false,
8202    }
8203}
8204
8205/// Whether path-only checkout can skip writing a blob because the worktree is up to date.
8206///
8207/// Matching stat data is trusted unless the entry is racy relative to the on-disk index mtime;
8208/// then the worktree content (after clean/filter conversion) must hash to `expected_oid`
8209/// ([`hash_worktree_file`]). `config` is an optional preloaded configuration.
8210///
8211/// Returns `Ok(true)` when the caller should skip rewriting the path.
8212///
8213/// # Errors
8214///
8215/// Propagates I/O or hashing failures from [`hash_worktree_file`].
8216#[allow(clippy::too_many_arguments)]
8217pub(crate) fn path_checkout_skip_blob_write_when_up_to_date_with_config(
8218    odb: &Odb,
8219    git_dir: &Path,
8220    work_tree: &Path,
8221    index: &Index,
8222    entry: &IndexEntry,
8223    expected_oid: &ObjectId,
8224    expected_mode: u32,
8225    rel_path: &str,
8226    filter_process: Option<&crate::filter_process::FilterProcessState>,
8227    preloaded_config: Option<&crate::config::ConfigSet>,
8228) -> Result<bool> {
8229    use crate::config::ConfigSet;
8230    use crate::crlf;
8231    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR};
8232
8233    if entry.oid != *expected_oid || entry.mode != expected_mode {
8234        return Ok(false);
8235    }
8236    if expected_mode != MODE_REGULAR && expected_mode != MODE_EXECUTABLE {
8237        return Ok(false);
8238    }
8239    let abs_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
8240    let meta = match fs::symlink_metadata(&abs_path) {
8241        Ok(m) => m,
8242        Err(_) => return Ok(false),
8243    };
8244    if !meta.is_file() || !stat_matches(entry, &meta) {
8245        return Ok(false);
8246    }
8247    let owned_config;
8248    let config = match preloaded_config {
8249        Some(cfg) => cfg,
8250        None => {
8251            owned_config = ConfigSet::load(
8252                &crate::environment::Environment::empty(),
8253                Some(git_dir),
8254                true,
8255            )
8256            .unwrap_or_default();
8257            &owned_config
8258        }
8259    };
8260    if !crate::index::WorktreeModePolicy::from_config(config)
8261        .worktree_mode_matches(mode_from_metadata(&meta), expected_mode)
8262    {
8263        return Ok(false);
8264    }
8265    let index_mtime = index_mtime_for_diff(index, None);
8266    if !entry_is_racy(entry, index_mtime) {
8267        return Ok(true);
8268    }
8269    let conv = crlf::ConversionConfig::from_config(config);
8270    let attrs = crlf::load_gitattributes(work_tree);
8271    let file_attrs = crlf::get_file_attrs(&attrs, rel_path, false, config);
8272    let wt_oid = hash_worktree_file(
8273        odb,
8274        &abs_path,
8275        &meta,
8276        &conv,
8277        &file_attrs,
8278        rel_path,
8279        Some(entry),
8280        filter_process,
8281    )?;
8282    Ok(wt_oid == *expected_oid)
8283}
8284
8285/// Clear cached stat fields so the next diff/status pass re-reads the work tree.
8286fn invalidate_index_stat_cache(ie: &mut IndexEntry) {
8287    ie.ctime_sec = 0;
8288    ie.ctime_nsec = 0;
8289    ie.mtime_sec = 0;
8290    ie.mtime_nsec = 0;
8291    ie.dev = 0;
8292    ie.ino = 0;
8293    ie.size = 0;
8294}
8295
8296/// Read worktree content for diffing, honoring Git tree modes.
8297///
8298/// For [`MODE_SYMLINK`] entries backed by a symlink on disk this returns the link target path
8299/// bytes (via [`read_link`](std::fs::read_link)), not the contents of the followed file. A
8300/// symlink entry checked out as a plain file (`core.symlinks=false`) already holds the target,
8301/// so it is read like other modes, with a plain [`read`](std::fs::read) of the path.
8302///
8303/// # Errors
8304///
8305/// Returns [`std::io::Error`] when the path cannot be read.
8306pub fn read_worktree_bytes_for_mode(path: &Path, mode: u32) -> std::io::Result<Vec<u8>> {
8307    if mode == MODE_SYMLINK && fs::symlink_metadata(path)?.file_type().is_symlink() {
8308        let target = fs::read_link(path)?;
8309        return Ok(symlink_target_bytes(&target));
8310    }
8311    fs::read(path)
8312}
8313
8314/// Read worktree file bytes in Git (repository) form for line diffs against index or tree blobs.
8315///
8316/// Applies the same `.gitattributes`, filter, and line-ending rules as
8317/// [`diff_index_to_worktree`]. Pass the stage-0 [`IndexEntry`] when available so
8318/// `autocrlf` index-guard normalization matches status and raw diff collection.
8319///
8320/// # Errors
8321///
8322/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
8323pub fn read_worktree_side_bytes_for_diff(
8324    odb: &Odb,
8325    work_tree: &Path,
8326    rel_path: &str,
8327    mode: u32,
8328    config: &crate::config::ConfigSet,
8329    index_entry: Option<&IndexEntry>,
8330    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8331) -> Result<Vec<u8>> {
8332    Ok(load_worktree_side_for_diff(
8333        odb,
8334        work_tree,
8335        rel_path,
8336        mode,
8337        config,
8338        index_entry,
8339        worktree_rules,
8340        false,
8341    )?
8342    .0
8343    .data)
8344}
8345
8346/// Load worktree bytes for diffing, optionally skipping reads above `core.bigFileThreshold`.
8347///
8348/// Returns empty [`super::structured::SideBytes`] with `binary = true` when the file is not read.
8349/// When `force_read_content` is true, large files are read and binary payload bytes are retained
8350/// (for `GIT binary patch` / `git apply`).
8351///
8352/// # Errors
8353///
8354/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
8355#[allow(clippy::too_many_arguments)]
8356pub fn load_worktree_side_for_diff(
8357    odb: &Odb,
8358    work_tree: &Path,
8359    rel_path: &str,
8360    mode: u32,
8361    config: &crate::config::ConfigSet,
8362    index_entry: Option<&IndexEntry>,
8363    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8364    force_read_content: bool,
8365) -> Result<(super::structured::SideBytes, bool)> {
8366    use super::tree_content::{side_bytes_after_blob_read, treat_side_as_binary_without_reading};
8367    let file_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
8368    let rel_match = crate::repo_relpath::as_utf8_for_matching(rel_path);
8369    let meta = fs::symlink_metadata(&file_path).map_err(|source| {
8370        crate::worktree::map_worktree_io(
8371            rel_match.as_ref(),
8372            crate::worktree::WorktreePathOperation::Metadata,
8373            source,
8374        )
8375    })?;
8376    if mode == MODE_SYMLINK && meta.file_type().is_symlink() {
8377        let target = fs::read_link(&file_path).map_err(|source| {
8378            crate::worktree::map_worktree_io(
8379                rel_match.as_ref(),
8380                crate::worktree::WorktreePathOperation::Read,
8381                source,
8382            )
8383        })?;
8384        return Ok((
8385            super::structured::SideBytes::from_bytes(symlink_target_bytes(&target)),
8386            false,
8387        ));
8388    }
8389    if !force_read_content
8390        && treat_side_as_binary_without_reading(odb, meta.len(), Some(rel_match.as_ref()), false)?
8391    {
8392        return Ok((super::structured::SideBytes::from_bytes(Vec::new()), true));
8393    }
8394    let data = if let Some(rules) = worktree_rules {
8395        worktree_blob_bytes(
8396            odb,
8397            &file_path,
8398            &meta,
8399            rules.conversion(),
8400            &rules.file_attrs(rel_match.as_ref(), meta.is_dir()),
8401            rel_match.as_ref(),
8402            index_entry,
8403            Some(rules.filter_process()),
8404        )?
8405    } else {
8406        let conv = crate::crlf::ConversionConfig::from_config(config);
8407        let attrs = crate::crlf::load_gitattributes(work_tree);
8408        let file_attrs =
8409            crate::crlf::get_file_attrs(&attrs, rel_match.as_ref(), meta.is_dir(), config);
8410        worktree_blob_bytes(
8411            odb,
8412            &file_path,
8413            &meta,
8414            &conv,
8415            &file_attrs,
8416            rel_match.as_ref(),
8417            index_entry,
8418            None,
8419        )?
8420    };
8421    Ok(side_bytes_after_blob_read(data, force_read_content))
8422}
8423
8424/// Symlink target as the byte string Git hashes for the blob OID.
8425///
8426/// On Unix the raw `OsStr` bytes are used verbatim. On Windows `OsStr` is WTF-8
8427/// and has no stable byte view, so the lossy UTF-8 form is used (symlink targets
8428/// in Git trees are UTF-8 in practice).
8429pub(crate) fn symlink_target_bytes(target: &Path) -> Vec<u8> {
8430    #[cfg(unix)]
8431    {
8432        use std::os::unix::ffi::OsStrExt as _;
8433        target.as_os_str().as_bytes().to_vec()
8434    }
8435    #[cfg(not(unix))]
8436    {
8437        target.as_os_str().to_string_lossy().as_bytes().to_vec()
8438    }
8439}
8440
8441/// A worktree file's blob content: normalized bytes, or an open file that is hashed or
8442/// stored as a stream.
8443///
8444/// Regular files of at least the caller's size threshold whose bytes can be stored without clean
8445/// conversion ([`crate::crlf::worktree_allows_raw_byte_stream`], including binary files under
8446/// `core.autocrlf`) are streamed so memory use does not depend on the file size.
8447pub(crate) enum WorktreeBlobContent {
8448    /// Content after the clean conversion (filters, encoding, line endings).
8449    Bytes(Vec<u8>),
8450    /// Unconverted big file, positioned at its start.
8451    Stream {
8452        /// Open handle on the worktree file.
8453        file: fs::File,
8454        /// File size when it was opened.
8455        size: u64,
8456    },
8457}
8458
8459impl WorktreeBlobContent {
8460    /// Object id of the content, without storing it.
8461    pub(crate) fn hash(self, odb: &Odb) -> Result<ObjectId> {
8462        match self {
8463            Self::Bytes(data) => odb.hash(ObjectKind::Blob, &data),
8464            Self::Stream { mut file, size } => odb.hash_stream(ObjectKind::Blob, size, &mut file),
8465        }
8466    }
8467
8468    /// Store the content as a blob and return its id.
8469    pub(crate) fn store(self, odb: &Odb, options: crate::odb::WriteOptions) -> Result<ObjectId> {
8470        match self {
8471            Self::Bytes(data) => odb.write_with_options(ObjectKind::Blob, &data, options),
8472            Self::Stream { mut file, size } => {
8473                odb.write_stream(ObjectKind::Blob, size, &mut file, options)
8474            }
8475        }
8476    }
8477
8478    /// [`Self::store`] with `options` when given, otherwise [`Self::hash`].
8479    fn object_id(self, odb: &Odb, store: Option<crate::odb::WriteOptions>) -> Result<ObjectId> {
8480        match store {
8481            Some(options) => self.store(odb, options),
8482            None => self.hash(odb),
8483        }
8484    }
8485}
8486
8487/// Read and clean-convert an open regular worktree file of `size` bytes.
8488#[allow(clippy::too_many_arguments)]
8489fn read_regular_worktree_bytes(
8490    odb: &Odb,
8491    mut file: fs::File,
8492    size: u64,
8493    conv: &crate::crlf::ConversionConfig,
8494    file_attrs: &crate::crlf::FileAttrs,
8495    rel_path: &str,
8496    index_entry: Option<&IndexEntry>,
8497    filter_process: Option<&crate::filter_process::FilterProcessState>,
8498) -> Result<Vec<u8>> {
8499    let needs_index_blob =
8500        index_entry.is_some_and(|_| crate::crlf::clean_uses_autocrlf_index_guard(file_attrs, conv));
8501    let prior_blob: Option<Vec<u8>> = if needs_index_blob {
8502        index_entry
8503            .filter(|e| e.oid != zero_oid())
8504            .and_then(|e| odb.read(&e.oid).ok().map(|o| o.data))
8505    } else {
8506        None
8507    };
8508    let mut raw = Vec::with_capacity(usize::try_from(size).unwrap_or_default());
8509    std::io::Read::read_to_end(&mut file, &mut raw).map_err(|source| {
8510        crate::worktree::map_worktree_io(
8511            rel_path,
8512            crate::worktree::WorktreePathOperation::Read,
8513            source,
8514        )
8515    })?;
8516    #[cfg(test)]
8517    odb.hot_path_metrics().record_blob_content_read();
8518    let opts = crate::crlf::ConvertToGitOpts {
8519        index_blob: prior_blob.as_deref(),
8520        renormalize: false,
8521        check_safecrlf: false,
8522        filter_process,
8523        command_runner: None,
8524    };
8525    Ok(crate::crlf::convert_to_git_owned(
8526        raw, rel_path, conv, file_attrs, opts,
8527    )?)
8528}
8529
8530fn open_with_size(path: &Path, rel_path: &str) -> Result<(fs::File, u64)> {
8531    let file = fs::File::open(path).map_err(|source| {
8532        crate::worktree::map_worktree_io(
8533            rel_path,
8534            crate::worktree::WorktreePathOperation::Read,
8535            source,
8536        )
8537    })?;
8538    let size = file
8539        .metadata()
8540        .map_err(|source| {
8541            crate::worktree::map_worktree_io(
8542                rel_path,
8543                crate::worktree::WorktreePathOperation::Metadata,
8544                source,
8545            )
8546        })?
8547        .len();
8548    Ok((file, size))
8549}
8550
8551/// Open a regular worktree file and clean-convert it, or hand it out as a stream when it is
8552/// at least `stream_from` bytes and its clean conversion is the identity.
8553#[allow(clippy::too_many_arguments)]
8554fn read_regular_worktree_blob(
8555    odb: &Odb,
8556    path: &Path,
8557    conv: &crate::crlf::ConversionConfig,
8558    file_attrs: &crate::crlf::FileAttrs,
8559    rel_path: &str,
8560    index_entry: Option<&IndexEntry>,
8561    filter_process: Option<&crate::filter_process::FilterProcessState>,
8562    stream_from: u64,
8563) -> Result<WorktreeBlobContent> {
8564    let (mut file, size) = open_with_size(path, rel_path)?;
8565    if size >= stream_from
8566        && crate::crlf::worktree_allows_raw_byte_stream(
8567            conv,
8568            file_attrs,
8569            index_entry,
8570            &mut file,
8571            size,
8572        )?
8573    {
8574        #[cfg(test)]
8575        odb.hot_path_metrics().record_blob_content_read();
8576        return Ok(WorktreeBlobContent::Stream { file, size });
8577    }
8578    read_regular_worktree_bytes(
8579        odb,
8580        file,
8581        size,
8582        conv,
8583        file_attrs,
8584        rel_path,
8585        index_entry,
8586        filter_process,
8587    )
8588    .map(WorktreeBlobContent::Bytes)
8589}
8590
8591/// Blob content for a worktree path: symlink target, empty for a directory, or the
8592/// clean-converted (or streamed, see [`WorktreeBlobContent`]) file content.
8593#[allow(clippy::too_many_arguments)]
8594pub(crate) fn worktree_blob_content(
8595    odb: &Odb,
8596    path: &Path,
8597    meta: &fs::Metadata,
8598    conv: &crate::crlf::ConversionConfig,
8599    file_attrs: &crate::crlf::FileAttrs,
8600    rel_path: &str,
8601    index_entry: Option<&IndexEntry>,
8602    filter_process: Option<&crate::filter_process::FilterProcessState>,
8603) -> Result<WorktreeBlobContent> {
8604    worktree_blob_content_from(
8605        odb,
8606        path,
8607        meta,
8608        conv,
8609        file_attrs,
8610        rel_path,
8611        index_entry,
8612        filter_process,
8613        hash_stream_threshold(odb)?,
8614    )
8615}
8616
8617/// [`worktree_blob_content`], streaming unconverted regular files from `stream_from` bytes.
8618#[allow(clippy::too_many_arguments)]
8619fn worktree_blob_content_from(
8620    odb: &Odb,
8621    path: &Path,
8622    meta: &fs::Metadata,
8623    conv: &crate::crlf::ConversionConfig,
8624    file_attrs: &crate::crlf::FileAttrs,
8625    rel_path: &str,
8626    index_entry: Option<&IndexEntry>,
8627    filter_process: Option<&crate::filter_process::FilterProcessState>,
8628    stream_from: u64,
8629) -> Result<WorktreeBlobContent> {
8630    if meta.file_type().is_symlink() {
8631        let target = fs::read_link(path).map_err(|source| {
8632            crate::worktree::map_worktree_io(
8633                rel_path,
8634                crate::worktree::WorktreePathOperation::Read,
8635                source,
8636            )
8637        })?;
8638        return Ok(WorktreeBlobContent::Bytes(symlink_target_bytes(&target)));
8639    }
8640    if meta.is_dir() {
8641        return Ok(WorktreeBlobContent::Bytes(Vec::new()));
8642    }
8643    read_regular_worktree_blob(
8644        odb,
8645        path,
8646        conv,
8647        file_attrs,
8648        rel_path,
8649        index_entry,
8650        filter_process,
8651        stream_from,
8652    )
8653}
8654
8655/// Like [`worktree_blob_content`], always returning the bytes (for callers that need the
8656/// content itself, such as line diffs).
8657#[allow(clippy::too_many_arguments)]
8658pub(crate) fn worktree_blob_bytes(
8659    odb: &Odb,
8660    path: &Path,
8661    meta: &fs::Metadata,
8662    conv: &crate::crlf::ConversionConfig,
8663    file_attrs: &crate::crlf::FileAttrs,
8664    rel_path: &str,
8665    index_entry: Option<&IndexEntry>,
8666    filter_process: Option<&crate::filter_process::FilterProcessState>,
8667) -> Result<Vec<u8>> {
8668    if meta.file_type().is_symlink() {
8669        let target = fs::read_link(path).map_err(|source| {
8670            crate::worktree::map_worktree_io(
8671                rel_path,
8672                crate::worktree::WorktreePathOperation::Read,
8673                source,
8674            )
8675        })?;
8676        return Ok(symlink_target_bytes(&target));
8677    }
8678    if meta.is_dir() {
8679        return Ok(Vec::new());
8680    }
8681    let (file, size) = open_with_size(path, rel_path)?;
8682    read_regular_worktree_bytes(
8683        odb,
8684        file,
8685        size,
8686        conv,
8687        file_attrs,
8688        rel_path,
8689        index_entry,
8690        filter_process,
8691    )
8692}
8693
8694#[allow(clippy::too_many_arguments)]
8695fn worktree_file_oid(
8696    odb: &Odb,
8697    path: &Path,
8698    meta: &fs::Metadata,
8699    conv: &crate::crlf::ConversionConfig,
8700    file_attrs: &crate::crlf::FileAttrs,
8701    rel_path: &str,
8702    index_entry: Option<&IndexEntry>,
8703    materialize: bool,
8704    filter_process: Option<&crate::filter_process::FilterProcessState>,
8705) -> Result<ObjectId> {
8706    let stream_from = hash_stream_threshold(odb)?;
8707    worktree_blob_content_from(
8708        odb,
8709        path,
8710        meta,
8711        conv,
8712        file_attrs,
8713        rel_path,
8714        index_entry,
8715        filter_process,
8716        stream_from,
8717    )?
8718    .object_id(odb, materialize.then(|| odb.bulk_stage_write_options()))
8719}
8720
8721/// Hash normalized worktree bytes for a path (does not write to the object database).
8722///
8723/// Files that need no clean conversion are hashed as a stream, without reading them into
8724/// memory, from 1 MiB (or `core.bigFileThreshold` when that is smaller).
8725///
8726/// # Errors
8727///
8728/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
8729/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
8730/// stored or hashed unfiltered in that case.
8731#[allow(clippy::too_many_arguments)]
8732pub fn hash_worktree_file(
8733    odb: &Odb,
8734    path: &Path,
8735    meta: &fs::Metadata,
8736    conv: &crate::crlf::ConversionConfig,
8737    file_attrs: &crate::crlf::FileAttrs,
8738    rel_path: &str,
8739    index_entry: Option<&IndexEntry>,
8740    filter_process: Option<&crate::filter_process::FilterProcessState>,
8741) -> Result<ObjectId> {
8742    worktree_file_oid(
8743        odb,
8744        path,
8745        meta,
8746        conv,
8747        file_attrs,
8748        rel_path,
8749        index_entry,
8750        false,
8751        filter_process,
8752    )
8753}
8754
8755/// Work-tree files at least this large are hashed as a stream (when their content goes into
8756/// Git unchanged) even below `core.bigFileThreshold`, which only governs how blobs are stored.
8757const STREAMED_HASH_MIN_SIZE: u64 = 1024 * 1024;
8758
8759/// Size from which an unconverted work-tree file is hashed as a stream rather than read whole.
8760fn hash_stream_threshold(odb: &Odb) -> Result<u64> {
8761    Ok(odb.big_file_threshold()?.min(STREAMED_HASH_MIN_SIZE))
8762}
8763
8764/// Like [`hash_worktree_file`], for callers that already know the worktree file type from a
8765/// directory scan (`worktree_mode` is the Git mode derived from that stat), so no metadata
8766/// probe is needed.
8767#[allow(clippy::too_many_arguments)]
8768pub(crate) fn hash_worktree_file_with_mode(
8769    odb: &Odb,
8770    path: &Path,
8771    worktree_mode: u32,
8772    conv: &crate::crlf::ConversionConfig,
8773    file_attrs: &crate::crlf::FileAttrs,
8774    rel_path: &str,
8775    index_entry: Option<&IndexEntry>,
8776    filter_process: Option<&crate::filter_process::FilterProcessState>,
8777) -> Result<ObjectId> {
8778    if worktree_mode == MODE_SYMLINK {
8779        let data = read_worktree_bytes_for_mode(path, worktree_mode)?;
8780        return odb.hash(ObjectKind::Blob, &data);
8781    }
8782    read_regular_worktree_blob(
8783        odb,
8784        path,
8785        conv,
8786        file_attrs,
8787        rel_path,
8788        index_entry,
8789        filter_process,
8790        hash_stream_threshold(odb)?,
8791    )?
8792    .hash(odb)
8793}
8794
8795/// Read, normalize (CRLF/attributes), and store a worktree blob once.
8796///
8797/// # Errors
8798///
8799/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
8800/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
8801/// stored or hashed unfiltered in that case.
8802#[allow(clippy::too_many_arguments)]
8803pub fn materialize_worktree_blob(
8804    odb: &Odb,
8805    path: &Path,
8806    meta: &fs::Metadata,
8807    conv: &crate::crlf::ConversionConfig,
8808    file_attrs: &crate::crlf::FileAttrs,
8809    rel_path: &str,
8810    index_entry: Option<&IndexEntry>,
8811    filter_process: Option<&crate::filter_process::FilterProcessState>,
8812) -> Result<ObjectId> {
8813    worktree_file_oid(
8814        odb,
8815        path,
8816        meta,
8817        conv,
8818        file_attrs,
8819        rel_path,
8820        index_entry,
8821        true,
8822        filter_process,
8823    )
8824}
8825
8826/// Derive a Git file mode from filesystem metadata.
8827pub fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
8828    if meta.file_type().is_symlink() {
8829        0o120000
8830    } else {
8831        #[cfg(unix)]
8832        {
8833            if meta.mode() & 0o111 != 0 {
8834                return 0o100755;
8835            }
8836        }
8837        0o100644
8838    }
8839}
8840
8841/// Compare a tree against the working tree.
8842///
8843/// Shows changes from `tree_oid` to the current working directory state.
8844/// Files tracked in the index but not in the tree are shown as Added.
8845/// Files in the tree but missing from the working tree are shown as Deleted.
8846///
8847/// # Parameters
8848///
8849/// - `odb` — object database.
8850/// - `tree_oid` — the tree to compare against (`None` for empty tree).
8851/// - `work_tree` — path to the working tree root.
8852/// - `index` — current index (used to discover new tracked files not in tree).
8853///
8854/// # Errors
8855///
8856/// Returns errors from ODB reads or I/O.
8857pub fn diff_tree_to_worktree(
8858    odb: &Odb,
8859    tree_oid: Option<&ObjectId>,
8860    work_tree: &Path,
8861    index: &Index,
8862) -> Result<Vec<DiffEntry>> {
8863    diff_tree_to_worktree_with_git_dir(
8864        odb,
8865        tree_oid,
8866        work_tree,
8867        &work_tree.join(".git"),
8868        index,
8869        None,
8870    )
8871}
8872
8873/// Like [`diff_tree_to_worktree`], but loads config from `repository_git_dir` (linked worktrees / gitfiles).
8874///
8875/// Pass `config` when the caller already holds a repository config snapshot.
8876pub fn diff_tree_to_worktree_with_git_dir(
8877    odb: &Odb,
8878    tree_oid: Option<&ObjectId>,
8879    work_tree: &Path,
8880    repository_git_dir: &Path,
8881    index: &Index,
8882    config: Option<&ConfigSet>,
8883) -> Result<Vec<DiffEntry>> {
8884    diff_tree_to_worktree_with_git_dir_and_rules(
8885        odb,
8886        tree_oid,
8887        work_tree,
8888        repository_git_dir,
8889        index,
8890        config,
8891        None,
8892    )
8893}
8894
8895/// Like [`diff_tree_to_worktree_with_git_dir`], with optional shared [`crate::worktree_rules::WorktreeRules`].
8896pub fn diff_tree_to_worktree_with_git_dir_and_rules(
8897    odb: &Odb,
8898    tree_oid: Option<&ObjectId>,
8899    work_tree: &Path,
8900    repository_git_dir: &Path,
8901    index: &Index,
8902    config: Option<&ConfigSet>,
8903    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8904) -> Result<Vec<DiffEntry>> {
8905    diff_tree_to_worktree_with_git_dir_and_rules_extras(
8906        odb,
8907        tree_oid,
8908        work_tree,
8909        repository_git_dir,
8910        index,
8911        config,
8912        worktree_rules,
8913        DiffTreeToWorktreeExtras::default(),
8914    )
8915}
8916
8917/// Like [`diff_tree_to_worktree_with_git_dir_and_rules`], with worktree access policy for the slow path.
8918#[allow(clippy::too_many_arguments)]
8919pub fn diff_tree_to_worktree_with_git_dir_and_rules_extras(
8920    odb: &Odb,
8921    tree_oid: Option<&ObjectId>,
8922    work_tree: &Path,
8923    repository_git_dir: &Path,
8924    index: &Index,
8925    config: Option<&ConfigSet>,
8926    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8927    extras: DiffTreeToWorktreeExtras,
8928) -> Result<Vec<DiffEntry>> {
8929    use crate::config::ConfigSet;
8930    use crate::crlf;
8931
8932    if worktree_rules.is_none() {
8933        if let Some(tree_oid) = tree_oid {
8934            if index.cache_tree_root.as_ref() == Some(tree_oid)
8935                && crate::write_tree::cache_tree_fully_valid(odb, index.cache_tree.as_ref())
8936            {
8937                let mut idx = index.clone();
8938                let options = DiffIndexToWorktreeOptions {
8939                    repository_git_dir: Some(repository_git_dir.to_path_buf()),
8940                    refresh_index_stat_in_pass: false,
8941                    // Tree↔worktree raw lines use a null worktree OID when content differs; the
8942                    // status fast path (`for_status`) uses the same null OID for size-only drift
8943                    // without hashing (see `status_size_change_proves_modified`).
8944                    for_status: false,
8945                    config: config.cloned().map(std::sync::Arc::new),
8946                    ..DiffIndexToWorktreeOptions::default()
8947                };
8948                let (entries, _, _) = diff_index_to_worktree_inner(
8949                    odb,
8950                    &mut idx,
8951                    work_tree,
8952                    repository_git_dir,
8953                    options,
8954                    false,
8955                    None,
8956                )?;
8957                return Ok(entries);
8958            }
8959        }
8960    }
8961
8962    let config = worktree_rules
8963        .map(|r| r.config().clone())
8964        .unwrap_or_else(|| {
8965            config.cloned().unwrap_or_else(|| {
8966                ConfigSet::load(
8967                    &crate::environment::Environment::empty(),
8968                    Some(repository_git_dir),
8969                    true,
8970                )
8971                .unwrap_or_else(|_| ConfigSet::new())
8972            })
8973        });
8974    let conv = worktree_rules
8975        .map(|r| r.conversion().clone())
8976        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
8977    let legacy_attrs = if worktree_rules.is_none() {
8978        Some(crlf::load_gitattributes_parsed(work_tree))
8979    } else {
8980        None
8981    };
8982    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
8983        if let Some(rules) = worktree_rules {
8984            rules.file_attrs(path, false)
8985        } else if let Some(parsed) = legacy_attrs.as_ref() {
8986            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
8987        } else {
8988            crlf::FileAttrs::default()
8989        }
8990    };
8991    let filter_fp = worktree_rules.map(|r| r.filter_process());
8992
8993    let sparse_index = index.has_sparse_directory_placeholders();
8994    // Flatten the tree into a BTreeMap keyed by path
8995    let tree_flat = match tree_oid {
8996        Some(oid) if sparse_index => flatten_tree_with_index(odb, oid, "", Some(index))?,
8997        Some(oid) => flatten_tree(odb, oid, "")?,
8998        None => Vec::new(),
8999    };
9000    let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
9001        tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
9002
9003    // Build index lookup: path → &IndexEntry (stage 0 only)
9004    let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
9005        std::collections::BTreeMap::new();
9006    let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
9007    let mut stage0_paths: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
9008    for ie in &index.entries {
9009        if ie.stage() != 0 {
9010            continue;
9011        }
9012        let path = String::from_utf8_lossy(&ie.path).to_string();
9013        index_entries.insert(&ie.path, ie);
9014        index_paths.insert(path);
9015        stage0_paths.insert(ie.path.clone());
9016    }
9017
9018    // Paths with only unmerged stages (1–3) and no stage 0 — `git diff <rev>` must still list them
9019    // so combined `diff --cc` conflict hunks can be emitted (`t4108-apply-threeway`).
9020    let mut unmerged_only_paths: std::collections::BTreeSet<String> =
9021        std::collections::BTreeSet::new();
9022    for ie in &index.entries {
9023        if !(1..=3).contains(&ie.stage()) {
9024            continue;
9025        }
9026        if stage0_paths.contains(&ie.path) {
9027            continue;
9028        }
9029        unmerged_only_paths.insert(String::from_utf8_lossy(&ie.path).into_owned());
9030    }
9031
9032    // Union of tree paths + index paths
9033    let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
9034    all_paths.extend(tree_map.keys().cloned());
9035    all_paths.extend(index_paths.iter().cloned());
9036    all_paths.extend(unmerged_only_paths.iter().cloned());
9037
9038    let index_mtime = index_mtime_for_diff(index, None);
9039    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
9040    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
9041    let mut result = Vec::new();
9042
9043    for path in &all_paths {
9044        if extras
9045            .restrict_to_paths
9046            .as_ref()
9047            .is_some_and(|limit| !limit.contains(path))
9048        {
9049            continue;
9050        }
9051        if index_entries
9052            .get(path.as_bytes())
9053            .is_some_and(|ie| ie.skip_worktree())
9054        {
9055            // Sparse checkout: `git diff <rev>` does not report tree↔worktree drift for
9056            // skip-worktree paths (they are outside the sparse cone). Matches t7012 stash flow.
9057            continue;
9058        }
9059        if index_entries
9060            .get(path.as_bytes())
9061            .is_some_and(|ie| ie.assume_unchanged())
9062        {
9063            continue;
9064        }
9065
9066        let tree_entry = tree_map.get(path.as_str());
9067
9068        // Gitlink entries (submodules) — compare HEAD commit, not file content.
9069        let is_gitlink = tree_entry.is_some_and(|te| te.mode == 0o160000)
9070            || index_entries
9071                .get(path.as_bytes())
9072                .is_some_and(|ie| ie.mode == 0o160000);
9073        if is_gitlink {
9074            let sub_dir = work_tree.join(path);
9075            let index_gitlink_oid = index_entries
9076                .get(path.as_bytes())
9077                .filter(|ie| ie.mode == 0o160000)
9078                .map(|ie| ie.oid);
9079            match (tree_entry, index_gitlink_oid) {
9080                (Some(te), _) => {
9081                    let sub_head = read_submodule_head_oid(&sub_dir);
9082                    // A gitlink whose worktree directory no longer exists is a deleted submodule:
9083                    // Git's `diff-lib.c` reports it as a deletion (status `D`, new mode 000000) and
9084                    // `--submodule` renders the `(submodule deleted)` summary (t4041 #46).
9085                    if sub_head.is_none() && !sub_dir.exists() {
9086                        result.push(DiffEntry {
9087                            status: DiffStatus::Deleted,
9088                            old_path: Some(path.clone()),
9089                            new_path: Some(path.clone()),
9090                            old_mode: FileMode::new(te.mode),
9091                            new_mode: FileMode::ABSENT,
9092                            old_oid: te.oid,
9093                            new_oid: zero_oid(),
9094                            score: None,
9095                        });
9096                        continue;
9097                    }
9098                    let ref_matches = if let Some(head) = sub_head {
9099                        head == te.oid
9100                    } else {
9101                        uninitialized_gitlink_worktree_matches_index(&sub_dir)
9102                    };
9103                    let index_matches_tree = index_gitlink_oid.is_some_and(|oid| oid == te.oid);
9104                    // Default tree↔worktree diff matches Git: untracked-only paths inside a
9105                    // checked-out submodule do not make the gitlink differ (see `git diff <tree>`).
9106                    let sub_flags = submodule_porcelain_flags(work_tree, path, te.oid);
9107                    let dirty_while_aligned =
9108                        index_matches_tree && ref_matches && sub_flags.modified;
9109                    if !ref_matches || dirty_while_aligned {
9110                        // Raw `git diff <tree>` lines use a null OID on the worktree side when the
9111                        // checked-out submodule HEAD differs from the tree's gitlink; patch output
9112                        // still resolves the real commit from the submodule directory.
9113                        let new_oid = if !ref_matches {
9114                            sub_head.unwrap_or_else(zero_oid)
9115                        } else {
9116                            zero_oid()
9117                        };
9118                        result.push(DiffEntry {
9119                            status: DiffStatus::Modified,
9120                            old_path: Some(path.clone()),
9121                            new_path: Some(path.clone()),
9122                            old_mode: FileMode::new(te.mode),
9123                            new_mode: FileMode::new(te.mode),
9124                            old_oid: te.oid,
9125                            new_oid,
9126                            score: None,
9127                        });
9128                    }
9129                }
9130                (None, Some(idx_oid)) => {
9131                    // Gitlink staged in the index but absent from the tree: a new submodule. Git
9132                    // reports it as an addition (status `A`) with the index gitlink on the new side,
9133                    // so `--submodule` renders `(new submodule)` (t4041 #46). The patch renderer
9134                    // resolves the real commit from the index OID / submodule HEAD.
9135                    let new_oid = read_submodule_head_oid(&sub_dir).unwrap_or(idx_oid);
9136                    result.push(DiffEntry {
9137                        status: DiffStatus::Added,
9138                        old_path: Some(path.clone()),
9139                        new_path: Some(path.clone()),
9140                        old_mode: FileMode::ABSENT,
9141                        new_mode: FileMode::new(0o160000),
9142                        old_oid: zero_oid(),
9143                        new_oid,
9144                        score: None,
9145                    });
9146                }
9147                (None, None) => {}
9148            }
9149            continue;
9150        }
9151
9152        let file_path = work_tree.join(path);
9153
9154        let wt_meta = match probe_worktree_symlink_metadata(
9155            &file_path,
9156            path,
9157            extras.diagnostics.as_ref(),
9158            false,
9159            extras.skip_inaccessible_paths,
9160            false,
9161        )? {
9162            WorktreeSymlinkMetadata::Found(m) => Some(m),
9163            WorktreeSymlinkMetadata::Inaccessible => continue,
9164            WorktreeSymlinkMetadata::Absent => None,
9165        };
9166
9167        if unmerged_only_paths.contains(path) {
9168            if let (Some(te), Some(meta)) = (tree_entry, wt_meta.as_ref()) {
9169                let tree_mode = crate::index::canon_mode(te.mode);
9170                let file_attrs = file_attrs_for(path);
9171                let wt_oid = hash_worktree_file(
9172                    odb,
9173                    &file_path,
9174                    meta,
9175                    &conv,
9176                    &file_attrs,
9177                    path,
9178                    None,
9179                    filter_fp,
9180                )?;
9181                let wt_mode =
9182                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(tree_mode));
9183                if wt_oid != te.oid || wt_mode != tree_mode {
9184                    result.push(DiffEntry {
9185                        status: DiffStatus::for_content_change(tree_mode, wt_mode),
9186                        old_path: Some(path.clone()),
9187                        new_path: Some(path.clone()),
9188                        old_mode: FileMode::new(tree_mode),
9189                        new_mode: FileMode::new(wt_mode),
9190                        old_oid: te.oid,
9191                        new_oid: wt_oid,
9192                        score: None,
9193                    });
9194                }
9195            }
9196            continue;
9197        }
9198
9199        match (tree_entry, wt_meta) {
9200            (Some(te), Some(ref meta)) => {
9201                let tree_mode = crate::index::canon_mode(te.mode);
9202                let Some(ie) = index_entries.get(path.as_bytes()) else {
9203                    continue;
9204                };
9205                let wt_mode =
9206                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(ie.mode));
9207
9208                let index_matches_tree = ie.oid == te.oid && ie.mode == tree_mode;
9209
9210                // Fully clean: index matches `HEAD`, worktree matches index, stat cache fresh.
9211                let stat_trusted = stat_matches_with_policy(ie, meta, stat_policy)
9212                    && !entry_is_racy_with_policy(ie, index_mtime, stat_policy);
9213                if index_matches_tree && stat_trusted && wt_mode == tree_mode {
9214                    continue;
9215                }
9216
9217                let file_attrs = file_attrs_for(path);
9218                let idx_ent = index_entries.get(path.as_bytes()).copied();
9219
9220                // Staged mode (same blob as `HEAD`, different mode recorded in the index) with
9221                // the worktree still matching the index. `core.filemode=false` does not discard
9222                // an explicit index chmod vs HEAD. Once the worktree drifts from the index, the
9223                // diff must describe the worktree side, so fall through.
9224                if ie.oid == te.oid && ie.mode != tree_mode && stat_trusted && wt_mode == ie.mode {
9225                    result.push(DiffEntry {
9226                        status: DiffStatus::for_content_change(tree_mode, ie.mode),
9227                        old_path: Some(path.clone()),
9228                        new_path: Some(path.clone()),
9229                        old_mode: FileMode::new(tree_mode),
9230                        new_mode: FileMode::new(ie.mode),
9231                        old_oid: te.oid,
9232                        new_oid: te.oid,
9233                        score: None,
9234                    });
9235                    continue;
9236                }
9237
9238                // Index still matches `HEAD`: only unstaged worktree drift (content and/or
9239                // worktree-only exec bit when `update-index` was not run — t4049 harness).
9240                if index_matches_tree {
9241                    let wt_oid = hash_worktree_file(
9242                        odb,
9243                        &file_path,
9244                        meta,
9245                        &conv,
9246                        &file_attrs,
9247                        path,
9248                        None,
9249                        filter_fp,
9250                    )?;
9251                    if wt_oid != te.oid {
9252                        result.push(DiffEntry {
9253                            status: DiffStatus::for_content_change(tree_mode, wt_mode),
9254                            old_path: Some(path.clone()),
9255                            new_path: Some(path.clone()),
9256                            old_mode: FileMode::new(tree_mode),
9257                            new_mode: FileMode::new(wt_mode),
9258                            old_oid: te.oid,
9259                            // Raw `git diff <tree>` uses a null OID on the worktree side (patch
9260                            // output resolves content from the working tree).
9261                            new_oid: zero_oid(),
9262                            score: None,
9263                        });
9264                    } else if wt_mode != tree_mode {
9265                        result.push(DiffEntry {
9266                            status: DiffStatus::for_content_change(tree_mode, wt_mode),
9267                            old_path: Some(path.clone()),
9268                            new_path: Some(path.clone()),
9269                            old_mode: FileMode::new(tree_mode),
9270                            new_mode: FileMode::new(wt_mode),
9271                            old_oid: te.oid,
9272                            new_oid: te.oid,
9273                            score: None,
9274                        });
9275                    }
9276                    continue;
9277                }
9278
9279                // Staged content (and possibly mode): `git diff <rev>` is tree vs working tree.
9280                let wt_oid = hash_worktree_file(
9281                    odb,
9282                    &file_path,
9283                    meta,
9284                    &conv,
9285                    &file_attrs,
9286                    path,
9287                    idx_ent,
9288                    filter_fp,
9289                )?;
9290                if wt_oid != te.oid || wt_mode != tree_mode {
9291                    let new_oid = if wt_oid != te.oid { zero_oid() } else { te.oid };
9292                    result.push(DiffEntry {
9293                        status: DiffStatus::for_content_change(tree_mode, wt_mode),
9294                        old_path: Some(path.clone()),
9295                        new_path: Some(path.clone()),
9296                        old_mode: FileMode::new(tree_mode),
9297                        new_mode: FileMode::new(wt_mode),
9298                        old_oid: te.oid,
9299                        new_oid,
9300                        score: None,
9301                    });
9302                }
9303            }
9304            (Some(te), None) => {
9305                let tree_mode = crate::index::canon_mode(te.mode);
9306                // In tree but missing from worktree
9307                result.push(DiffEntry {
9308                    status: DiffStatus::Deleted,
9309                    old_path: Some(path.clone()),
9310                    new_path: None,
9311                    old_mode: FileMode::new(tree_mode),
9312                    new_mode: FileMode::ABSENT,
9313                    old_oid: te.oid,
9314                    new_oid: zero_oid(),
9315                    score: None,
9316                });
9317            }
9318            (None, Some(ref meta)) => {
9319                // In index but not in tree, and exists in worktree
9320                let file_attrs = file_attrs_for(path);
9321                let idx_ent = index_entries.get(path.as_bytes()).copied();
9322                let wt_oid = materialize_worktree_blob(
9323                    odb,
9324                    &file_path,
9325                    meta,
9326                    &conv,
9327                    &file_attrs,
9328                    path,
9329                    idx_ent,
9330                    filter_fp,
9331                )?;
9332                let wt_mode = mode_from_metadata(meta);
9333                result.push(DiffEntry {
9334                    status: DiffStatus::Added,
9335                    old_path: None,
9336                    new_path: Some(path.clone()),
9337                    old_mode: FileMode::ABSENT,
9338                    new_mode: FileMode::new(wt_mode),
9339                    old_oid: zero_oid(),
9340                    new_oid: wt_oid,
9341                    score: None,
9342                });
9343            }
9344            (None, None) => {
9345                // Tracked in index but neither in tree nor worktree — skip
9346            }
9347        }
9348    }
9349
9350    sort_entries_by_path(&mut result);
9351    Ok(result)
9352}
9353
9354// ── Rename detection ────────────────────────────────────────────────
9355
9356/// 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.
9357#[derive(Debug, Clone, PartialEq, Eq)]
9358pub struct InexactRenameDetectionSkipped {
9359    /// Unmatched delete-side candidates still eligible for inexact pairing.
9360    pub unmatched_sources: usize,
9361    /// Unmatched add-side candidates still eligible for inexact pairing.
9362    pub unmatched_destinations: usize,
9363    /// Configured `diff.renameLimit`, if any (`None` means unlimited).
9364    pub configured_limit: Option<usize>,
9365    /// Smallest limit that would allow the remaining matrix (`max(sources, destinations)`).
9366    pub suggested_limit: usize,
9367}
9368
9369/// Tree or commit diff entries after rename detection, with an optional limit notice.
9370#[derive(Debug, Clone, Default, PartialEq, Eq)]
9371pub struct DiffEntriesResult {
9372    /// Flat diff entries in path order.
9373    pub entries: Vec<DiffEntry>,
9374    /// Set when inexact rename detection was skipped due to `diff.renameLimit`.
9375    pub inexact_rename_skipped: Option<InexactRenameDetectionSkipped>,
9376}
9377
9378/// Result of [`detect_renames_with_policy`].
9379#[derive(Debug, Clone, PartialEq, Eq)]
9380pub struct RenameDetectionResult {
9381    /// Diff entries after rename pairing.
9382    pub entries: Vec<DiffEntry>,
9383    /// Set when inexact rename detection was skipped due to `diff.renameLimit`.
9384    pub inexact_skipped: Option<InexactRenameDetectionSkipped>,
9385}
9386
9387/// Options for [`detect_renames_with_policy`].
9388#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9389pub struct DetectRenamesOptions {
9390    /// Skip Git's basename pre-pass (break-rewrite and copy detection paths).
9391    pub skip_basename_heuristic: bool,
9392}
9393
9394fn read_added_entry_bytes(
9395    odb: &Odb,
9396    entry: &DiffEntry,
9397    work_root: Option<&Path>,
9398) -> Option<Vec<u8>> {
9399    if entry.new_oid != zero_oid() {
9400        if let Ok(obj) = odb.read(&entry.new_oid) {
9401            return Some(obj.data);
9402        }
9403    }
9404    let path = entry.new_path.as_deref()?;
9405    let root = work_root?;
9406    fs::read(root.join(path)).ok()
9407}
9408
9409/// Detect renames by pairing Deleted and Added entries with similar content.
9410///
9411/// `threshold` is the minimum similarity percentage (0–100) for a pair to
9412/// be considered a rename (Git's default is 50%).  The function reads blob
9413/// content from the ODB to compute a line-level similarity score.
9414///
9415/// Exact-OID matches are always 100% similar regardless of content.
9416///
9417/// When `work_root` is set, added entries whose `new_oid` is the zero placeholder (as in
9418/// uncached `diff-index` when the work tree diverged from the index) load content from disk
9419/// under that root instead of the object database.
9420#[allow(clippy::expect_used)]
9421pub fn detect_renames(
9422    odb: &Odb,
9423    work_root: Option<&Path>,
9424    entries: Vec<DiffEntry>,
9425    threshold: u32,
9426) -> Vec<DiffEntry> {
9427    let config = ConfigSet::new();
9428    let policy = ThreadPolicy::default();
9429    detect_renames_with_policy(
9430        odb,
9431        work_root,
9432        entries,
9433        threshold,
9434        &config,
9435        &policy,
9436        None,
9437        DetectRenamesOptions::default(),
9438    )
9439    .expect("rename detection")
9440    .entries
9441}
9442
9443#[inline]
9444fn poll_rename_cancel(
9445    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
9446) -> crate::error::Result<()> {
9447    if let Some(watch) = cancel_watch {
9448        crate::progress::poll_watch(watch)?;
9449    }
9450    Ok(())
9451}
9452
9453/// Like [`detect_renames`], but honors `index.threads` / `--threads` via `thread_policy`.
9454///
9455/// A list without both an added and a deleted entry has nothing to pair. When its paths
9456/// are also unique and in [`sort_entries_by_path`] order, as tree diffs return them, the
9457/// list comes back unchanged without reading blobs or allocating.
9458///
9459/// # Errors
9460///
9461/// Returns object database errors from reading candidate blobs, and the cancellation
9462/// error when `cancel_watch` is set.
9463#[allow(clippy::too_many_arguments)]
9464pub fn detect_renames_with_policy(
9465    odb: &Odb,
9466    work_root: Option<&Path>,
9467    entries: Vec<DiffEntry>,
9468    threshold: u32,
9469    config: &ConfigSet,
9470    thread_policy: &ThreadPolicy,
9471    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
9472    detect_opts: DetectRenamesOptions,
9473) -> Result<RenameDetectionResult> {
9474    poll_rename_cancel(cancel_watch)?;
9475    let has_deleted = entries.iter().any(|e| e.status == DiffStatus::Deleted);
9476    let has_added = entries.iter().any(|e| e.status == DiffStatus::Added);
9477    if (!has_deleted || !has_added) && entries_strictly_sorted_by_path(&entries) {
9478        // With unique sorted paths, regrouping and re-sorting would rebuild the same list.
9479        return Ok(RenameDetectionResult {
9480            entries,
9481            inexact_skipped: None,
9482        });
9483    }
9484    // Split entries into deleted, added, and others.
9485    let mut deleted: Vec<DiffEntry> = Vec::new();
9486    let mut added: Vec<DiffEntry> = Vec::new();
9487    let mut others: Vec<DiffEntry> = Vec::new();
9488
9489    for entry in entries {
9490        match entry.status {
9491            DiffStatus::Deleted => deleted.push(entry),
9492            DiffStatus::Added => added.push(entry),
9493            _ => others.push(entry),
9494        }
9495    }
9496
9497    if deleted.is_empty() || added.is_empty() {
9498        // Nothing to pair — return original order.
9499        let mut result = others;
9500        result.extend(deleted);
9501        result.extend(added);
9502        sort_entries_by_path(&mut result);
9503        return Ok(RenameDetectionResult {
9504            entries: result,
9505            inexact_skipped: None,
9506        });
9507    }
9508
9509    fn is_regularish_mode(mode: FileMode) -> bool {
9510        mode == FileMode::REGULAR || mode == FileMode::EXECUTABLE
9511    }
9512
9513    fn same_path_same_blob(del: &DiffEntry, add: &DiffEntry) -> bool {
9514        del.old_path == add.new_path && del.old_oid == add.new_oid && del.old_mode == add.new_mode
9515    }
9516
9517    fn sort_rename_scores(
9518        scores: &mut [(u32, usize, usize)],
9519        deleted: &[DiffEntry],
9520        added: &[DiffEntry],
9521    ) {
9522        scores.sort_by(|a, b| {
9523            let a_noop = same_path_same_blob(&deleted[a.1], &added[a.2]);
9524            let b_noop = same_path_same_blob(&deleted[b.1], &added[b.2]);
9525            let a_same = same_basename(&deleted[a.1], &added[a.2]);
9526            let b_same = same_basename(&deleted[b.1], &added[b.2]);
9527            a_noop
9528                .cmp(&b_noop)
9529                .then_with(|| b_same.cmp(&a_same))
9530                .then_with(|| b.0.cmp(&a.0))
9531        });
9532    }
9533
9534    fn apply_rename_scores(
9535        scores: &[(u32, usize, usize)],
9536        deleted: &[DiffEntry],
9537        added: &[DiffEntry],
9538        used_deleted: &mut [bool],
9539        used_added: &mut [bool],
9540        renames: &mut Vec<DiffEntry>,
9541    ) {
9542        for (score, di, ai) in scores {
9543            if used_deleted[*di] || used_added[*ai] {
9544                continue;
9545            }
9546            used_deleted[*di] = true;
9547            used_added[*ai] = true;
9548
9549            let del = &deleted[*di];
9550            let add = &added[*ai];
9551            if same_path_same_blob(del, add) {
9552                continue;
9553            }
9554
9555            renames.push(DiffEntry {
9556                status: DiffStatus::Renamed,
9557                old_path: del.old_path.clone(),
9558                new_path: add.new_path.clone(),
9559                old_mode: del.old_mode,
9560                new_mode: add.new_mode,
9561                old_oid: del.old_oid,
9562                new_oid: add.new_oid,
9563                score: Some(*score),
9564            });
9565        }
9566    }
9567
9568    let mut used_deleted = vec![false; deleted.len()];
9569    let mut used_added = vec![false; added.len()];
9570    let mut renames: Vec<DiffEntry> = Vec::new();
9571
9572    // Git always runs exact rename detection, regardless of renameLimit.
9573    let mut added_by_oid: std::collections::HashMap<ObjectId, Vec<usize>> =
9574        std::collections::HashMap::new();
9575    for (ai, add) in added.iter().enumerate() {
9576        added_by_oid.entry(add.new_oid).or_default().push(ai);
9577    }
9578    let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
9579    for (di, del) in deleted.iter().enumerate() {
9580        poll_rename_cancel(cancel_watch)?;
9581        if let Some(ais) = added_by_oid.get(&del.old_oid) {
9582            for &ai in ais {
9583                exact_scores.push((100, di, ai));
9584            }
9585        }
9586    }
9587    sort_rename_scores(&mut exact_scores, &deleted, &added);
9588    apply_rename_scores(
9589        &exact_scores,
9590        &deleted,
9591        &added,
9592        &mut used_deleted,
9593        &mut used_added,
9594        &mut renames,
9595    );
9596
9597    let mut blob_cache = None;
9598    if !detect_opts.skip_basename_heuristic
9599        && super::basename_rename::basename_needs_similarity_scoring(
9600            &deleted,
9601            &added,
9602            &used_deleted,
9603            &used_added,
9604        )
9605    {
9606        let mut cache = super::rename_blob_cache::RenameBlobCache::new(deleted.len(), added.len());
9607        let par_opts = diff_rename_parallel_options(config, thread_policy);
9608        super::basename_rename::apply_basename_rename_pairs(
9609            odb,
9610            work_root,
9611            &deleted,
9612            &added,
9613            &mut used_deleted,
9614            &mut used_added,
9615            &mut renames,
9616            threshold,
9617            cancel_watch,
9618            &mut cache,
9619            par_opts,
9620        )?;
9621        blob_cache = Some(cache);
9622    }
9623
9624    let remaining_deleted: Vec<usize> = used_deleted
9625        .iter()
9626        .enumerate()
9627        .filter_map(|(i, used)| (!*used).then_some(i))
9628        .collect();
9629    let remaining_added: Vec<usize> = used_added
9630        .iter()
9631        .enumerate()
9632        .filter_map(|(i, used)| (!*used).then_some(i))
9633        .collect();
9634
9635    let rename_limit = parse_rename_limit(config);
9636    let mut inexact_skipped = None;
9637    if !remaining_deleted.is_empty()
9638        && !remaining_added.is_empty()
9639        && inexact_matrix_exceeds_limit(
9640            remaining_added.len(),
9641            remaining_deleted.len(),
9642            rename_limit,
9643        )
9644    {
9645        let suggested = remaining_deleted.len().max(remaining_added.len());
9646        inexact_skipped = Some(InexactRenameDetectionSkipped {
9647            unmatched_sources: remaining_deleted.len(),
9648            unmatched_destinations: remaining_added.len(),
9649            configured_limit: rename_limit.configured_max(),
9650            suggested_limit: suggested,
9651        });
9652    } else if !remaining_deleted.is_empty() && !remaining_added.is_empty() {
9653        let par_opts = diff_rename_parallel_options(config, thread_policy);
9654
9655        let mut prefetch_entries: Vec<DiffEntry> =
9656            Vec::with_capacity(remaining_deleted.len() + remaining_added.len());
9657        for &di in &remaining_deleted {
9658            prefetch_entries.push(deleted[di].clone());
9659        }
9660        for &ai in &remaining_added {
9661            prefetch_entries.push(added[ai].clone());
9662        }
9663        super::tree_content::prefetch_tree_diff_blobs(odb, &prefetch_entries)?;
9664
9665        let deleted_sizes: Vec<Option<usize>> = remaining_deleted
9666            .iter()
9667            .map(|&di| blob_payload_size(odb, &deleted[di].old_oid))
9668            .collect();
9669        let added_sizes: Vec<Option<usize>> = remaining_added
9670            .iter()
9671            .map(|&ai| added_entry_payload_size(odb, &added[ai], work_root))
9672            .collect();
9673
9674        let mut load_deleted = vec![false; remaining_deleted.len()];
9675        let mut load_added = vec![false; remaining_added.len()];
9676        let mut candidate_pairs: Vec<(usize, usize)> = Vec::new();
9677        for (row, &di) in remaining_deleted.iter().enumerate() {
9678            poll_rename_cancel(cancel_watch)?;
9679            let del = &deleted[di];
9680            if !is_regularish_mode(del.old_mode) {
9681                continue;
9682            }
9683            let Some(ds) = deleted_sizes[row] else {
9684                continue;
9685            };
9686            for (col, &ai) in remaining_added.iter().enumerate() {
9687                let add = &added[ai];
9688                if !is_regularish_mode(add.new_mode) {
9689                    continue;
9690                }
9691                let Some(asz) = added_sizes[col] else {
9692                    continue;
9693                };
9694                if rename_pair_size_reachable(ds, asz, threshold) {
9695                    load_deleted[row] = true;
9696                    load_added[col] = true;
9697                    candidate_pairs.push((row, col));
9698                }
9699            }
9700        }
9701
9702        if !candidate_pairs.is_empty() {
9703            let loads_needed = load_deleted.iter().filter(|&&b| b).count()
9704                + load_added.iter().filter(|&&b| b).count();
9705            let (deleted_contents, added_contents) = if let Some(cache) = blob_cache.as_mut() {
9706                let mut deleted_staging: Vec<Option<Vec<u8>>> = vec![None; remaining_deleted.len()];
9707                for (row, &need) in load_deleted.iter().enumerate() {
9708                    if !need {
9709                        continue;
9710                    }
9711                    let di = remaining_deleted[row];
9712                    deleted_staging[row] = cache
9713                        .take_deleted_blob(di)
9714                        .or_else(|| odb.read(&deleted[di].old_oid).ok().map(|obj| obj.data));
9715                }
9716                let mut added_staging: Vec<Option<Vec<u8>>> = vec![None; remaining_added.len()];
9717                for (col, &need) in load_added.iter().enumerate() {
9718                    if !need {
9719                        continue;
9720                    }
9721                    let ai = remaining_added[col];
9722                    added_staging[col] = cache
9723                        .take_added_blob(ai)
9724                        .or_else(|| read_added_entry_bytes(odb, &added[ai], work_root));
9725                }
9726                let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
9727                    if !load_deleted[i] {
9728                        return None;
9729                    }
9730                    deleted_staging[i].clone()
9731                };
9732                let load_added_blob = |i: usize| -> Option<Vec<u8>> {
9733                    if !load_added[i] {
9734                        return None;
9735                    }
9736                    added_staging[i].clone()
9737                };
9738                if loads_needed <= 32 {
9739                    (
9740                        (0..remaining_deleted.len())
9741                            .map(load_deleted_blob)
9742                            .collect(),
9743                        (0..remaining_added.len()).map(load_added_blob).collect(),
9744                    )
9745                } else {
9746                    (
9747                        parallel_load_optional_blobs(
9748                            odb,
9749                            remaining_deleted.len(),
9750                            par_opts,
9751                            0,
9752                            load_deleted_blob,
9753                        ),
9754                        parallel_load_optional_blobs(
9755                            odb,
9756                            remaining_added.len(),
9757                            par_opts,
9758                            0,
9759                            load_added_blob,
9760                        ),
9761                    )
9762                }
9763            } else {
9764                let mut deleted_staging: Vec<Option<Vec<u8>>> = vec![None; remaining_deleted.len()];
9765                for (row, &need) in load_deleted.iter().enumerate() {
9766                    if !need {
9767                        continue;
9768                    }
9769                    let di = remaining_deleted[row];
9770                    deleted_staging[row] = odb.read(&deleted[di].old_oid).ok().map(|obj| obj.data);
9771                }
9772                let mut added_staging: Vec<Option<Vec<u8>>> = vec![None; remaining_added.len()];
9773                for (col, &need) in load_added.iter().enumerate() {
9774                    if !need {
9775                        continue;
9776                    }
9777                    let ai = remaining_added[col];
9778                    added_staging[col] = read_added_entry_bytes(odb, &added[ai], work_root);
9779                }
9780                let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
9781                    if !load_deleted[i] {
9782                        return None;
9783                    }
9784                    deleted_staging[i].clone()
9785                };
9786                let load_added_blob = |i: usize| -> Option<Vec<u8>> {
9787                    if !load_added[i] {
9788                        return None;
9789                    }
9790                    added_staging[i].clone()
9791                };
9792                if loads_needed <= 32 {
9793                    (
9794                        (0..remaining_deleted.len())
9795                            .map(load_deleted_blob)
9796                            .collect(),
9797                        (0..remaining_added.len()).map(load_added_blob).collect(),
9798                    )
9799                } else {
9800                    (
9801                        parallel_load_optional_blobs(
9802                            odb,
9803                            remaining_deleted.len(),
9804                            par_opts,
9805                            0,
9806                            load_deleted_blob,
9807                        ),
9808                        parallel_load_optional_blobs(
9809                            odb,
9810                            remaining_added.len(),
9811                            par_opts,
9812                            0,
9813                            load_added_blob,
9814                        ),
9815                    )
9816                }
9817            };
9818
9819            let est_table_bytes: usize = deleted_contents
9820                .iter()
9821                .chain(added_contents.iter())
9822                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
9823                .sum();
9824            let build_deleted_table = |row: usize| -> std::result::Result<
9825                Option<RenameLineTable>,
9826                std::convert::Infallible,
9827            > {
9828                Ok(deleted_contents[row]
9829                    .as_ref()
9830                    .map(|d| RenameLineTable::from_blob(d)))
9831            };
9832            let build_added_table = |col: usize| -> std::result::Result<
9833                Option<RenameLineTable>,
9834                std::convert::Infallible,
9835            > {
9836                Ok(added_contents[col]
9837                    .as_ref()
9838                    .map(|d| RenameLineTable::from_blob(d)))
9839            };
9840            let deleted_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
9841                (0..remaining_deleted.len())
9842                    .map(|row| build_deleted_table(row).unwrap())
9843                    .collect()
9844            } else {
9845                parallel_map_indices(
9846                    odb,
9847                    remaining_deleted.len(),
9848                    par_opts,
9849                    est_table_bytes,
9850                    build_deleted_table,
9851                )
9852                .expect("deleted rename line tables")
9853            };
9854            let added_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
9855                (0..remaining_added.len())
9856                    .map(|col| build_added_table(col).unwrap())
9857                    .collect()
9858            } else {
9859                parallel_map_indices(
9860                    odb,
9861                    remaining_added.len(),
9862                    par_opts,
9863                    est_table_bytes,
9864                    build_added_table,
9865                )
9866                .expect("added rename line tables")
9867            };
9868
9869            let mut inexact_scores: Vec<(u32, usize, usize)> = Vec::new();
9870            for (row, col) in candidate_pairs {
9871                poll_rename_cancel(cancel_watch)?;
9872                let di = remaining_deleted[row];
9873                let ai = remaining_added[col];
9874                let (Some(old_table), Some(new_table)) = (&deleted_tables[row], &added_tables[col])
9875                else {
9876                    continue;
9877                };
9878                let score = RenameLineTable::similarity_score(old_table, new_table);
9879                if score >= threshold {
9880                    inexact_scores.push((score, di, ai));
9881                }
9882            }
9883            sort_rename_scores(&mut inexact_scores, &deleted, &added);
9884            apply_rename_scores(
9885                &inexact_scores,
9886                &deleted,
9887                &added,
9888                &mut used_deleted,
9889                &mut used_added,
9890                &mut renames,
9891            );
9892        }
9893    }
9894
9895    // Collect unmatched entries.
9896    let mut result = others;
9897    result.extend(renames);
9898    for (i, entry) in deleted.into_iter().enumerate() {
9899        if !used_deleted[i] {
9900            result.push(entry);
9901        }
9902    }
9903    for (i, entry) in added.into_iter().enumerate() {
9904        if !used_added[i] {
9905            result.push(entry);
9906        }
9907    }
9908
9909    result = prune_break_deletions_obviated_by_renames(result);
9910    sort_entries_by_path(&mut result);
9911    Ok(RenameDetectionResult {
9912        entries: result,
9913        inexact_skipped,
9914    })
9915}
9916
9917/// Detect copies among diff entries.
9918///
9919/// This first runs rename detection (pairing Deleted+Added), then for any
9920/// remaining Added entries, looks for copy sources.
9921///
9922/// - `find_copies_harder` = false: only Modified entries are copy source candidates.
9923/// - `find_copies_harder` = true: also examine unmodified files from `source_tree_entries`.
9924///
9925/// `source_tree_entries` should be a list of (path, mode, oid) from the source tree;
9926/// used when `find_copies_harder` is true to consider unmodified files as copy sources.
9927/// # Errors
9928///
9929/// Returns [`Error::HashCollision`] when hashing worktree blob content for copy detection
9930/// would produce a known-bad SHA-1 object id.
9931pub fn detect_copies(
9932    odb: &Odb,
9933    work_root: Option<&Path>,
9934    entries: Vec<DiffEntry>,
9935    threshold: u32,
9936    find_copies_harder: bool,
9937    source_tree_entries: &[(String, FileMode, ObjectId)],
9938) -> Result<Vec<DiffEntry>> {
9939    let config = ConfigSet::new();
9940    let policy = ThreadPolicy::default();
9941    detect_copies_with_policy(
9942        odb,
9943        work_root,
9944        entries,
9945        threshold,
9946        find_copies_harder,
9947        source_tree_entries,
9948        &config,
9949        &policy,
9950    )
9951}
9952
9953/// Like [`detect_copies`], but honors parallel thread policy and rename limits.
9954///
9955/// # Errors
9956///
9957/// Same as [`detect_copies`].
9958#[expect(clippy::too_many_arguments)]
9959pub fn detect_copies_with_policy(
9960    odb: &Odb,
9961    work_root: Option<&Path>,
9962    entries: Vec<DiffEntry>,
9963    threshold: u32,
9964    find_copies_harder: bool,
9965    source_tree_entries: &[(String, FileMode, ObjectId)],
9966    config: &ConfigSet,
9967    thread_policy: &ThreadPolicy,
9968) -> Result<Vec<DiffEntry>> {
9969    use std::collections::{HashMap, HashSet};
9970
9971    // Separate entries by status.
9972    let mut deleted: Vec<DiffEntry> = Vec::new();
9973    let mut added: Vec<DiffEntry> = Vec::new();
9974    let mut others: Vec<DiffEntry> = Vec::new();
9975
9976    for entry in entries {
9977        match entry.status {
9978            DiffStatus::Deleted => deleted.push(entry),
9979            DiffStatus::Added => added.push(entry),
9980            _ => others.push(entry),
9981        }
9982    }
9983
9984    // Build source candidates: deleted files, modified files, and optionally tree entries.
9985    // Track which sources are from deleted files (can become renames).
9986    let mut sources: Vec<(String, ObjectId, bool)> = Vec::new(); // (path, oid, is_deleted)
9987    let mut deleted_source_idx: HashMap<String, usize> = HashMap::new();
9988
9989    for entry in &deleted {
9990        if let Some(ref path) = entry.old_path {
9991            deleted_source_idx.insert(path.clone(), sources.len());
9992            sources.push((path.clone(), entry.old_oid, true));
9993        }
9994    }
9995
9996    // Modified and type-changed files are candidates for `-C` (e.g. symlink rewrite leaves the
9997    // old blob available as a copy source for another path; see `t4008-diff-break-rewrite`).
9998    for entry in &others {
9999        if matches!(entry.status, DiffStatus::Modified | DiffStatus::TypeChanged) {
10000            if let Some(ref old_path) = entry.old_path {
10001                if !sources.iter().any(|(p, _, _)| p == old_path) {
10002                    sources.push((old_path.clone(), entry.old_oid, false));
10003                }
10004            }
10005        }
10006    }
10007
10008    // With find_copies_harder, add all source tree entries.
10009    if find_copies_harder {
10010        for (path, _mode, oid) in source_tree_entries {
10011            if !sources.iter().any(|(p, _, _)| p == path) {
10012                sources.push((path.clone(), *oid, false));
10013            }
10014        }
10015    }
10016
10017    // Modes of every source, for the old side of copy and rename entries: the deleted and
10018    // modified entries carry their own, and `source_tree_entries` the rest.
10019    let mut source_modes: Vec<(String, FileMode, ObjectId)> = source_tree_entries.to_vec();
10020    for entry in deleted.iter().chain(&others) {
10021        if let Some(ref path) = entry.old_path {
10022            if !source_modes.iter().any(|(p, _, _)| p == path) {
10023                source_modes.push((path.clone(), entry.old_mode, entry.old_oid));
10024            }
10025        }
10026    }
10027
10028    if sources.is_empty() {
10029        let mut result = others;
10030        result.extend(deleted);
10031        result.extend(added);
10032        sort_entries_by_path(&mut result);
10033        return Ok(result);
10034    }
10035
10036    let par_opts = diff_rename_parallel_options(config, thread_policy);
10037    let rename_limit = parse_rename_limit(config);
10038
10039    fn apply_copy_score_assignments(
10040        scores: &[(u32, usize, usize)],
10041        sources: &[(String, ObjectId, bool)],
10042        added: &[DiffEntry],
10043        source_tree_entries: &[(String, FileMode, ObjectId)],
10044        used_added2: &mut [bool],
10045        renamed_deleted: &mut HashSet<usize>,
10046        result_entries: &mut Vec<DiffEntry>,
10047    ) {
10048        let mut used_added = vec![false; added.len()];
10049        let mut source_to_added: HashMap<usize, Vec<(usize, u32)>> = HashMap::new();
10050        for &(score, si, ai) in scores {
10051            if used_added[ai] {
10052                continue;
10053            }
10054            used_added[ai] = true;
10055            source_to_added.entry(si).or_default().push((ai, score));
10056        }
10057
10058        for (&si, assignments_for_src) in &source_to_added {
10059            let (_, _, is_deleted) = &sources[si];
10060            if *is_deleted && !assignments_for_src.is_empty() {
10061                let rename_ai = assignments_for_src
10062                    .iter()
10063                    .max_by_key(|(ai, _score)| added[*ai].path().to_string())
10064                    .map(|(ai, _)| *ai);
10065
10066                for &(ai, score) in assignments_for_src {
10067                    let (ref src_path, _, _) = sources[si];
10068                    let add = &added[ai];
10069                    let src_mode = source_tree_entries
10070                        .iter()
10071                        .find(|(p, _, _)| p == src_path)
10072                        .map_or(add.old_mode, |(_, m, _)| *m);
10073
10074                    let is_rename = Some(ai) == rename_ai;
10075                    result_entries.push(DiffEntry {
10076                        status: if is_rename {
10077                            DiffStatus::Renamed
10078                        } else {
10079                            DiffStatus::Copied
10080                        },
10081                        old_path: Some(src_path.clone()),
10082                        new_path: add.new_path.clone(),
10083                        old_mode: src_mode,
10084                        new_mode: add.new_mode,
10085                        old_oid: sources[si].1,
10086                        new_oid: add.new_oid,
10087                        score: Some(score),
10088                    });
10089                    used_added2[ai] = true;
10090                }
10091                renamed_deleted.insert(si);
10092            } else {
10093                for &(ai, score) in assignments_for_src {
10094                    let (ref src_path, _, _) = sources[si];
10095                    let add = &added[ai];
10096                    let src_mode = source_tree_entries
10097                        .iter()
10098                        .find(|(p, _, _)| p == src_path)
10099                        .map_or(add.old_mode, |(_, m, _)| *m);
10100
10101                    result_entries.push(DiffEntry {
10102                        status: DiffStatus::Copied,
10103                        old_path: Some(src_path.clone()),
10104                        new_path: add.new_path.clone(),
10105                        old_mode: src_mode,
10106                        new_mode: add.new_mode,
10107                        old_oid: sources[si].1,
10108                        new_oid: add.new_oid,
10109                        score: Some(score),
10110                    });
10111                    used_added2[ai] = true;
10112                }
10113            }
10114        }
10115    }
10116
10117    fn added_entry_oid(
10118        odb: &Odb,
10119        add: &DiffEntry,
10120        added_contents: &[Option<Vec<u8>>],
10121        ai: usize,
10122    ) -> Result<ObjectId> {
10123        if add.new_oid != zero_oid() {
10124            Ok(add.new_oid)
10125        } else if let Some(ref data) = added_contents[ai] {
10126            odb.hash(ObjectKind::Blob, data)
10127        } else {
10128            Ok(zero_oid())
10129        }
10130    }
10131
10132    let mut result_entries: Vec<DiffEntry> = Vec::new();
10133    let mut renamed_deleted: HashSet<usize> = HashSet::new();
10134    let mut used_added2 = vec![false; added.len()];
10135    let mut source_contents = vec![None; sources.len()];
10136
10137    if !added.is_empty() {
10138        let added_contents = parallel_load_optional_blobs(odb, added.len(), par_opts, 0, |ai| {
10139            read_added_entry_bytes(odb, &added[ai], work_root)
10140        });
10141
10142        // Git always scores exact OID copy/rename pairs before applying renameLimit.
10143        let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
10144        for (si, (src_path, src_oid, _)) in sources.iter().enumerate() {
10145            for (ai, add) in added.iter().enumerate() {
10146                if add.new_path.as_deref() == Some(src_path.as_str()) {
10147                    continue;
10148                }
10149                let add_oid = added_entry_oid(odb, add, &added_contents, ai)?;
10150                if *src_oid == add_oid && add_oid != zero_oid() {
10151                    exact_scores.push((100, si, ai));
10152                }
10153            }
10154        }
10155        exact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
10156        apply_copy_score_assignments(
10157            &exact_scores,
10158            &sources,
10159            &added,
10160            &source_modes,
10161            &mut used_added2,
10162            &mut renamed_deleted,
10163            &mut result_entries,
10164        );
10165
10166        let remaining_added: Vec<usize> = used_added2
10167            .iter()
10168            .enumerate()
10169            .filter_map(|(i, used)| (!*used).then_some(i))
10170            .collect();
10171        let remaining_source_count = sources
10172            .iter()
10173            .enumerate()
10174            .filter(|(si, (_, _, is_deleted))| !*is_deleted || !renamed_deleted.contains(si))
10175            .count();
10176
10177        let skip_inexact = remaining_added.is_empty()
10178            || inexact_matrix_exceeds_limit(
10179                remaining_added.len(),
10180                remaining_source_count,
10181                rename_limit,
10182            );
10183
10184        if !skip_inexact {
10185            source_contents = parallel_load_optional_blobs(odb, sources.len(), par_opts, 0, |si| {
10186                odb.read(&sources[si].1).ok().map(|obj| obj.data)
10187            });
10188
10189            let est_bytes: usize = source_contents
10190                .iter()
10191                .chain(added_contents.iter())
10192                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
10193                .sum();
10194
10195            let source_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
10196                odb,
10197                sources.len(),
10198                par_opts,
10199                est_bytes,
10200                |si| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
10201                    Ok(source_contents[si]
10202                        .as_ref()
10203                        .map(|d| RenameLineTable::from_blob(d)))
10204                },
10205            )
10206            .expect("copy source line tables");
10207            let added_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
10208                odb,
10209                added.len(),
10210                par_opts,
10211                est_bytes,
10212                |ai| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
10213                    Ok(added_contents[ai]
10214                        .as_ref()
10215                        .map(|d| RenameLineTable::from_blob(d)))
10216                },
10217            )
10218            .expect("copy added line tables");
10219
10220            let score_rows: Vec<Vec<(u32, usize, usize)>> = parallel_map_indices(
10221                odb,
10222                sources.len(),
10223                par_opts,
10224                est_bytes,
10225                |si| -> std::result::Result<Vec<(u32, usize, usize)>, std::convert::Infallible> {
10226                    if renamed_deleted.contains(&si) {
10227                        return Ok(Vec::new());
10228                    }
10229                    let (src_path, _, _) = &sources[si];
10230                    let mut row: Vec<(u32, usize, usize)> = Vec::new();
10231                    for &ai in &remaining_added {
10232                        let add = &added[ai];
10233                        if add.new_path.as_deref() == Some(src_path.as_str()) {
10234                            continue;
10235                        }
10236                        let score = match (&source_tables[si], &added_tables[ai]) {
10237                            (Some(old_table), Some(new_table)) => {
10238                                RenameLineTable::similarity_score(old_table, new_table)
10239                            }
10240                            _ => 0,
10241                        };
10242                        if score >= threshold {
10243                            row.push((score, si, ai));
10244                        }
10245                    }
10246                    Ok(row)
10247                },
10248            )
10249            .expect("copy score rows");
10250            let mut inexact_scores: Vec<(u32, usize, usize)> =
10251                score_rows.into_iter().flatten().collect();
10252            inexact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
10253            apply_copy_score_assignments(
10254                &inexact_scores,
10255                &sources,
10256                &added,
10257                &source_modes,
10258                &mut used_added2,
10259                &mut renamed_deleted,
10260                &mut result_entries,
10261            );
10262        }
10263    }
10264
10265    // Keep deleted entries that weren't consumed by a rename.
10266    for entry in deleted.into_iter() {
10267        if let Some(ref path) = entry.old_path {
10268            if let Some(&si) = deleted_source_idx.get(path) {
10269                if renamed_deleted.contains(&si) {
10270                    // This deletion was consumed by a rename; skip it.
10271                    continue;
10272                }
10273            }
10274        }
10275        result_entries.push(entry);
10276    }
10277
10278    let mut result = others;
10279    result.extend(result_entries);
10280    // Keep unmatched added entries.
10281    for (i, entry) in added.into_iter().enumerate() {
10282        if !used_added2[i] {
10283            result.push(entry);
10284        }
10285    }
10286
10287    // Like `git diff -C`, only created paths become copies: a modification is a copy
10288    // destination only after `-B` splits it into a deletion and an addition, which
10289    // `postprocess_diff_entries` does before calling this.
10290    let mut final_result = result;
10291    sort_entries_by_path(&mut final_result);
10292    Ok(final_result)
10293}
10294
10295/// Options for Git-style rename, copy, and break-rewrite post-processing on tree diffs.
10296#[derive(Debug, Clone, Copy)]
10297pub struct DiffPostprocessOptions {
10298    /// When set, `--break-rewrites` threshold on Git's internal 0–[`GIT_DIFF_MAX_SCORE`] scale
10299    /// (`None` = off).
10300    pub break_score: Option<u64>,
10301    /// `-M` similarity threshold (0–100); `100` disables rename detection.
10302    pub rename_threshold: u32,
10303    /// `-C` copy threshold (0–100); `None` skips copy detection.
10304    pub copy_threshold: Option<u32>,
10305    /// `-C --find-copies-harder`: treat unmodified source-tree paths as copy sources.
10306    pub find_copies_harder: bool,
10307    /// When break-rewrite runs on tree↔worktree diffs, fill `new_oid` from worktree bytes for
10308    /// `--raw` output (matches `git diff <tree> -B`; leave false for index↔worktree / diff-files).
10309    pub materialize_break_worktree_new_oid: bool,
10310}
10311
10312impl Default for DiffPostprocessOptions {
10313    fn default() -> Self {
10314        Self {
10315            break_score: None,
10316            rename_threshold: 100,
10317            copy_threshold: None,
10318            find_copies_harder: false,
10319            materialize_break_worktree_new_oid: false,
10320        }
10321    }
10322}
10323
10324/// Index and git directory used to read canonical (clean) worktree bytes during break-rewrite
10325/// post-processing on tree↔worktree diffs.
10326#[derive(Debug, Clone, Copy)]
10327pub struct DiffPostprocessWorktree<'a> {
10328    /// Git directory for config and attributes (linked worktrees / gitfiles).
10329    pub repository_git_dir: &'a Path,
10330    /// Current index; stage-0 entries supply autocrlf index-guard blobs when needed.
10331    pub index: &'a Index,
10332}
10333
10334fn blob_bytes(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
10335    Ok(odb.read(oid)?.data)
10336}
10337
10338/// Bytes on the "new" side of a diff entry (ODB blob, or worktree file when `new_oid` is null).
10339///
10340/// When `worktree` is set, worktree paths are normalized with the same attribute/filter rules as
10341/// [`diff_tree_to_worktree`]; otherwise raw filesystem bytes are read.
10342fn read_new_side_bytes(
10343    odb: &Odb,
10344    entry: &DiffEntry,
10345    work_root: Option<&Path>,
10346    worktree: Option<&DiffPostprocessWorktree<'_>>,
10347    config: &ConfigSet,
10348) -> Result<Vec<u8>> {
10349    if entry.new_oid != zero_oid() {
10350        return blob_bytes(odb, &entry.new_oid);
10351    }
10352    let path = entry.new_path.as_deref().ok_or_else(|| {
10353        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
10354    })?;
10355    let root = work_root.ok_or_else(|| {
10356        Error::ObjectNotFound(format!(
10357            "worktree bytes required for {} but no work_root was provided",
10358            path
10359        ))
10360    })?;
10361    if let Some(wctx) = worktree {
10362        let mode = entry.new_mode.bits();
10363        let index_entry = wctx.index.get(path.as_bytes(), 0);
10364        return read_worktree_side_bytes_for_diff(odb, root, path, mode, config, index_entry, None);
10365    }
10366    fs::read(root.join(path)).map_err(Error::Io)
10367}
10368
10369/// True when clean-smudged worktree bytes differ from the raw on-disk file (e.g. `eol=lf` + CRLF).
10370fn worktree_side_differs_after_normalize(
10371    odb: &Odb,
10372    entry: &DiffEntry,
10373    work_root: &Path,
10374    worktree: &DiffPostprocessWorktree<'_>,
10375    config: &ConfigSet,
10376) -> Result<bool> {
10377    let path = entry.new_path.as_deref().ok_or_else(|| {
10378        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
10379    })?;
10380    let raw = fs::read(work_root.join(path)).map_err(Error::Io)?;
10381    let canonical = read_new_side_bytes(odb, entry, Some(work_root), Some(worktree), config)?;
10382    Ok(raw != canonical)
10383}
10384
10385fn split_broken_modifications(
10386    odb: &Odb,
10387    work_root: Option<&Path>,
10388    entries: Vec<DiffEntry>,
10389    break_score: u64,
10390    worktree: Option<&DiffPostprocessWorktree<'_>>,
10391    config: &ConfigSet,
10392) -> Result<Vec<DiffEntry>> {
10393    let mut out = Vec::with_capacity(entries.len());
10394    for e in entries {
10395        if e.status != DiffStatus::Modified {
10396            out.push(e);
10397            continue;
10398        }
10399        let old = blob_bytes(odb, &e.old_oid)?;
10400        let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
10401        if should_break_rewrite_pair(&old, &new, break_score) {
10402            let path = e.path().to_owned();
10403            out.push(DiffEntry {
10404                status: DiffStatus::Deleted,
10405                old_path: Some(path.clone()),
10406                new_path: None,
10407                old_mode: e.old_mode,
10408                new_mode: FileMode::ABSENT,
10409                old_oid: e.old_oid,
10410                new_oid: zero_oid(),
10411                score: None,
10412            });
10413            out.push(DiffEntry {
10414                status: DiffStatus::Added,
10415                old_path: None,
10416                new_path: Some(path),
10417                old_mode: FileMode::ABSENT,
10418                new_mode: e.new_mode,
10419                old_oid: zero_oid(),
10420                new_oid: e.new_oid,
10421                score: None,
10422            });
10423        } else {
10424            out.push(e);
10425        }
10426    }
10427    Ok(out)
10428}
10429
10430fn annotate_broken_modifications(
10431    odb: &Odb,
10432    work_root: Option<&Path>,
10433    entries: Vec<DiffEntry>,
10434    break_score: u64,
10435    worktree: Option<&DiffPostprocessWorktree<'_>>,
10436    config: &ConfigSet,
10437    materialize_worktree_oid: bool,
10438) -> Result<Vec<DiffEntry>> {
10439    let mut out = Vec::with_capacity(entries.len());
10440    for mut e in entries {
10441        if e.status == DiffStatus::Modified {
10442            let old = blob_bytes(odb, &e.old_oid)?;
10443            let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
10444            if should_break_rewrite_pair(&old, &new, break_score) {
10445                // `git diff-files -B` keeps plain `M` when the worktree side is still null.
10446                // `git diff <tree> -B` materializes the clean OID; `M###` appears only when
10447                // attributes/filters changed the bytes used for break scoring (e.g. `eol=lf`).
10448                let show_score = if e.new_oid != zero_oid() {
10449                    true
10450                } else if materialize_worktree_oid {
10451                    match (work_root, worktree) {
10452                        (Some(root), Some(wctx)) => {
10453                            worktree_side_differs_after_normalize(odb, &e, root, wctx, config)?
10454                        }
10455                        _ => false,
10456                    }
10457                } else {
10458                    false
10459                };
10460                if show_score {
10461                    e.score = rewrite_dissimilarity_index_percent(&old, &new);
10462                }
10463            }
10464        }
10465        out.push(e);
10466    }
10467    Ok(out)
10468}
10469
10470fn materialize_break_worktree_new_oids(
10471    odb: &Odb,
10472    work_root: Option<&Path>,
10473    entries: &mut [DiffEntry],
10474    worktree: Option<&DiffPostprocessWorktree<'_>>,
10475    config: &ConfigSet,
10476) -> Result<()> {
10477    for e in entries {
10478        if e.new_oid != zero_oid() {
10479            continue;
10480        }
10481        if !matches!(e.status, DiffStatus::Modified | DiffStatus::Added) {
10482            continue;
10483        }
10484        let bytes = read_new_side_bytes(odb, e, work_root, worktree, config)?;
10485        e.new_oid = odb.hash(ObjectKind::Blob, &bytes)?;
10486    }
10487    Ok(())
10488}
10489
10490/// After break-rewrite splits a modified path into delete+add, rename detection may pair the add
10491/// with another deletion (`g` → `f`). Git drops the unused delete at the rename target path.
10492fn prune_break_deletions_obviated_by_renames(entries: Vec<DiffEntry>) -> Vec<DiffEntry> {
10493    use std::collections::HashSet;
10494    let rename_targets: HashSet<String> = entries
10495        .iter()
10496        .filter(|e| e.status == DiffStatus::Renamed)
10497        .filter_map(|e| e.new_path.clone())
10498        .collect();
10499    if rename_targets.is_empty() {
10500        return entries;
10501    }
10502    entries
10503        .into_iter()
10504        .filter(|e| {
10505            if e.status != DiffStatus::Deleted {
10506                return true;
10507            }
10508            e.old_path
10509                .as_ref()
10510                .is_none_or(|p| !rename_targets.contains(p))
10511        })
10512        .collect()
10513}
10514
10515/// Apply break-rewrite, rename, and copy detection to a flat tree or index diff.
10516///
10517/// `source_tree_oid` supplies `--find-copies-harder` candidates; `work_root` is used when added
10518/// entries carry a zero OID (index↔worktree). Pass `worktree` when break-rewrite runs on a
10519/// tree↔worktree diff so worktree-side bytes honor attributes and filters.
10520///
10521/// # Errors
10522///
10523/// Propagates object read failures while evaluating break scores.
10524#[allow(clippy::too_many_arguments)]
10525pub fn postprocess_diff_entries(
10526    odb: &Odb,
10527    entries: Vec<DiffEntry>,
10528    source_tree_oid: Option<&ObjectId>,
10529    work_root: Option<&Path>,
10530    worktree: Option<&DiffPostprocessWorktree<'_>>,
10531    config: &ConfigSet,
10532    thread_policy: &ThreadPolicy,
10533    opts: &DiffPostprocessOptions,
10534    inexact_notice: Option<&mut Option<InexactRenameDetectionSkipped>>,
10535) -> Result<Vec<DiffEntry>> {
10536    let mut entries = entries;
10537    if let Some(break_score) = opts.break_score {
10538        let rename_or_copy = opts.rename_threshold < 100 || opts.copy_threshold.is_some();
10539        entries = if rename_or_copy {
10540            split_broken_modifications(odb, work_root, entries, break_score, worktree, config)?
10541        } else {
10542            annotate_broken_modifications(
10543                odb,
10544                work_root,
10545                entries,
10546                break_score,
10547                worktree,
10548                config,
10549                opts.materialize_break_worktree_new_oid,
10550            )?
10551        };
10552        if opts.materialize_break_worktree_new_oid {
10553            materialize_break_worktree_new_oids(odb, work_root, &mut entries, worktree, config)?;
10554        }
10555    }
10556
10557    let source_tree_entries = source_tree_oid
10558        .map(|t| flatten_tree_entries(odb, t))
10559        .transpose()?
10560        .unwrap_or_default();
10561
10562    if opts.copy_threshold.is_some() || opts.find_copies_harder {
10563        let threshold = opts.copy_threshold.unwrap_or(opts.rename_threshold.min(99));
10564        detect_copies_with_policy(
10565            odb,
10566            work_root,
10567            entries,
10568            threshold,
10569            opts.find_copies_harder,
10570            &source_tree_entries,
10571            config,
10572            thread_policy,
10573        )
10574    } else if opts.rename_threshold < 100 {
10575        let skip_basename = opts.break_score.is_some();
10576        let out = detect_renames_with_policy(
10577            odb,
10578            work_root,
10579            entries,
10580            opts.rename_threshold,
10581            config,
10582            thread_policy,
10583            None,
10584            DetectRenamesOptions {
10585                skip_basename_heuristic: skip_basename,
10586            },
10587        )?;
10588        if let Some(slot) = inexact_notice {
10589            if out.inexact_skipped.is_some() {
10590                *slot = out.inexact_skipped;
10591            }
10592        }
10593        Ok(out.entries)
10594    } else {
10595        Ok(entries)
10596    }
10597}
10598
10599/// Depth-first list of `(path, mode, oid)` blob entries in a tree (for copy detection).
10600pub fn flatten_tree_entries(
10601    odb: &Odb,
10602    tree_oid: &ObjectId,
10603) -> Result<Vec<(String, FileMode, ObjectId)>> {
10604    fn walk(
10605        odb: &Odb,
10606        tree_oid: &ObjectId,
10607        prefix: &str,
10608        out: &mut Vec<(String, FileMode, ObjectId)>,
10609    ) -> Result<()> {
10610        let obj = odb.read(tree_oid)?;
10611        let oid_len = odb.hash_algo().len();
10612        for e in TreeRefIter::with_oid_len(&obj.data, oid_len) {
10613            let e = e?;
10614            let name = String::from_utf8_lossy(e.name);
10615            let path = if prefix.is_empty() {
10616                name.to_string()
10617            } else {
10618                format!("{prefix}/{name}")
10619            };
10620            if e.mode == 0o040000 {
10621                walk(odb, &e.oid, &path, out)?;
10622            } else {
10623                out.push((path, FileMode::new(e.mode), e.oid));
10624            }
10625        }
10626        Ok(())
10627    }
10628    let mut out = Vec::new();
10629    walk(odb, tree_oid, "", &mut out)?;
10630    Ok(out)
10631}
10632
10633/// Apply Git-style rename and optional copy detection for index↔worktree diffs.
10634///
10635/// When `copies` is true (Git `diff.renames` / `status.renames` set to `copy`/`copies`),
10636/// runs copy detection, which also pairs deleted sources with one rename and any additional
10637/// destinations as copies.
10638///
10639/// # Errors
10640///
10641/// Propagates errors from reading the `head_tree` object from `odb`.
10642#[allow(clippy::too_many_arguments)]
10643pub fn status_apply_rename_copy_detection(
10644    odb: &Odb,
10645    unstaged_raw: Vec<DiffEntry>,
10646    threshold: u32,
10647    copies: bool,
10648    head_tree: Option<&ObjectId>,
10649    config: &ConfigSet,
10650    thread_policy: &ThreadPolicy,
10651    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
10652) -> Result<Vec<DiffEntry>> {
10653    if !copies {
10654        return detect_renames_with_policy(
10655            odb,
10656            None,
10657            unstaged_raw,
10658            threshold,
10659            config,
10660            thread_policy,
10661            cancel_watch,
10662            DetectRenamesOptions::default(),
10663        )
10664        .map(|r| r.entries);
10665    }
10666    let source_tree_entries: Vec<(String, FileMode, ObjectId)> = match head_tree {
10667        Some(oid) => flatten_tree(odb, oid, "")?
10668            .into_iter()
10669            .map(|e| (e.path, FileMode::new(e.mode), e.oid))
10670            .collect(),
10671        None => Vec::new(),
10672    };
10673    detect_copies_with_policy(
10674        odb,
10675        None,
10676        unstaged_raw,
10677        threshold,
10678        false,
10679        &source_tree_entries,
10680        config,
10681        thread_policy,
10682    )
10683}
10684
10685/// Check if two entries share the same filename (basename).
10686fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
10687    let old = del.old_path.as_deref().unwrap_or("");
10688    let new = add.new_path.as_deref().unwrap_or("");
10689    let old_base = old.rsplit('/').next().unwrap_or(old);
10690    let new_base = new.rsplit('/').next().unwrap_or(new);
10691    old_base == new_base && !old_base.is_empty()
10692}
10693
10694const RENAME_SCORE_MAX: u32 = 100;
10695
10696/// Uncompressed blob size without inflating object bodies (for rename pre-filtering).
10697fn blob_payload_size(odb: &Odb, oid: &ObjectId) -> Option<usize> {
10698    if oid.is_zero() {
10699        return None;
10700    }
10701    odb.read_info(oid).ok().map(|info| info.size as usize)
10702}
10703
10704/// Payload size for an added diff side (ODB blob or worktree bytes when not stored yet).
10705fn added_entry_payload_size(odb: &Odb, add: &DiffEntry, work_root: Option<&Path>) -> Option<usize> {
10706    if !add.new_oid.is_zero() {
10707        if let Some(size) = blob_payload_size(odb, &add.new_oid) {
10708            return Some(size);
10709        }
10710    }
10711    read_added_entry_bytes(odb, add, work_root).map(|data| data.len())
10712}
10713
10714/// Git `diffcore-delta` size gate: skip pairs that cannot reach `minimum_score`.
10715fn rename_pair_size_reachable(src_size: usize, dst_size: usize, minimum_score: u32) -> bool {
10716    let max_size = src_size.max(dst_size);
10717    let min_size = src_size.min(dst_size);
10718    if max_size == 0 {
10719        return minimum_score <= RENAME_SCORE_MAX;
10720    }
10721    (max_size - min_size).saturating_mul(RENAME_SCORE_MAX as usize)
10722        <= max_size.saturating_mul((RENAME_SCORE_MAX - minimum_score) as usize)
10723}
10724
10725/// Compute a similarity percentage (0–100) between two byte slices.
10726///
10727/// Matches Git rename detection: span-hash intersection (`hash_chars` in diffcore-delta),
10728/// `score = copied_bytes * 100 / max(src_size, dst_size)` on raw blob sizes.
10729fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
10730    RenameLineTable::similarity_score(
10731        &RenameLineTable::from_blob(old),
10732        &RenameLineTable::from_blob(new),
10733    )
10734}
10735
10736/// Compute rename/copy similarity percentage (0–100) between two byte slices.
10737///
10738/// Uses Git `diffcore-delta` span hashing (`hash_chars`): 64-byte or newline-bounded
10739/// chunks, CR skipped before LF in text blobs, `score = copied * 100 / max(len)`.
10740#[must_use]
10741pub fn rename_similarity_score(old: &[u8], new: &[u8]) -> u32 {
10742    compute_similarity(old, new)
10743}
10744
10745// ── Output formatting ───────────────────────────────────────────────
10746
10747/// Format a diff entry in Git's raw diff format.
10748///
10749/// Example: `:100644 100644 abc1234... def5678... M\tfile.txt`
10750pub fn format_raw(entry: &DiffEntry) -> String {
10751    let path = match entry.status {
10752        DiffStatus::Renamed | DiffStatus::Copied => {
10753            format!(
10754                "{}\t{}",
10755                entry.old_path.as_deref().unwrap_or(""),
10756                entry.new_path.as_deref().unwrap_or("")
10757            )
10758        }
10759        _ => entry.path().to_owned(),
10760    };
10761
10762    let status_str = match (entry.status, entry.score) {
10763        (DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
10764        (DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
10765        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
10766        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
10767        _ => entry.status.letter().to_string(),
10768    };
10769
10770    let (old_hex, new_hex) = raw_oid_hex_pair(&entry.old_oid, &entry.new_oid);
10771    format!(
10772        ":{} {} {} {} {}\t{}",
10773        entry.old_mode, entry.new_mode, old_hex, new_hex, status_str, path
10774    )
10775}
10776
10777/// Render a diff entry's `(old, new)` OIDs as full hex for `--raw` output,
10778/// widening any null OID to the repository's hash width.
10779///
10780/// Diff entries store null OIDs at SHA-1 width regardless of the repository
10781/// algorithm, but `git diff --raw` prints them at the real hash width (64
10782/// zeros in a SHA-256 repo). A diff entry never has both sides null, so the
10783/// width is taken from whichever side carries a real object.
10784fn raw_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
10785    let width = if !old.is_zero() {
10786        old.algo().hex_len()
10787    } else if !new.is_zero() {
10788        new.algo().hex_len()
10789    } else {
10790        old.algo().hex_len()
10791    };
10792    let render = |oid: &ObjectId| {
10793        if oid.is_zero() {
10794            "0".repeat(width)
10795        } else {
10796            oid.to_hex()
10797        }
10798    };
10799    (render(old), render(new))
10800}
10801
10802/// Format a diff entry with abbreviated OIDs.
10803pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
10804    let old_hex = format!("{}", entry.old_oid);
10805    let new_hex = format!("{}", entry.new_oid);
10806    let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
10807    let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
10808
10809    // Renames/copies carry a similarity score and a `<old>\t<new>` path pair.
10810    let path = match entry.status {
10811        DiffStatus::Renamed | DiffStatus::Copied => format!(
10812            "{}\t{}",
10813            entry.old_path.as_deref().unwrap_or(""),
10814            entry.new_path.as_deref().unwrap_or("")
10815        ),
10816        _ => entry.path().to_owned(),
10817    };
10818    let status_str = match (entry.status, entry.score) {
10819        (DiffStatus::Renamed, Some(s)) => format!("R{s:03}"),
10820        (DiffStatus::Copied, Some(s)) => format!("C{s:03}"),
10821        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
10822        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
10823        _ => entry.status.letter().to_string(),
10824    };
10825
10826    format!(
10827        ":{} {} {} {} {}\t{}",
10828        entry.old_mode, entry.new_mode, old_abbrev, new_abbrev, status_str, path
10829    )
10830}
10831
10832/// Generate a unified diff patch for two blobs.
10833///
10834/// # Parameters
10835///
10836/// - `old_content` — the old file content (empty for added files).
10837/// - `new_content` — the new file content (empty for deleted files).
10838/// - `old_path` — display path for the old side.
10839/// - `new_path` — display path for the new side.
10840/// - `context_lines` — number of context lines around changes (default: 3).
10841/// - Inter-hunk context defaults to `0` (see [`unified_diff_with_prefix`]).
10842///
10843/// # Returns
10844///
10845/// The unified diff as a string.
10846pub fn unified_diff(
10847    old_content: &str,
10848    new_content: &str,
10849    old_path: &str,
10850    new_path: &str,
10851    context_lines: usize,
10852    indent_heuristic: bool,
10853    quote_path_fully: bool,
10854) -> String {
10855    unified_diff_with_prefix(
10856        old_content,
10857        new_content,
10858        old_path,
10859        new_path,
10860        context_lines,
10861        0,
10862        "a/",
10863        "b/",
10864        indent_heuristic,
10865        quote_path_fully,
10866    )
10867}
10868
10869/// Same as `unified_diff` but with configurable source/destination prefixes.
10870///
10871/// `inter_hunk_context` is Git's `--inter-hunk-context`: adjacent hunks merge when
10872/// the unchanged gap between them is at most `2 * context_lines + inter_hunk_context` lines.
10873#[allow(clippy::too_many_arguments)] // Mirrors Git-style unified diff parameters.
10874pub fn unified_diff_with_prefix(
10875    old_content: &str,
10876    new_content: &str,
10877    old_path: &str,
10878    new_path: &str,
10879    context_lines: usize,
10880    inter_hunk_context: usize,
10881    src_prefix: &str,
10882    dst_prefix: &str,
10883    indent_heuristic: bool,
10884    quote_path_fully: bool,
10885) -> String {
10886    unified_diff_with_prefix_and_funcname(
10887        old_content,
10888        new_content,
10889        old_path,
10890        new_path,
10891        context_lines,
10892        inter_hunk_context,
10893        src_prefix,
10894        dst_prefix,
10895        None,
10896        indent_heuristic,
10897        quote_path_fully,
10898    )
10899}
10900
10901/// Same as [`unified_diff_with_prefix`] with optional custom hunk-header
10902/// function-name matching.
10903#[allow(clippy::too_many_arguments)]
10904pub fn unified_diff_with_prefix_and_funcname(
10905    old_content: &str,
10906    new_content: &str,
10907    old_path: &str,
10908    new_path: &str,
10909    context_lines: usize,
10910    inter_hunk_context: usize,
10911    src_prefix: &str,
10912    dst_prefix: &str,
10913    funcname_matcher: Option<&FuncnameMatcher>,
10914    indent_heuristic: bool,
10915    quote_path_fully: bool,
10916) -> String {
10917    unified_diff_with_prefix_and_funcname_and_algorithm(
10918        old_content,
10919        new_content,
10920        old_path,
10921        new_path,
10922        context_lines,
10923        inter_hunk_context,
10924        src_prefix,
10925        dst_prefix,
10926        funcname_matcher,
10927        similar::Algorithm::Myers,
10928        false,
10929        false,
10930        indent_heuristic,
10931        quote_path_fully,
10932    )
10933}
10934
10935/// Same as [`unified_diff_with_prefix_and_funcname_and_algorithm`], streaming to `out`.
10936///
10937/// # Errors
10938///
10939/// Returns I/O errors from `out`.
10940#[allow(clippy::too_many_arguments)]
10941pub fn write_unified_diff_with_prefix_and_funcname_and_algorithm<W: std::io::Write>(
10942    out: &mut W,
10943    old_content: &str,
10944    new_content: &str,
10945    old_path: &str,
10946    new_path: &str,
10947    context_lines: usize,
10948    inter_hunk_context: usize,
10949    src_prefix: &str,
10950    dst_prefix: &str,
10951    funcname_matcher: Option<&FuncnameMatcher>,
10952    algorithm: similar::Algorithm,
10953    function_context: bool,
10954    use_git_histogram: bool,
10955    indent_heuristic: bool,
10956    quote_path_fully: bool,
10957) -> Result<()> {
10958    if function_context {
10959        let patch = unified_diff_with_function_context(
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            funcname_matcher,
10969            algorithm,
10970            indent_heuristic,
10971            quote_path_fully,
10972        );
10973        out.write_all(patch.as_bytes())?;
10974        return Ok(());
10975    }
10976
10977    if use_git_histogram {
10978        let patch = unified_diff_histogram_with_prefix_and_funcname_indent(
10979            old_content,
10980            new_content,
10981            old_path,
10982            new_path,
10983            context_lines,
10984            inter_hunk_context,
10985            src_prefix,
10986            dst_prefix,
10987            funcname_matcher,
10988            indent_heuristic,
10989            quote_path_fully,
10990        );
10991        out.write_all(patch.as_bytes())?;
10992        return Ok(());
10993    }
10994
10995    if algorithm == similar::Algorithm::Myers && !function_context {
10996        let patch = unified_diff_git_myers_with_prefix_and_funcname(
10997            old_content,
10998            new_content,
10999            old_path,
11000            new_path,
11001            context_lines,
11002            inter_hunk_context,
11003            src_prefix,
11004            dst_prefix,
11005            funcname_matcher,
11006            indent_heuristic,
11007            quote_path_fully,
11008        );
11009        out.write_all(patch.as_bytes())?;
11010        return Ok(());
11011    }
11012
11013    if algorithm == similar::Algorithm::Lcs && !function_context {
11014        let patch = unified_diff_git_minimal_with_prefix_and_funcname(
11015            old_content,
11016            new_content,
11017            old_path,
11018            new_path,
11019            context_lines,
11020            inter_hunk_context,
11021            src_prefix,
11022            dst_prefix,
11023            funcname_matcher,
11024            indent_heuristic,
11025            quote_path_fully,
11026        );
11027        out.write_all(patch.as_bytes())?;
11028        return Ok(());
11029    }
11030
11031    if algorithm == similar::Algorithm::Patience && !function_context {
11032        let patch = unified_diff_git_patience_with_prefix_and_funcname(
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            indent_heuristic,
11043            quote_path_fully,
11044        );
11045        out.write_all(patch.as_bytes())?;
11046        return Ok(());
11047    }
11048
11049    use crate::quote_path::format_diff_path_with_prefix;
11050    use similar::{udiff::UnifiedDiffHunk, TextDiff};
11051
11052    let diff = TextDiff::configure()
11053        .algorithm(algorithm)
11054        .diff_lines(old_content, new_content);
11055    let compacted_ops = diff_indent_heuristic::diff_lines_ops_compacted(
11056        old_content,
11057        new_content,
11058        algorithm,
11059        indent_heuristic,
11060    );
11061
11062    if old_path == "/dev/null" {
11063        out.write_all(b"--- /dev/null\n")?;
11064    } else if src_prefix.is_empty() {
11065        writeln!(out, "--- {old_path}")?;
11066    } else {
11067        out.write_all(b"--- ")?;
11068        out.write_all(
11069            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
11070        )?;
11071        out.write_all(b"\n")?;
11072    }
11073    if new_path == "/dev/null" {
11074        out.write_all(b"+++ /dev/null\n")?;
11075    } else if dst_prefix.is_empty() {
11076        writeln!(out, "+++ {new_path}")?;
11077    } else {
11078        out.write_all(b"+++ ")?;
11079        out.write_all(
11080            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
11081        )?;
11082        out.write_all(b"\n")?;
11083    }
11084
11085    let old_lines: Vec<&str> = old_content.lines().collect();
11086    let max_common_gap = context_lines
11087        .saturating_mul(2)
11088        .saturating_add(inter_hunk_context);
11089    let op_groups = group_diff_ops_gap(compacted_ops, context_lines, max_common_gap);
11090
11091    let mut hunk_buf = String::new();
11092    let mut eof_annot = UnifiedDiffNoNewlineAnnotator::new(old_content, new_content);
11093    for ops in op_groups {
11094        if ops.is_empty() {
11095            continue;
11096        }
11097        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
11098        hunk_buf.clear();
11099        use std::fmt::Write as FmtWrite;
11100        let _ = FmtWrite::write_fmt(&mut hunk_buf, format_args!("{hunk}"));
11101        let mut fragment = hunk_buf.clone();
11102        if let Some(first_newline) = hunk_buf.find('\n') {
11103            let header_line = &hunk_buf[..first_newline];
11104            let rest = &hunk_buf[first_newline..];
11105            if let Some(func_ctx) =
11106                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
11107            {
11108                fragment.clear();
11109                fragment.push_str(header_line);
11110                fragment.push(' ');
11111                fragment.push_str(&func_ctx);
11112                fragment.push_str(rest);
11113            }
11114        }
11115        eof_annot.write_hunk(out, &fragment)?;
11116    }
11117    Ok(())
11118}
11119
11120/// Same as [`unified_diff_with_prefix_and_funcname`] but allows callers to
11121/// choose the line diff algorithm used for hunk generation.
11122///
11123/// When `function_context` is true (`git diff -W`), hunks are expanded to
11124/// whole logical functions using the same rules as Git's `XDL_EMIT_FUNCCONTEXT`.
11125#[allow(clippy::too_many_arguments)]
11126pub fn unified_diff_with_prefix_and_funcname_and_algorithm(
11127    old_content: &str,
11128    new_content: &str,
11129    old_path: &str,
11130    new_path: &str,
11131    context_lines: usize,
11132    inter_hunk_context: usize,
11133    src_prefix: &str,
11134    dst_prefix: &str,
11135    funcname_matcher: Option<&FuncnameMatcher>,
11136    algorithm: similar::Algorithm,
11137    function_context: bool,
11138    use_git_histogram: bool,
11139    indent_heuristic: bool,
11140    quote_path_fully: bool,
11141) -> String {
11142    let mut output = Vec::new();
11143    if write_unified_diff_with_prefix_and_funcname_and_algorithm(
11144        &mut output,
11145        old_content,
11146        new_content,
11147        old_path,
11148        new_path,
11149        context_lines,
11150        inter_hunk_context,
11151        src_prefix,
11152        dst_prefix,
11153        funcname_matcher,
11154        algorithm,
11155        function_context,
11156        use_git_histogram,
11157        indent_heuristic,
11158        quote_path_fully,
11159    )
11160    .is_err()
11161    {
11162        output.clear();
11163    }
11164    String::from_utf8_lossy(&output).into_owned()
11165}
11166
11167/// Group diff ops into hunks like Git's `xdl_get_hunk`: two changes merge into one hunk while
11168/// the run of unchanged lines between them is at most `max_common_gap`
11169/// (`2 * context + inter_hunk_context`), and each hunk keeps at most `context` unchanged lines
11170/// at its edges. Unlike `similar::group_diff_ops`, the split threshold is decoupled from the
11171/// edge context so odd gap limits group exactly like Git.
11172pub(crate) fn group_diff_ops_gap(
11173    mut ops: Vec<similar::DiffOp>,
11174    context: usize,
11175    max_common_gap: usize,
11176) -> Vec<Vec<similar::DiffOp>> {
11177    use similar::DiffOp;
11178    if ops.is_empty() {
11179        return vec![];
11180    }
11181
11182    let mut pending_group = Vec::new();
11183    let mut rv = Vec::new();
11184
11185    if let Some(DiffOp::Equal {
11186        old_index,
11187        new_index,
11188        len,
11189    }) = ops.first_mut()
11190    {
11191        let offset = (*len).saturating_sub(context);
11192        *old_index += offset;
11193        *new_index += offset;
11194        *len -= offset;
11195    }
11196
11197    if let Some(DiffOp::Equal { len, .. }) = ops.last_mut() {
11198        *len -= (*len).saturating_sub(context);
11199    }
11200
11201    for op in ops.into_iter() {
11202        if let DiffOp::Equal {
11203            old_index,
11204            new_index,
11205            len,
11206        } = op
11207        {
11208            // End the current group and start a new one whenever the unchanged
11209            // run is too long to fuse the surrounding changes.
11210            if len > max_common_gap {
11211                pending_group.push(DiffOp::Equal {
11212                    old_index,
11213                    new_index,
11214                    len: context,
11215                });
11216                rv.push(pending_group);
11217                let offset = len.saturating_sub(context);
11218                pending_group = vec![DiffOp::Equal {
11219                    old_index: old_index + offset,
11220                    new_index: new_index + offset,
11221                    len: len - offset,
11222                }];
11223                continue;
11224            }
11225        }
11226        pending_group.push(op);
11227    }
11228
11229    match &pending_group[..] {
11230        &[] | &[similar::DiffOp::Equal { .. }] => {}
11231        _ => rv.push(pending_group),
11232    }
11233
11234    rv
11235}
11236#[expect(clippy::too_many_arguments)]
11237/// `git diff -W`: expand each hunk to include full function bodies (see Git `xemit.c`).
11238fn unified_diff_with_function_context(
11239    old_content: &str,
11240    new_content: &str,
11241    old_path: &str,
11242    new_path: &str,
11243    context_lines: usize,
11244    inter_hunk_context: usize,
11245    src_prefix: &str,
11246    dst_prefix: &str,
11247    funcname_matcher: Option<&FuncnameMatcher>,
11248    algorithm: similar::Algorithm,
11249    indent_heuristic: bool,
11250    quote_path_fully: bool,
11251) -> String {
11252    use crate::quote_path::format_diff_path_with_prefix;
11253    use similar::{udiff::UnifiedDiffHunk, TextDiff};
11254
11255    let diff = TextDiff::configure()
11256        .algorithm(algorithm)
11257        .diff_lines(old_content, new_content);
11258
11259    let old_lines: Vec<&str> = old_content.lines().collect();
11260    let new_lines: Vec<&str> = new_content.lines().collect();
11261    let n_old = old_lines.len();
11262    let n_new = new_lines.len();
11263
11264    // Group changes the way Git's xdl_get_hunk does: merge while the unchanged
11265    // gap is at most `2*context + inter_hunk_context`. `similar::group_diff_ops`
11266    // splits only at gap > `2*radius`, which over-merges changes in *different*
11267    // functions (e.g. a leading insertion and a body change) into one hunk and
11268    // then over-expands the function context (t4015 #136). Use the gap-correct
11269    // grouping (same as the non-function-context path).
11270    let max_common_gap = context_lines
11271        .saturating_mul(2)
11272        .saturating_add(inter_hunk_context);
11273    let all_ops = diff.ops().to_vec();
11274    let op_groups = group_diff_ops_gap(all_ops.clone(), context_lines, max_common_gap);
11275
11276    let mut ranges: Vec<(usize, usize, usize, usize)> = Vec::new();
11277
11278    for ops in op_groups {
11279        if ops.is_empty() {
11280            continue;
11281        }
11282        let i1_anchor = func_context_old_anchor(&ops, n_old);
11283        let i1_end = hunk_old_change_end_exclusive(&ops);
11284        let skip_preimage_pull =
11285            append_with_whole_function_added(&ops, n_old, n_new, &new_lines, funcname_matcher);
11286        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
11287        let hunk_str = format!("{hunk}");
11288        let header_line = hunk_str
11289            .lines()
11290            .next()
11291            .unwrap_or("")
11292            .trim_end_matches(['\r', '\n']);
11293        let Some((base_s1, _base_e1, _base_s2, _base_e2)) =
11294            parse_unified_hunk_header_ranges(header_line)
11295        else {
11296            continue;
11297        };
11298
11299        let ctx = context_lines;
11300        let (s1, e1, s2, e2) = if skip_preimage_pull {
11301            let s = n_old.saturating_sub(ctx);
11302            let s2 = map_old_line_to_new(&all_ops, s, n_new).min(n_new);
11303            (s, n_old, s2, n_new)
11304        } else {
11305            let mut s1 = base_s1.saturating_sub(ctx);
11306            let mut s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
11307
11308            let base_pre_s1 = i1_anchor.saturating_sub(ctx);
11309            if base_pre_s1 < s1 {
11310                s1 = base_pre_s1;
11311                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
11312            }
11313
11314            let fs1 = expand_func_pre_start(s1, i1_anchor, n_old, &old_lines, funcname_matcher);
11315            if fs1 < s1 {
11316                s1 = fs1;
11317                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
11318            }
11319
11320            // `i1_end` is the exclusive end of the changed region; its post-image
11321            // context is `ctx` lines (Git's `xche->i1 + xche->chg1 + lctx`). The
11322            // hunk's `base_e1` already includes the group's trailing context, so
11323            // use the change end + ctx directly to avoid double-counting context
11324            // (which over-extended the hunk and merged separate functions — t4015 #136).
11325            let mut e1 = (i1_end + ctx).min(n_old);
11326            let mut e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
11327            let fe1 = expand_func_post_end(e1, i1_end, n_old, &old_lines, funcname_matcher);
11328            if fe1 > e1 {
11329                e1 = fe1;
11330                e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
11331            }
11332            (s1, e1, s2, e2)
11333        };
11334
11335        ranges.push((s1, e1, s2, e2));
11336    }
11337
11338    // Merge ranges whose function-context expansion made them overlap on the old
11339    // side (Git emits a single hunk in that case).
11340    ranges.sort_by_key(|r| (r.0, r.2));
11341    let mut merged: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(ranges.len());
11342    for (s1, e1, s2, e2) in ranges {
11343        if let Some(last) = merged.last_mut() {
11344            if s1 < last.1 {
11345                last.1 = last.1.max(e1);
11346                last.3 = last.3.max(e2);
11347                continue;
11348            }
11349        }
11350        merged.push((s1, e1, s2, e2));
11351    }
11352    let ranges = merged;
11353
11354    let mut output = String::new();
11355    if old_path == "/dev/null" {
11356        output.push_str("--- /dev/null\n");
11357    } else if src_prefix.is_empty() {
11358        output.push_str(&format!("--- {old_path}\n"));
11359    } else {
11360        output.push_str("--- ");
11361        output.push_str(&format_diff_path_with_prefix(
11362            src_prefix,
11363            old_path,
11364            quote_path_fully,
11365        ));
11366        output.push('\n');
11367    }
11368    if new_path == "/dev/null" {
11369        output.push_str("+++ /dev/null\n");
11370    } else if dst_prefix.is_empty() {
11371        output.push_str(&format!("+++ {new_path}\n"));
11372    } else {
11373        output.push_str("+++ ");
11374        output.push_str(&format_diff_path_with_prefix(
11375            dst_prefix,
11376            new_path,
11377            quote_path_fully,
11378        ));
11379        output.push('\n');
11380    }
11381
11382    for (s1, e1, s2, e2) in ranges {
11383        if s1 >= e1 && s2 >= e2 {
11384            continue;
11385        }
11386        let old_seg =
11387            line_slice_for_diff_with_eof_nl(&old_lines, s1, e1, old_content.ends_with('\n'));
11388        let new_seg =
11389            line_slice_for_diff_with_eof_nl(&new_lines, s2, e2, new_content.ends_with('\n'));
11390        let inner_ctx = old_seg.lines().count().max(new_seg.lines().count()).max(1);
11391        let piece = unified_diff_with_prefix_and_funcname_and_algorithm(
11392            &old_seg,
11393            &new_seg,
11394            old_path,
11395            new_path,
11396            inner_ctx,
11397            0,
11398            src_prefix,
11399            dst_prefix,
11400            funcname_matcher,
11401            algorithm,
11402            false,
11403            false,
11404            indent_heuristic,
11405            quote_path_fully,
11406        );
11407        let shifted = shift_unified_hunk_headers_to_full_file(&piece, s1, s2);
11408        let with_func =
11409            enrich_unified_hunk_headers_funcname(&shifted, &old_lines, funcname_matcher);
11410        for line in with_func.lines() {
11411            if line.starts_with("--- ") || line.starts_with("+++ ") {
11412                continue;
11413            }
11414            output.push_str(line);
11415            output.push('\n');
11416        }
11417    }
11418
11419    output
11420}
11421
11422/// `piece` is a unified diff for a slice of the file; hunk headers use 1-based
11423/// coordinates relative to that slice. Shift them by `delta_old` / `delta_new`
11424/// (0-based offsets of the slice in the full file) so the combined patch applies
11425/// to the whole file.
11426fn shift_unified_hunk_headers_to_full_file(
11427    patch: &str,
11428    delta_old: usize,
11429    delta_new: usize,
11430) -> String {
11431    if delta_old == 0 && delta_new == 0 {
11432        return patch.to_owned();
11433    }
11434    let mut out = String::with_capacity(patch.len());
11435    for line in patch.lines() {
11436        if let Some(shifted) = shift_one_unified_hunk_header(line, delta_old, delta_new) {
11437            out.push_str(&shifted);
11438        } else {
11439            out.push_str(line);
11440        }
11441        out.push('\n');
11442    }
11443    out
11444}
11445
11446fn shift_one_unified_hunk_header(line: &str, delta_old: usize, delta_new: usize) -> Option<String> {
11447    let rest = line.strip_prefix("@@ ")?;
11448    let (old_chunk, after_plus) = rest.split_once(" +")?;
11449    let old_spec = old_chunk.strip_prefix('-')?;
11450    let (new_spec, suffix) = after_plus.split_once(" @@")?;
11451    let shifted_old = shift_unified_range_spec(old_spec, delta_old)?;
11452    let shifted_new = shift_unified_range_spec(new_spec, delta_new)?;
11453    Some(format!("@@ -{shifted_old} +{shifted_new} @@{suffix}"))
11454}
11455
11456fn shift_unified_range_spec(spec: &str, delta: usize) -> Option<String> {
11457    let spec = spec.trim();
11458    if let Some((start_s, count_s)) = spec.split_once(',') {
11459        let start: usize = start_s.parse().ok()?;
11460        let count: usize = count_s.parse().ok()?;
11461        Some(format!("{},{}", start.saturating_add(delta), count))
11462    } else {
11463        let start: usize = spec.parse().ok()?;
11464        Some(format!("{}", start.saturating_add(delta)))
11465    }
11466}
11467
11468/// Re-attach `@@ ... @@ <funcname>` using full-file line indices (inner diffs use slices).
11469fn enrich_unified_hunk_headers_funcname(
11470    patch: &str,
11471    full_old_lines: &[&str],
11472    funcname_matcher: Option<&FuncnameMatcher>,
11473) -> String {
11474    let mut out = String::with_capacity(patch.len());
11475    for line in patch.lines() {
11476        if let Some(fixed) = enrich_one_hunk_header_funcname(line, full_old_lines, funcname_matcher)
11477        {
11478            out.push_str(&fixed);
11479        } else {
11480            out.push_str(line);
11481        }
11482        out.push('\n');
11483    }
11484    out
11485}
11486
11487fn enrich_one_hunk_header_funcname(
11488    line: &str,
11489    full_old_lines: &[&str],
11490    funcname_matcher: Option<&FuncnameMatcher>,
11491) -> Option<String> {
11492    let after_at = line.strip_prefix("@@ ")?;
11493    let idx = after_at.find(" @@")?;
11494    let mid = after_at[..idx].trim();
11495    let tail = after_at[idx + 3..].trim_start();
11496    let header_for_parse = format!("@@ {mid} @@");
11497    let func = extract_function_context(&header_for_parse, full_old_lines, funcname_matcher, None);
11498    Some(if let Some(f) = func {
11499        format!("@@ {mid} @@ {f}")
11500    } else if !tail.is_empty() {
11501        format!("@@ {mid} @@ {tail}")
11502    } else {
11503        format!("@@ {mid} @@")
11504    })
11505}
11506
11507fn line_slice_for_diff_with_eof_nl(
11508    lines: &[&str],
11509    start: usize,
11510    end: usize,
11511    full_file_ends_with_newline: bool,
11512) -> String {
11513    if start >= end {
11514        return String::new();
11515    }
11516    let mut s = lines[start..end].join("\n");
11517    let slice_is_suffix_of_file = end == lines.len();
11518    let need_trailing_nl = if slice_is_suffix_of_file {
11519        full_file_ends_with_newline
11520    } else {
11521        true
11522    };
11523    if need_trailing_nl && !s.ends_with('\n') {
11524        s.push('\n');
11525    }
11526    s
11527}
11528
11529/// Map a 0-based old line index to the corresponding 0-based new line index using the full-file
11530/// diff ops (Git aligns context across deletions/insertions).
11531fn map_old_line_to_new(ops: &[similar::DiffOp], old_line: usize, n_new: usize) -> usize {
11532    use similar::DiffOp;
11533    let mut n = 0usize;
11534    for op in ops {
11535        match *op {
11536            DiffOp::Equal {
11537                old_index,
11538                new_index,
11539                len,
11540            } => {
11541                if old_index + len <= old_line {
11542                    n = new_index + len;
11543                    continue;
11544                }
11545                if old_index < old_line {
11546                    let take = old_line - old_index;
11547                    return (new_index + take).min(n_new);
11548                }
11549                return new_index.min(n_new);
11550            }
11551            DiffOp::Delete {
11552                old_index,
11553                old_len,
11554                new_index,
11555            } => {
11556                if old_index + old_len <= old_line {
11557                    n = new_index;
11558                    continue;
11559                }
11560                if old_index < old_line {
11561                    return new_index.min(n_new);
11562                }
11563            }
11564            DiffOp::Insert {
11565                old_index,
11566                new_index,
11567                new_len,
11568            } => {
11569                if old_index < old_line {
11570                    n = new_index + new_len;
11571                    continue;
11572                }
11573                if old_index == old_line {
11574                    // `old_line` is an exclusive end or insertion point aligned with this insert
11575                    // (e.g. EOF append maps to after the inserted block).
11576                    return (new_index + new_len).min(n_new);
11577                }
11578                return new_index.min(n_new);
11579            }
11580            DiffOp::Replace {
11581                old_index,
11582                old_len,
11583                new_index,
11584                new_len,
11585            } => {
11586                if old_index + old_len <= old_line {
11587                    n = new_index + new_len;
11588                    continue;
11589                }
11590                if old_index < old_line {
11591                    let into_old = old_line - old_index;
11592                    let mapped = new_index + into_old.min(new_len);
11593                    return mapped.min(n_new);
11594                }
11595                return new_index.min(n_new);
11596            }
11597        }
11598    }
11599    n.min(n_new)
11600}
11601
11602/// Parse `@@ -old +new @@` into 0-based half-open ranges in each file.
11603fn parse_unified_hunk_header_ranges(header: &str) -> Option<(usize, usize, usize, usize)> {
11604    let rest = header.strip_prefix("@@ ")?;
11605    let (old_tok, rest2) = rest.split_once(" +")?;
11606    let old_tok = old_tok.strip_prefix('-')?;
11607    let new_tok = rest2.split_once(" @@").map(|(a, _)| a)?;
11608
11609    fn parse_side(spec: &str) -> Option<(usize, usize)> {
11610        let spec = spec.trim();
11611        let (start_one_based, count) = if let Some((a, b)) = spec.split_once(',') {
11612            (a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)
11613        } else {
11614            let s = spec.parse::<usize>().ok()?;
11615            (s, 1usize)
11616        };
11617        let s0 = start_one_based.saturating_sub(1);
11618        let e0 = s0.saturating_add(count);
11619        Some((s0, e0))
11620    }
11621
11622    let (os, oe) = parse_side(old_tok)?;
11623    let (ns, ne) = parse_side(new_tok)?;
11624    Some((os, oe, ns, ne))
11625}
11626
11627/// Git `xemit.c`: when a hunk only inserts at EOF (first inserted line is `new_index == n_old`)
11628/// and the added text already contains a funcname line, do not pull extra context from the preimage.
11629fn append_with_whole_function_added(
11630    ops: &[similar::DiffOp],
11631    n_old: usize,
11632    n_new: usize,
11633    new_lines: &[&str],
11634    matcher: Option<&FuncnameMatcher>,
11635) -> bool {
11636    use similar::DiffOp;
11637    if n_old == 0 {
11638        return false;
11639    }
11640    let mut only_ins_or_eq = true;
11641    let mut min_new_ins = usize::MAX;
11642    for op in ops {
11643        match *op {
11644            DiffOp::Equal { .. } => {}
11645            DiffOp::Insert {
11646                new_index, new_len, ..
11647            } => {
11648                min_new_ins = min_new_ins.min(new_index);
11649                if new_len == 0 {
11650                    only_ins_or_eq = false;
11651                }
11652            }
11653            DiffOp::Delete { .. } | DiffOp::Replace { .. } => {
11654                only_ins_or_eq = false;
11655            }
11656        }
11657    }
11658    let mut insert_at_eof = false;
11659    for op in ops {
11660        if let DiffOp::Insert { old_index, .. } = *op {
11661            if old_index == n_old {
11662                insert_at_eof = true;
11663                break;
11664            }
11665        }
11666    }
11667    let append_at_eof = min_new_ins == n_old || insert_at_eof;
11668    if !only_ins_or_eq || !append_at_eof || min_new_ins == usize::MAX {
11669        return false;
11670    }
11671    // Git only skips preimage pull when the inserted block is clearly a new logical
11672    // function (see `xemit.c` walking `xdf2` for `is_func_rec`). A loose "any line
11673    // looks like a function" check would match `return` / `printf` and break `-W`
11674    // hunks that still need preimage context (t4051 `extended`).
11675    let mut j = min_new_ins;
11676    while j < n_new {
11677        let line = new_lines[j];
11678        if line.trim().is_empty() {
11679            j += 1;
11680            continue;
11681        }
11682        if let Some(m) = matcher {
11683            if m.match_line(line).is_some() {
11684                return true;
11685            }
11686        } else if inserted_block_starts_with_c_like_function_definition(line) {
11687            return true;
11688        }
11689        j += 1;
11690    }
11691    false
11692}
11693
11694fn inserted_block_starts_with_c_like_function_definition(line: &str) -> bool {
11695    let t = line.trim_start();
11696    let Some(open_paren) = t.find('(') else {
11697        return false;
11698    };
11699    let head = &t[..open_paren];
11700    let tokens: Vec<&str> = head.split_whitespace().collect();
11701    if tokens.len() < 2 {
11702        // `printf(...)`, `return (`, etc. — not `return_type name(`.
11703        return false;
11704    }
11705    let nameish = tokens.last().copied().unwrap_or("");
11706    let name = nameish.trim_end_matches(['*', '&']);
11707    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
11708        return false;
11709    }
11710    let type_or_modifier = |tok: &str| {
11711        matches!(
11712            tok,
11713            "static"
11714                | "extern"
11715                | "inline"
11716                | "void"
11717                | "int"
11718                | "char"
11719                | "short"
11720                | "long"
11721                | "float"
11722                | "double"
11723                | "unsigned"
11724                | "signed"
11725                | "struct"
11726                | "enum"
11727                | "union"
11728                | "const"
11729                | "volatile"
11730                | "typedef"
11731        )
11732    };
11733    tokens[..tokens.len() - 1]
11734        .iter()
11735        .any(|tok| type_or_modifier(tok))
11736}
11737
11738fn hunk_old_change_end_exclusive(ops: &[similar::DiffOp]) -> usize {
11739    use similar::DiffOp;
11740    let mut max_o = 0usize;
11741    for op in ops {
11742        match *op {
11743            DiffOp::Delete {
11744                old_index, old_len, ..
11745            } => {
11746                max_o = max_o.max(old_index + old_len);
11747            }
11748            DiffOp::Replace {
11749                old_index, old_len, ..
11750            } => {
11751                max_o = max_o.max(old_index + old_len);
11752            }
11753            DiffOp::Insert { old_index, .. } => {
11754                // Pure insertions do not consume old lines; Git's post-context anchor is the
11755                // insertion point (`old_index`), not 0 (t4051 `extended`).
11756                max_o = max_o.max(old_index);
11757            }
11758            DiffOp::Equal { .. } => {}
11759        }
11760    }
11761    max_o
11762}
11763
11764fn func_context_old_anchor(ops: &[similar::DiffOp], n_old: usize) -> usize {
11765    use similar::DiffOp;
11766    let mut has_delete_or_replace = false;
11767    let mut min_del = usize::MAX;
11768    let mut min_ins_old = usize::MAX;
11769
11770    for op in ops {
11771        match *op {
11772            DiffOp::Delete {
11773                old_index, old_len, ..
11774            } => {
11775                has_delete_or_replace = true;
11776                min_del = min_del.min(old_index);
11777                min_del = min_del.min(old_index + old_len.saturating_sub(1));
11778            }
11779            DiffOp::Replace {
11780                old_index, old_len, ..
11781            } => {
11782                has_delete_or_replace = true;
11783                min_del = min_del.min(old_index);
11784                min_del = min_del.min(old_index + old_len.saturating_sub(1));
11785            }
11786            DiffOp::Insert { old_index, .. } => {
11787                min_ins_old = min_ins_old.min(old_index);
11788            }
11789            DiffOp::Equal { .. } => {}
11790        }
11791    }
11792
11793    let mut i1 = if has_delete_or_replace {
11794        min_del
11795    } else if min_ins_old != usize::MAX {
11796        min_ins_old
11797    } else {
11798        0
11799    };
11800
11801    let pure_insert = ops
11802        .iter()
11803        .all(|op| matches!(op, DiffOp::Insert { .. } | DiffOp::Equal { .. }))
11804        && ops.iter().any(|op| matches!(op, DiffOp::Insert { .. }));
11805
11806    if pure_insert && i1 >= n_old && n_old > 0 {
11807        i1 = n_old - 1;
11808    }
11809
11810    i1.min(n_old.saturating_sub(1))
11811}
11812
11813fn expand_func_pre_start(
11814    s1: usize,
11815    i1: usize,
11816    n_old: usize,
11817    old_lines: &[&str],
11818    matcher: Option<&FuncnameMatcher>,
11819) -> usize {
11820    if n_old == 0 {
11821        return s1;
11822    }
11823    let i1 = i1.min(n_old.saturating_sub(1));
11824    let mut fs1 = get_func_line_backward(old_lines, i1, matcher).unwrap_or(i1);
11825    while fs1 > 0
11826        && !is_line_empty_for_func_context(old_lines[fs1 - 1])
11827        && !is_func_line(old_lines[fs1 - 1], matcher)
11828    {
11829        fs1 -= 1;
11830    }
11831    s1.min(fs1)
11832}
11833
11834fn expand_func_post_end(
11835    e1: usize,
11836    i1_end: usize,
11837    n_old: usize,
11838    old_lines: &[&str],
11839    matcher: Option<&FuncnameMatcher>,
11840) -> usize {
11841    let from = i1_end.min(n_old);
11842    let fe1 = get_func_line_forward(old_lines, from, matcher).unwrap_or(n_old);
11843    let mut fe1_adj = fe1;
11844    while fe1_adj > 0 && is_line_empty_for_func_context(old_lines[fe1_adj - 1]) {
11845        fe1_adj -= 1;
11846    }
11847    e1.max(fe1_adj).min(n_old)
11848}
11849
11850fn is_line_empty_for_func_context(line: &str) -> bool {
11851    line.chars().all(|c| c.is_whitespace())
11852}
11853
11854pub(crate) fn is_func_line(line: &str, matcher: Option<&FuncnameMatcher>) -> bool {
11855    if let Some(m) = matcher {
11856        return m.match_line(line).is_some();
11857    }
11858    let t = line.trim_end_matches(['\n', '\r']);
11859    if t.is_empty() {
11860        return false;
11861    }
11862    let b = t.as_bytes()[0];
11863    if !(b.is_ascii_alphabetic() || b == b'_' || b == b'$') {
11864        return false;
11865    }
11866    true
11867}
11868
11869fn get_func_line_backward(
11870    old_lines: &[&str],
11871    start: usize,
11872    matcher: Option<&FuncnameMatcher>,
11873) -> Option<usize> {
11874    let mut l = start.min(old_lines.len().saturating_sub(1));
11875    if old_lines.is_empty() {
11876        return None;
11877    }
11878    loop {
11879        if is_func_line(old_lines[l], matcher) {
11880            return Some(l);
11881        }
11882        if l == 0 {
11883            break;
11884        }
11885        l -= 1;
11886    }
11887    None
11888}
11889
11890fn get_func_line_forward(
11891    old_lines: &[&str],
11892    start: usize,
11893    matcher: Option<&FuncnameMatcher>,
11894) -> Option<usize> {
11895    let mut l = start;
11896    while l < old_lines.len() {
11897        if is_func_line(old_lines[l], matcher) {
11898            return Some(l);
11899        }
11900        l += 1;
11901    }
11902    None
11903}
11904#[expect(clippy::too_many_arguments)]
11905/// Compute a unified diff with anchored lines.
11906///
11907/// Anchored lines that appear exactly once in both old and new content are
11908/// forced to match, splitting the diff into segments around those anchor points.
11909/// This produces diffs where the anchored text stays as context and surrounding
11910/// lines are shown as additions/removals.
11911///
11912/// Segment diffs use `algorithm`. When `use_git_histogram` is true, histogram uses the
11913/// Git-compatible histogram engine; otherwise `algorithm` is passed to `similar`.
11914pub fn anchored_unified_diff(
11915    old_content: &str,
11916    new_content: &str,
11917    old_path: &str,
11918    new_path: &str,
11919    context_lines: usize,
11920    anchors: &[String],
11921    algorithm: similar::Algorithm,
11922    use_git_histogram: bool,
11923    indent_heuristic: bool,
11924    quote_path_fully: bool,
11925) -> String {
11926    use crate::quote_path::format_diff_path_with_prefix;
11927    use similar::TextDiff;
11928
11929    let old_lines: Vec<&str> = old_content.lines().collect();
11930    let new_lines: Vec<&str> = new_content.lines().collect();
11931
11932    // Find anchored lines that appear exactly once in both old and new
11933    let mut anchor_pairs: Vec<(usize, usize)> = Vec::new(); // (old_idx, new_idx)
11934
11935    for anchor in anchors {
11936        let anchor_str = anchor.as_str();
11937
11938        // Count occurrences in old
11939        let old_positions: Vec<usize> = old_lines
11940            .iter()
11941            .enumerate()
11942            .filter(|(_, l)| l.trim_end() == anchor_str)
11943            .map(|(i, _)| i)
11944            .collect();
11945
11946        // Count occurrences in new
11947        let new_positions: Vec<usize> = new_lines
11948            .iter()
11949            .enumerate()
11950            .filter(|(_, l)| l.trim_end() == anchor_str)
11951            .map(|(i, _)| i)
11952            .collect();
11953
11954        // Only anchor if unique in both
11955        if old_positions.len() == 1 && new_positions.len() == 1 {
11956            anchor_pairs.push((old_positions[0], new_positions[0]));
11957        }
11958    }
11959
11960    // If no valid anchors, fall back to normal diff
11961    if anchor_pairs.is_empty() {
11962        return unified_diff_with_prefix_and_funcname_and_algorithm(
11963            old_content,
11964            new_content,
11965            old_path,
11966            new_path,
11967            context_lines,
11968            0,
11969            "a/",
11970            "b/",
11971            None,
11972            algorithm,
11973            false,
11974            use_git_histogram,
11975            indent_heuristic,
11976            quote_path_fully,
11977        );
11978    }
11979
11980    // Sort anchor pairs by their position in the old file
11981    anchor_pairs.sort_by_key(|&(old_idx, _)| old_idx);
11982
11983    // Filter to only keep pairs where new positions are also increasing
11984    // (longest increasing subsequence of new positions)
11985    let mut filtered: Vec<(usize, usize)> = Vec::new();
11986    for &pair in &anchor_pairs {
11987        if filtered.is_empty() || filtered.last().is_some_and(|last| pair.1 > last.1) {
11988            filtered.push(pair);
11989        }
11990    }
11991    let anchor_pairs = filtered;
11992
11993    // Build a modified version of old/new where we diff segments between anchors.
11994    // We'll construct the diff by processing segments:
11995    // - Before first anchor
11996    // - Between consecutive anchors
11997    // - After last anchor
11998    // Each anchor line itself is a fixed context match.
11999
12000    // Collect all diff operations
12001    struct LineDiffOp {
12002        tag: char, // ' ', '+', '-'
12003        line: String,
12004    }
12005
12006    let append_segment_diff =
12007        |ops: &mut Vec<LineDiffOp>, old_seg_input: &str, new_seg_input: &str| {
12008            use similar::ChangeTag;
12009            let old_ls: Vec<&str> = old_seg_input.lines().collect();
12010            let new_ls: Vec<&str> = new_seg_input.lines().collect();
12011            if old_ls.is_empty() && new_ls.is_empty() {
12012                return;
12013            }
12014            let seg_diff = TextDiff::configure()
12015                .algorithm(algorithm)
12016                .diff_slices(&old_ls, &new_ls);
12017            let raw = seg_diff.ops().to_vec();
12018            let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
12019                &raw,
12020                &old_ls,
12021                &new_ls,
12022                indent_heuristic,
12023            );
12024            for op in &compacted {
12025                for ch in op.iter_changes(&old_ls, &new_ls) {
12026                    let t = match ch.tag() {
12027                        ChangeTag::Equal => ' ',
12028                        ChangeTag::Delete => '-',
12029                        ChangeTag::Insert => '+',
12030                    };
12031                    ops.push(LineDiffOp {
12032                        tag: t,
12033                        line: ch.value().to_string(),
12034                    });
12035                }
12036            }
12037        };
12038
12039    let mut ops: Vec<LineDiffOp> = Vec::new();
12040    let mut old_pos = 0usize;
12041    let mut new_pos = 0usize;
12042
12043    for &(old_anchor, new_anchor) in &anchor_pairs {
12044        // Diff the segment before this anchor
12045        let old_segment: Vec<&str> = old_lines[old_pos..old_anchor].to_vec();
12046        let new_segment: Vec<&str> = new_lines[new_pos..new_anchor].to_vec();
12047
12048        let old_seg_text = old_segment.join("\n");
12049        let new_seg_text = new_segment.join("\n");
12050
12051        if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
12052            let old_seg_input = if old_seg_text.is_empty() {
12053                String::new()
12054            } else {
12055                format!("{}\n", old_seg_text)
12056            };
12057            let new_seg_input = if new_seg_text.is_empty() {
12058                String::new()
12059            } else {
12060                format!("{}\n", new_seg_text)
12061            };
12062            append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
12063        }
12064
12065        // The anchor line itself is always context
12066        ops.push(LineDiffOp {
12067            tag: ' ',
12068            line: old_lines[old_anchor].to_string(),
12069        });
12070
12071        old_pos = old_anchor + 1;
12072        new_pos = new_anchor + 1;
12073    }
12074
12075    // Diff the remaining segment after the last anchor
12076    let old_segment: Vec<&str> = old_lines[old_pos..].to_vec();
12077    let new_segment: Vec<&str> = new_lines[new_pos..].to_vec();
12078    let old_seg_text = old_segment.join("\n");
12079    let new_seg_text = new_segment.join("\n");
12080
12081    if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
12082        let old_seg_input = if old_seg_text.is_empty() {
12083            String::new()
12084        } else {
12085            format!("{}\n", old_seg_text)
12086        };
12087        let new_seg_input = if new_seg_text.is_empty() {
12088            String::new()
12089        } else {
12090            format!("{}\n", new_seg_text)
12091        };
12092        append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
12093    }
12094
12095    // Now format as unified diff with hunks
12096    let mut output = String::new();
12097    if old_path == "/dev/null" {
12098        output.push_str("--- /dev/null\n");
12099    } else {
12100        output.push_str("--- ");
12101        output.push_str(&format_diff_path_with_prefix(
12102            "a/",
12103            old_path,
12104            quote_path_fully,
12105        ));
12106        output.push('\n');
12107    }
12108    if new_path == "/dev/null" {
12109        output.push_str("+++ /dev/null\n");
12110    } else {
12111        output.push_str("+++ ");
12112        output.push_str(&format_diff_path_with_prefix(
12113            "b/",
12114            new_path,
12115            quote_path_fully,
12116        ));
12117        output.push('\n');
12118    }
12119
12120    // Group ops into hunks with context
12121    let total_ops = ops.len();
12122    if total_ops == 0 {
12123        return output;
12124    }
12125
12126    // Find ranges of changes
12127    let mut hunks: Vec<(usize, usize)> = Vec::new(); // (start, end) indices into ops
12128    let mut i = 0;
12129    while i < total_ops {
12130        if ops[i].tag != ' ' {
12131            let start = i.saturating_sub(context_lines);
12132            let mut end = i;
12133            // Extend to include consecutive changes and their context
12134            while end < total_ops {
12135                if ops[end].tag != ' ' {
12136                    end += 1;
12137                    continue;
12138                }
12139                // Check if there's another change within context_lines
12140                let mut next_change = end;
12141                while next_change < total_ops && ops[next_change].tag == ' ' {
12142                    next_change += 1;
12143                }
12144                if next_change < total_ops && next_change - end <= context_lines * 2 {
12145                    end = next_change + 1;
12146                } else {
12147                    end = (end + context_lines).min(total_ops);
12148                    break;
12149                }
12150            }
12151            // Merge with previous hunk if overlapping
12152            if let Some(last) = hunks.last_mut() {
12153                if start <= last.1 {
12154                    last.1 = end;
12155                } else {
12156                    hunks.push((start, end));
12157                }
12158            } else {
12159                hunks.push((start, end));
12160            }
12161            i = end;
12162        } else {
12163            i += 1;
12164        }
12165    }
12166
12167    // Output each hunk
12168    for (start, end) in hunks {
12169        // Count old/new lines in this hunk
12170        let mut old_start = 1usize;
12171        let mut new_start = 1usize;
12172        // Calculate line numbers by counting ops before this hunk
12173        for op in &ops[..start] {
12174            match op.tag {
12175                ' ' => {
12176                    old_start += 1;
12177                    new_start += 1;
12178                }
12179                '-' => {
12180                    old_start += 1;
12181                }
12182                '+' => {
12183                    new_start += 1;
12184                }
12185                _ => {}
12186            }
12187        }
12188        let mut old_count = 0usize;
12189        let mut new_count = 0usize;
12190        for op in &ops[start..end] {
12191            match op.tag {
12192                ' ' => {
12193                    old_count += 1;
12194                    new_count += 1;
12195                }
12196                '-' => {
12197                    old_count += 1;
12198                }
12199                '+' => {
12200                    new_count += 1;
12201                }
12202                _ => {}
12203            }
12204        }
12205
12206        output.push_str(&format!(
12207            "@@ -{},{} +{},{} @@\n",
12208            old_start, old_count, new_start, new_count
12209        ));
12210        for op in &ops[start..end] {
12211            output.push(op.tag);
12212            output.push_str(&op.line);
12213            output.push('\n');
12214        }
12215    }
12216
12217    output
12218}
12219
12220/// Extract function context for a hunk header.
12221///
12222/// Given a hunk header like `@@ -8,7 +8,7 @@`, find the last line
12223/// before line 8 in the old content that looks like a function header
12224/// (starts with a non-whitespace character, like Git's default).
12225fn func_context_line_matches(line: &str, funcname_matcher: Option<&FuncnameMatcher>) -> bool {
12226    if let Some(m) = funcname_matcher {
12227        return m.match_line(line).is_some();
12228    }
12229    is_func_line(line, None)
12230}
12231
12232fn extract_function_context(
12233    header: &str,
12234    old_lines: &[&str],
12235    funcname_matcher: Option<&FuncnameMatcher>,
12236    _source_path: Option<&str>,
12237) -> Option<String> {
12238    // Parse the old start line number from "@@ -<start>,<count> ..."
12239    let at_pos = header.find("-")?;
12240    let rest = &header[at_pos + 1..];
12241    let comma_or_space = rest.find([',', ' '])?;
12242    let start_str = &rest[..comma_or_space];
12243    let start_line: usize = start_str.parse().ok()?;
12244
12245    // Parse the old line count; "@@ -<start>,<count> ..." (no comma means count 1).
12246    // Only look for the comma inside the old-range token itself — searching the
12247    // whole remainder would pick up the comma from the new side (e.g. "+0,0").
12248    let old_token_end = rest.find([' ', '\t']).unwrap_or(rest.len());
12249    let old_token = &rest[..old_token_end];
12250    let old_count: usize = if let Some(comma) = old_token.find(',') {
12251        old_token[comma + 1..].parse().unwrap_or(1)
12252    } else {
12253        1
12254    };
12255
12256    if start_line == 0 {
12257        return None;
12258    }
12259
12260    // Look backwards for a line that matches the funcname pattern. start_line is
12261    // 1-indexed. For a normal hunk the first changed pre-image line is
12262    // old_lines[start_line-1], so we search lines strictly before it
12263    // (old_lines[0..start_line-1]). For a pure insertion (old count 0) the
12264    // content is inserted *after* old line start_line, so Git's function search
12265    // begins at that line itself: search old_lines[0..start_line].
12266    if start_line == 0 {
12267        return None;
12268    }
12269    let search_end = if old_count == 0 {
12270        start_line.min(old_lines.len())
12271    } else {
12272        if start_line <= 1 {
12273            return None;
12274        }
12275        (start_line - 1).min(old_lines.len())
12276    };
12277    let truncate = |line: &str| {
12278        let mut end = line.len().min(80);
12279        while end > 0 && !line.is_char_boundary(end) {
12280            end -= 1;
12281        }
12282        let mut out = line[..end].to_owned();
12283        let tail = line[end..].trim_start();
12284        // Git drops trailing whitespace on truncated funcname lines when the
12285        // remainder of the source line continues with normal text (t2021-style
12286        // shell tests), but keeps it when the next token is a string delimiter
12287        // (gettext `.po` msgstr lines).
12288        if tail.is_empty()
12289            || (!tail.starts_with('"')
12290                && !tail.starts_with('\'')
12291                && !line.starts_with("msgstr")
12292                && !line.starts_with("msgid"))
12293        {
12294            out = out.trim_end().to_owned();
12295        }
12296        out
12297    };
12298
12299    for i in (0..search_end).rev() {
12300        let line = old_lines[i];
12301        if line.is_empty() {
12302            continue;
12303        }
12304        if func_context_line_matches(line, funcname_matcher) {
12305            if let Some(matcher) = funcname_matcher {
12306                if let Some(matched) = matcher.match_line(line) {
12307                    return Some(truncate(&matched));
12308                }
12309                continue;
12310            }
12311            return Some(truncate(line));
12312        }
12313    }
12314    None
12315}
12316
12317fn last_line_index_without_trailing_newline(text: &str) -> Option<usize> {
12318    LineSide::from_text(text).incomplete_last_line_index()
12319}
12320
12321fn parse_unified_hunk_starts(header: &str) -> Option<(usize, usize)> {
12322    let rest = header.strip_prefix("@@ ")?;
12323    let (old_part, new_part) = rest.split_once(" +")?;
12324    let old_start = parse_unified_range_start(old_part.strip_prefix('-')?)?;
12325    let new_start = parse_unified_range_start(new_part.split(" @@").next()?)?;
12326    Some((old_start, new_start))
12327}
12328
12329fn parse_unified_range_start(spec: &str) -> Option<usize> {
12330    let spec = spec.trim();
12331    if let Some((start, _)) = spec.split_once(',') {
12332        start.parse().ok()
12333    } else {
12334        spec.parse().ok()
12335    }
12336}
12337
12338/// Tracks hunk line numbers while streaming `\ No newline at end of file` markers.
12339struct UnifiedDiffNoNewlineAnnotator {
12340    old_last: Option<usize>,
12341    new_last: Option<usize>,
12342    old_line: usize,
12343    new_line: usize,
12344    passthrough: bool,
12345}
12346
12347impl UnifiedDiffNoNewlineAnnotator {
12348    fn new(old: &str, new: &str) -> Self {
12349        let old_last = last_line_index_without_trailing_newline(old);
12350        let new_last = last_line_index_without_trailing_newline(new);
12351        Self {
12352            old_last,
12353            new_last,
12354            old_line: 1,
12355            new_line: 1,
12356            passthrough: old_last.is_none() && new_last.is_none(),
12357        }
12358    }
12359
12360    fn write_hunk<W: std::io::Write>(&mut self, out: &mut W, patch: &str) -> Result<()> {
12361        if self.passthrough {
12362            out.write_all(patch.as_bytes())?;
12363            return Ok(());
12364        }
12365        let mut lines = patch.lines().peekable();
12366        while let Some(line) = lines.next() {
12367            self.write_annotated_line(out, line, &mut lines)?;
12368        }
12369        Ok(())
12370    }
12371
12372    fn write_annotated_line<W: std::io::Write>(
12373        &mut self,
12374        out: &mut W,
12375        line: &str,
12376        lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
12377    ) -> Result<()> {
12378        if let Some((o, n)) = line
12379            .strip_prefix("@@ ")
12380            .and_then(|_| parse_unified_hunk_starts(line))
12381        {
12382            self.old_line = o;
12383            self.new_line = n;
12384            writeln!(out, "{line}")?;
12385            return Ok(());
12386        }
12387        if line.starts_with("--- ")
12388            || line.starts_with("+++ ")
12389            || line.starts_with("diff --git ")
12390            || line.starts_with("diff --cc ")
12391            || line.starts_with("diff --combined ")
12392        {
12393            writeln!(out, "{line}")?;
12394            return Ok(());
12395        }
12396        if line.starts_with('-') {
12397            writeln!(out, "{line}")?;
12398            let already_marked = lines
12399                .peek()
12400                .is_some_and(|next| *next == "\\ No newline at end of file");
12401            if !already_marked && self.old_last == Some(self.old_line.saturating_sub(1)) {
12402                writeln!(out, "\\ No newline at end of file")?;
12403            }
12404            self.old_line += 1;
12405            return Ok(());
12406        }
12407        if line.starts_with('+') {
12408            writeln!(out, "{line}")?;
12409            let already_marked = lines
12410                .peek()
12411                .is_some_and(|next| *next == "\\ No newline at end of file");
12412            if !already_marked && self.new_last == Some(self.new_line.saturating_sub(1)) {
12413                writeln!(out, "\\ No newline at end of file")?;
12414            }
12415            self.new_line += 1;
12416            return Ok(());
12417        }
12418        if line.starts_with(' ') {
12419            writeln!(out, "{line}")?;
12420            let already_marked = lines
12421                .peek()
12422                .is_some_and(|next| *next == "\\ No newline at end of file");
12423            if !already_marked {
12424                let old_idx = self.old_line.saturating_sub(1);
12425                let new_idx = self.new_line.saturating_sub(1);
12426                if self.old_last == Some(old_idx) || self.new_last == Some(new_idx) {
12427                    writeln!(out, "\\ No newline at end of file")?;
12428                }
12429            }
12430            self.old_line += 1;
12431            self.new_line += 1;
12432            return Ok(());
12433        }
12434        writeln!(out, "{line}")?;
12435        Ok(())
12436    }
12437}
12438
12439/// Insert Git `\ No newline at end of file` after `-`/`+` hunk lines when that side of the
12440/// file lacks a trailing newline on the last line.
12441#[must_use]
12442pub fn annotate_unified_diff_no_newline_at_eof(patch: &str, old: &str, new: &str) -> String {
12443    let mut annotator = UnifiedDiffNoNewlineAnnotator::new(old, new);
12444    if annotator.passthrough {
12445        return patch.to_string();
12446    }
12447    let mut out = Vec::with_capacity(patch.len() + 64);
12448    let mut lines = patch.lines().peekable();
12449    while let Some(line) = lines.next() {
12450        if annotator
12451            .write_annotated_line(&mut out, line, &mut lines)
12452            .is_err()
12453        {
12454            return patch.to_string();
12455        }
12456    }
12457    String::from_utf8(out).unwrap_or_else(|_| patch.to_string())
12458}
12459
12460/// Normalise one line like Git's `-b` / `--ignore-space-change`.
12461#[must_use]
12462pub fn normalize_ignore_space_change_line(line: &str) -> String {
12463    let mut result = String::with_capacity(line.len());
12464    let mut in_space = false;
12465    for c in line.chars() {
12466        if c.is_whitespace() {
12467            if !in_space {
12468                result.push(' ');
12469                in_space = true;
12470            }
12471        } else {
12472            result.push(c);
12473            in_space = false;
12474        }
12475    }
12476    while result.ends_with(' ') {
12477        result.pop();
12478    }
12479    result
12480}
12481
12482/// Normalise text like Git's `-b` / `--ignore-space-change`: on each line, collapse runs of
12483/// whitespace to a single ASCII space and trim trailing spaces.
12484///
12485/// Line breaks are preserved by splitting on [`str::lines`] and rejoining with `\n` (same approach
12486/// as the porcelain `diff` whitespace handling in `grit`).
12487#[must_use]
12488pub fn normalize_ignore_space_change(content: &str) -> String {
12489    content
12490        .lines()
12491        .map(normalize_ignore_space_change_line)
12492        .collect::<Vec<_>>()
12493        .join("\n")
12494}
12495
12496/// Count insertions and deletions using the given line-diff algorithm.
12497///
12498/// Uses the same compacted line alignment as unified patches and [`crate::diff::line_change_counts`].
12499#[must_use]
12500pub fn count_changes_with_algorithm(
12501    old_content: &str,
12502    new_content: &str,
12503    algorithm: similar::Algorithm,
12504    use_git_histogram: bool,
12505) -> (usize, usize) {
12506    use crate::diff::options::{DiffAlgorithm, DiffOptions};
12507    let diff_algorithm = if use_git_histogram {
12508        DiffAlgorithm::Histogram
12509    } else {
12510        match algorithm {
12511            similar::Algorithm::Myers => DiffAlgorithm::Myers,
12512            similar::Algorithm::Lcs => DiffAlgorithm::Minimal,
12513            similar::Algorithm::Patience => DiffAlgorithm::Patience,
12514            _ => DiffAlgorithm::Myers,
12515        }
12516    };
12517    count_line_changes_with_options(
12518        old_content,
12519        new_content,
12520        &DiffOptions {
12521            algorithm: diff_algorithm,
12522            ..DiffOptions::default()
12523        },
12524    )
12525}
12526
12527/// Count insertions and deletions using Git's histogram line-diff engine.
12528#[must_use]
12529pub fn count_changes_git_histogram(old_content: &str, new_content: &str) -> (usize, usize) {
12530    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, true)
12531}
12532
12533/// Line count for diffstat/`--numstat`, matching Git's `count_lines()` in `diff.c`.
12534///
12535/// Counts newline-terminated lines; a final line without trailing newline still counts as one line.
12536/// An empty buffer yields `0`.
12537#[must_use]
12538pub fn count_git_lines(data: &[u8]) -> usize {
12539    if data.is_empty() {
12540        return 0;
12541    }
12542    let mut count = 0usize;
12543    let mut nl_just_seen = false;
12544    for &ch in data {
12545        if ch == b'\n' {
12546            count += 1;
12547            nl_just_seen = true;
12548        } else {
12549            nl_just_seen = false;
12550        }
12551    }
12552    if !nl_just_seen {
12553        count += 1;
12554    }
12555    count
12556}
12557
12558/// Internal maximum diff score used by Git rename/break heuristics (`MAX_SCORE` in `diffcore.h`).
12559pub const GIT_DIFF_MAX_SCORE: u64 = 60_000;
12560const DIFF_MAX_SCORE: u64 = GIT_DIFF_MAX_SCORE;
12561const DIFF_MINIMUM_BREAK_SIZE: usize = 400;
12562const DIFF_DEFAULT_BREAK_SCORE: u64 = 30_000;
12563/// Default break threshold (`DEFAULT_BREAK_SCORE` in `diffcore.h`), internal 0–[`GIT_DIFF_MAX_SCORE`] scale.
12564pub const GIT_DIFF_DEFAULT_BREAK_SCORE: u64 = DIFF_DEFAULT_BREAK_SCORE;
12565const DIFF_HASHBASE: u32 = 107_927;
12566
12567#[derive(Clone, Copy, Default)]
12568struct SpanSlot {
12569    hashval: u32,
12570    cnt: u32,
12571}
12572
12573struct SpanHashTop {
12574    alloc_log2: u8,
12575    free_slots: i32,
12576    data: Vec<SpanSlot>,
12577}
12578
12579impl SpanHashTop {
12580    fn new(initial_log2: u8) -> Self {
12581        let cap = 1usize << initial_log2;
12582        Self {
12583            alloc_log2: initial_log2,
12584            free_slots: initial_free(initial_log2),
12585            data: vec![SpanSlot::default(); cap],
12586        }
12587    }
12588
12589    fn len(&self) -> usize {
12590        1usize << self.alloc_log2
12591    }
12592
12593    fn add_span(&mut self, hashval: u32, cnt: u32) {
12594        loop {
12595            let lim = self.len();
12596            let mut bucket = (hashval as usize) & (lim - 1);
12597            loop {
12598                let h = &mut self.data[bucket];
12599                if h.cnt == 0 {
12600                    h.hashval = hashval;
12601                    h.cnt = cnt;
12602                    self.free_slots -= 1;
12603                    if self.free_slots < 0 {
12604                        self.rehash();
12605                        break;
12606                    }
12607                    return;
12608                }
12609                if h.hashval == hashval {
12610                    h.cnt = h.cnt.saturating_add(cnt);
12611                    return;
12612                }
12613                bucket += 1;
12614                if bucket >= lim {
12615                    bucket = 0;
12616                }
12617            }
12618        }
12619    }
12620
12621    fn rehash(&mut self) {
12622        let old = std::mem::take(&mut self.data);
12623        let old_log = self.alloc_log2;
12624        self.alloc_log2 = old_log.saturating_add(1);
12625        let new_len = 1usize << self.alloc_log2;
12626        self.free_slots = initial_free(self.alloc_log2);
12627        self.data = vec![SpanSlot::default(); new_len];
12628        let old_sz = 1usize << old_log;
12629        for o in old.iter().take(old_sz) {
12630            let o = *o;
12631            if o.cnt == 0 {
12632                continue;
12633            }
12634            self.add_span_after_rehash(o.hashval, o.cnt);
12635        }
12636    }
12637
12638    fn add_span_after_rehash(&mut self, hashval: u32, cnt: u32) {
12639        loop {
12640            let lim = self.len();
12641            let mut bucket = (hashval as usize) & (lim - 1);
12642            loop {
12643                let h = &mut self.data[bucket];
12644                if h.cnt == 0 {
12645                    h.hashval = hashval;
12646                    h.cnt = cnt;
12647                    self.free_slots -= 1;
12648                    if self.free_slots < 0 {
12649                        self.rehash();
12650                        break;
12651                    }
12652                    return;
12653                }
12654                if h.hashval == hashval {
12655                    h.cnt = h.cnt.saturating_add(cnt);
12656                    return;
12657                }
12658                bucket += 1;
12659                if bucket >= lim {
12660                    bucket = 0;
12661                }
12662            }
12663        }
12664    }
12665
12666    fn sort_by_hashval(&mut self) {
12667        let sz = self.len();
12668        self.data[..sz].sort_by(|a, b| {
12669            if a.cnt == 0 {
12670                return std::cmp::Ordering::Greater;
12671            }
12672            if b.cnt == 0 {
12673                return std::cmp::Ordering::Less;
12674            }
12675            a.hashval.cmp(&b.hashval)
12676        });
12677    }
12678}
12679
12680fn initial_free(sz_log2: u8) -> i32 {
12681    let sz = sz_log2 as i32;
12682    ((1i32 << sz_log2) * (sz - 3) / sz).max(0)
12683}
12684
12685fn hash_blob_spans(buf: &[u8], is_text: bool) -> SpanHashTop {
12686    let mut hash = SpanHashTop::new(9);
12687    let mut n = 0u32;
12688    let mut accum1: u32 = 0;
12689    let mut accum2: u32 = 0;
12690    let mut i = 0usize;
12691    while i < buf.len() {
12692        let c = buf[i] as u32;
12693        let old_1 = accum1;
12694        i += 1;
12695
12696        if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
12697            continue;
12698        }
12699
12700        accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
12701        accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
12702        accum1 = accum1.wrapping_add(c);
12703        n += 1;
12704        if n < 64 && c != b'\n' as u32 {
12705            continue;
12706        }
12707        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
12708        hash.add_span(hashval, n);
12709        n = 0;
12710        accum1 = 0;
12711        accum2 = 0;
12712    }
12713    if n > 0 {
12714        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
12715        hash.add_span(hashval, n);
12716    }
12717    hash.sort_by_hashval();
12718    hash
12719}
12720
12721/// Approximate copied vs added material between two blobs (Git `diffcore_count_changes`).
12722///
12723/// Returns `(copied_bytes_from_src, literal_added_bytes_in_dst)` matching Git's
12724/// `diffcore_count_changes` semantics (used for `--dirstat=changes` damage).
12725#[must_use]
12726pub fn diffcore_count_changes(old: &[u8], new: &[u8]) -> (u64, u64) {
12727    crate::trace::trace_span!("grit.diff.blob");
12728    let src_is_text = !crate::merge_file::is_binary(old);
12729    let dst_is_text = !crate::merge_file::is_binary(new);
12730    let src_count = hash_blob_spans(old, src_is_text);
12731    let dst_count = hash_blob_spans(new, dst_is_text);
12732    let mut sc: u64 = 0;
12733    let mut la: u64 = 0;
12734    let mut si = 0usize;
12735    let mut di = 0usize;
12736    let src_len = src_count.len();
12737    let dst_len = dst_count.len();
12738    loop {
12739        if si >= src_len || src_count.data[si].cnt == 0 {
12740            break;
12741        }
12742        let s_hash = src_count.data[si].hashval;
12743        let s_cnt = u64::from(src_count.data[si].cnt);
12744        while di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval < s_hash {
12745            la += u64::from(dst_count.data[di].cnt);
12746            di += 1;
12747        }
12748        let mut dst_cnt = 0u64;
12749        if di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval == s_hash {
12750            dst_cnt = u64::from(dst_count.data[di].cnt);
12751            di += 1;
12752        }
12753        if s_cnt < dst_cnt {
12754            la += dst_cnt - s_cnt;
12755            sc += s_cnt;
12756        } else {
12757            sc += dst_cnt;
12758        }
12759        si += 1;
12760    }
12761    while di < dst_len && dst_count.data[di].cnt != 0 {
12762        la += u64::from(dst_count.data[di].cnt);
12763        di += 1;
12764    }
12765    (sc, la)
12766}
12767
12768/// Whether an in-place blob edit should be split into delete+create for rename/copy (`should_break`
12769/// in `diffcore-break.c`). `break_score` is on the internal 0–[`GIT_DIFF_MAX_SCORE`] scale (default
12770/// `DIFF_DEFAULT_BREAK_SCORE`).
12771#[must_use]
12772pub fn should_break_rewrite_pair(old: &[u8], new: &[u8], break_score: u64) -> bool {
12773    should_break_rewrite_inner(old, new, break_score)
12774}
12775
12776/// Drop entries that do not match `filter` (Git `--diff-filter`, after rename detection).
12777#[must_use]
12778pub fn apply_diff_filter(
12779    entries: Vec<DiffEntry>,
12780    filter: Option<super::filter::DiffFilter>,
12781) -> Vec<DiffEntry> {
12782    match filter {
12783        Some(f) => f.filter_entries(entries),
12784        None => entries,
12785    }
12786}
12787
12788/// Parse `-M` / `--find-renames` threshold into Git's 0–100 similarity percent.
12789#[must_use]
12790pub fn parse_diff_rename_threshold_percent(arg: &str) -> Option<u8> {
12791    let token = if arg.is_empty() { "50" } else { arg };
12792    let internal = parse_diff_rename_score_token(token)?;
12793    Some((internal.saturating_mul(100) / GIT_DIFF_MAX_SCORE).min(100) as u8)
12794}
12795
12796/// Parse a single Git `parse_rename_score` token (`50`, `50%`, decimal forms) into internal
12797/// 0–[`GIT_DIFF_MAX_SCORE`] units.
12798pub fn parse_diff_rename_score_token(arg: &str) -> Option<u64> {
12799    let mut num: u64 = 0;
12800    let mut scale: u64 = 1;
12801    let mut dot = false;
12802    let mut saw_digit = false;
12803    for ch in arg.chars() {
12804        if !dot && ch == '.' {
12805            scale = 1;
12806            dot = true;
12807            continue;
12808        }
12809        if ch == '%' {
12810            scale = if dot { scale.saturating_mul(100) } else { 100 };
12811            break;
12812        }
12813        if ch.is_ascii_digit() {
12814            saw_digit = true;
12815            if scale < 100_000 {
12816                scale = scale.saturating_mul(10);
12817                num = num.saturating_mul(10) + u64::from(ch as u8 - b'0');
12818            }
12819        } else {
12820            break;
12821        }
12822    }
12823    if !saw_digit {
12824        return None;
12825    }
12826    Some(if num >= scale {
12827        GIT_DIFF_MAX_SCORE
12828    } else {
12829        GIT_DIFF_MAX_SCORE * num / scale
12830    })
12831}
12832
12833/// Git `merge_score` from `diffcore-break.c` when a pair is considered broken: how much of the
12834/// source blob was removed (0–`DIFF_MAX_SCORE` scale). Used for `dissimilarity index` metadata.
12835#[must_use]
12836pub fn rewrite_merge_score(old: &[u8], new: &[u8]) -> Option<u64> {
12837    if old.is_empty() {
12838        return None;
12839    }
12840    let max_size = old.len().max(new.len());
12841    if max_size < DIFF_MINIMUM_BREAK_SIZE {
12842        return None;
12843    }
12844    let (src_copied, _) = diffcore_count_changes(old, new);
12845    let src_copied = src_copied.min(old.len() as u64);
12846    let src_removed = (old.len() as u64).saturating_sub(src_copied);
12847    Some(src_removed * DIFF_MAX_SCORE / old.len() as u64)
12848}
12849
12850/// Percentage shown in `dissimilarity index N%` for a rewrite (`similarity_index` in Git's diff.c).
12851#[must_use]
12852pub fn rewrite_dissimilarity_index_percent(old: &[u8], new: &[u8]) -> Option<u32> {
12853    let score = rewrite_merge_score(old, new)?;
12854    Some((score * 100 / DIFF_MAX_SCORE).min(100) as u32)
12855}
12856
12857fn should_break_rewrite_inner(src: &[u8], dst: &[u8], break_score: u64) -> bool {
12858    if src.is_empty() {
12859        return false;
12860    }
12861    let max_size = src.len().max(dst.len());
12862    if max_size < DIFF_MINIMUM_BREAK_SIZE {
12863        return false;
12864    }
12865    let (src_copied, literal_added) = diffcore_count_changes(src, dst);
12866    let src_copied = src_copied.min(src.len() as u64);
12867    let mut literal_added = literal_added;
12868    let dst_len = dst.len() as u64;
12869    if src_copied < dst_len && literal_added + src_copied > dst_len {
12870        literal_added = dst_len.saturating_sub(src_copied);
12871    }
12872    let src_removed = (src.len() as u64).saturating_sub(src_copied);
12873    let merge_score = src_removed * DIFF_MAX_SCORE / src.len() as u64;
12874    if merge_score > break_score {
12875        return true;
12876    }
12877    let delta_size = src_removed.saturating_add(literal_added);
12878    if delta_size * DIFF_MAX_SCORE / (max_size as u64) < break_score {
12879        return false;
12880    }
12881    let s = src.len() as u64;
12882    if (s * break_score < src_removed * DIFF_MAX_SCORE)
12883        && (literal_added * 20 < src_removed)
12884        && (literal_added * 20 < src_copied)
12885    {
12886        return false;
12887    }
12888    true
12889}
12890
12891// ── Helpers ─────────────────────────────────────────────────────────
12892
12893/// Flatten a tree object recursively into a sorted list of (path, mode, oid).
12894struct FlatEntry {
12895    path: String,
12896    mode: u32,
12897    oid: ObjectId,
12898}
12899
12900fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
12901    flatten_tree_with_index(odb, tree_oid, prefix, None)
12902}
12903
12904fn sparse_index_skips_tree_descent(index: &Index, dir_path: &str, tree_oid: &ObjectId) -> bool {
12905    let mut storage = crate::repo_relpath::storage_bytes(dir_path).into_owned();
12906    if !storage.ends_with(b"/") {
12907        storage.push(b'/');
12908    }
12909    index.find_entry_pos(&storage, 0).is_some_and(|pos| {
12910        let entry = &index.entries[pos];
12911        entry.is_sparse_directory_placeholder() && entry.oid == *tree_oid
12912    })
12913}
12914
12915/// Tree OID at `path` (slash-separated, no trailing slash) under `root`, if every component is a tree.
12916fn subtree_oid_at_path(odb: &Odb, root: &ObjectId, path: &str) -> Result<Option<ObjectId>> {
12917    let mut oid = *root;
12918    for component in path.split('/').filter(|c| !c.is_empty()) {
12919        let entries = read_tree(odb, &oid)?;
12920        let Some(entry) = entries
12921            .iter()
12922            .find(|e| e.name.as_slice() == component.as_bytes())
12923        else {
12924            return Ok(None);
12925        };
12926        if !is_tree_mode(entry.mode) {
12927            return Ok(None);
12928        }
12929        oid = entry.oid;
12930    }
12931    Ok(Some(oid))
12932}
12933
12934/// Report every blob in an index subtree as staged additions (HEAD has no tree at this path).
12935fn emit_flattened_subtree_as_added(
12936    odb: &Odb,
12937    subtree: &ObjectId,
12938    prefix: &str,
12939    ignore_submodules: bool,
12940    result: &mut Vec<DiffEntry>,
12941) -> Result<()> {
12942    for entry in flatten_tree(odb, subtree, prefix)? {
12943        if ignore_submodules && entry.mode == 0o160000 {
12944            continue;
12945        }
12946        result.push(DiffEntry {
12947            status: DiffStatus::Added,
12948            old_path: None,
12949            new_path: Some(entry.path),
12950            old_mode: FileMode::ABSENT,
12951            new_mode: FileMode::new(entry.mode),
12952            old_oid: zero_oid(),
12953            new_oid: entry.oid,
12954            score: None,
12955        });
12956    }
12957    Ok(())
12958}
12959
12960/// Compare two flattened subtrees and append staged diff entries.
12961fn diff_flattened_subtrees(
12962    head: &[FlatEntry],
12963    index: &[FlatEntry],
12964    ignore_submodules: bool,
12965    result: &mut Vec<DiffEntry>,
12966    stage0_paths: &mut std::collections::BTreeSet<String>,
12967    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
12968) {
12969    let mut head_map: std::collections::BTreeMap<&str, &FlatEntry> = head
12970        .iter()
12971        .map(|entry| (entry.path.as_str(), entry))
12972        .collect();
12973    for entry in index {
12974        stage0_paths.insert(entry.path.clone());
12975        tree_map.remove(entry.path.as_str());
12976        match head_map.remove(entry.path.as_str()) {
12977            Some(te) => {
12978                if te.oid != entry.oid || te.mode != entry.mode {
12979                    result.push(DiffEntry {
12980                        status: DiffStatus::for_content_change(te.mode, entry.mode),
12981                        old_path: Some(entry.path.clone()),
12982                        new_path: Some(entry.path.clone()),
12983                        old_mode: FileMode::new(te.mode),
12984                        new_mode: FileMode::new(entry.mode),
12985                        old_oid: te.oid,
12986                        new_oid: entry.oid,
12987                        score: None,
12988                    });
12989                }
12990            }
12991            None => {
12992                result.push(DiffEntry {
12993                    status: DiffStatus::Added,
12994                    old_path: None,
12995                    new_path: Some(entry.path.clone()),
12996                    old_mode: FileMode::ABSENT,
12997                    new_mode: FileMode::new(entry.mode),
12998                    old_oid: zero_oid(),
12999                    new_oid: entry.oid,
13000                    score: None,
13001                });
13002            }
13003        }
13004    }
13005    for (path, te) in head_map {
13006        if ignore_submodules && te.mode == 0o160000 {
13007            continue;
13008        }
13009        tree_map.remove(path);
13010        result.push(DiffEntry {
13011            status: DiffStatus::Deleted,
13012            old_path: Some(path.to_owned()),
13013            new_path: None,
13014            old_mode: FileMode::new(te.mode),
13015            new_mode: FileMode::ABSENT,
13016            old_oid: te.oid,
13017            new_oid: zero_oid(),
13018            score: None,
13019        });
13020    }
13021}
13022
13023fn flatten_tree_with_index(
13024    odb: &Odb,
13025    tree_oid: &ObjectId,
13026    prefix: &str,
13027    index: Option<&Index>,
13028) -> Result<Vec<FlatEntry>> {
13029    let entries = read_tree(odb, tree_oid)?;
13030    let mut result = Vec::new();
13031
13032    for entry in entries {
13033        let name_str = String::from_utf8_lossy(&entry.name);
13034        let path = format_path(prefix, &name_str);
13035        if is_tree_mode(entry.mode) {
13036            if index.is_some_and(|idx| sparse_index_skips_tree_descent(idx, &path, &entry.oid)) {
13037                continue;
13038            }
13039            let nested = flatten_tree_with_index(odb, &entry.oid, &path, index)?;
13040            result.extend(nested);
13041        } else {
13042            result.push(FlatEntry {
13043                path,
13044                mode: entry.mode,
13045                oid: entry.oid,
13046            });
13047        }
13048    }
13049
13050    Ok(result)
13051}
13052
13053/// Paths present in `HEAD`'s tree with mode and blob/commit OID (for status porcelain v2).
13054pub fn head_path_states(
13055    odb: &Odb,
13056    head_tree: Option<&ObjectId>,
13057) -> Result<std::collections::BTreeMap<String, (u32, ObjectId)>> {
13058    let mut m = std::collections::BTreeMap::new();
13059    let Some(t) = head_tree else {
13060        return Ok(m);
13061    };
13062    for fe in flatten_tree(odb, t, "")? {
13063        m.insert(fe.path, (fe.mode, fe.oid));
13064    }
13065    Ok(m)
13066}
13067
13068/// Whether a mode represents a tree (directory).
13069fn is_tree_mode(mode: u32) -> bool {
13070    crate::index::is_tree_mode(mode)
13071}
13072
13073/// Git orders diff lists by raw path bytes, which differs from the stored form of
13074/// non-UTF-8 paths (see [`crate::repo_relpath::storage_bytes`]).
13075fn path_order(a: &DiffEntry, b: &DiffEntry) -> std::cmp::Ordering {
13076    use crate::repo_relpath::storage_bytes;
13077    storage_bytes(a.path()).cmp(&storage_bytes(b.path()))
13078}
13079
13080fn entries_sorted_by_path(entries: &[DiffEntry]) -> bool {
13081    entries.is_sorted_by(|a, b| path_order(a, b).is_le())
13082}
13083
13084fn entries_strictly_sorted_by_path(entries: &[DiffEntry]) -> bool {
13085    entries.is_sorted_by(|a, b| path_order(a, b).is_lt())
13086}
13087
13088/// Stable-sort diff entries by [`DiffEntry::path`] in raw byte order, Git's output order
13089/// for diff lists (non-UTF-8 paths compare by their decoded bytes).
13090///
13091/// Tree diffs and rename detection already return entries in this order, so the common
13092/// case is a single comparison pass with no allocation (a stable sort would otherwise
13093/// allocate scratch space proportional to the list).
13094///
13095/// # Parameters
13096///
13097/// - `entries`: the list to order in place; entries with equal paths keep their order.
13098pub fn sort_entries_by_path(entries: &mut [DiffEntry]) {
13099    if entries_sorted_by_path(entries) {
13100        return;
13101    }
13102    entries.sort_by(path_order);
13103}
13104
13105/// Build a path with an optional prefix.
13106fn format_path(prefix: &str, name: &str) -> String {
13107    if prefix.is_empty() {
13108        name.to_owned()
13109    } else {
13110        let mut path = String::with_capacity(prefix.len() + 1 + name.len());
13111        path.push_str(prefix);
13112        path.push('/');
13113        path.push_str(name);
13114        path
13115    }
13116}
13117
13118/// Format a numeric mode as a zero-padded octal string.
13119pub fn format_mode(mode: u32) -> String {
13120    format!("{mode:06o}")
13121}
13122
13123/// Read the HEAD commit OID from a submodule checkout directory.
13124///
13125/// Returns `None` if the path is missing, not a submodule checkout, or has no resolvable HEAD.
13126#[must_use]
13127pub fn read_submodule_head_for_checkout(sub_dir: &Path) -> Option<ObjectId> {
13128    read_submodule_head(sub_dir)
13129}
13130
13131/// First line of a commit's message for `git diff --submodule=log` output.
13132///
13133/// Honors `encoding` in the commit object (Latin-1 vs UTF-8) using the same
13134/// rules as Git's submodule summary.
13135#[must_use]
13136pub fn submodule_commit_subject_line(c: &CommitData) -> String {
13137    let enc = c.encoding.as_deref().unwrap_or("UTF-8");
13138    let is_latin1 = enc.eq_ignore_ascii_case("ISO8859-1")
13139        || enc.eq_ignore_ascii_case("ISO-8859-1")
13140        || enc.eq_ignore_ascii_case("LATIN1")
13141        || enc.eq_ignore_ascii_case("ISO-8859-15");
13142    if let Some(raw) = c.raw_message.as_deref() {
13143        let line = raw.split(|b| *b == b'\n').next().unwrap_or(raw);
13144        if is_latin1 {
13145            return line
13146                .iter()
13147                .map(|&b| b as char)
13148                .collect::<String>()
13149                .trim()
13150                .to_owned();
13151        }
13152        return String::from_utf8_lossy(line).trim().to_string();
13153    }
13154    c.message.lines().next().unwrap_or("").trim().to_owned()
13155}
13156
13157/// True when a gitlink directory has no initialized submodule (no embedded `.git`), so the
13158/// recorded index OID is treated as the worktree reference (Git `ce_compare_gitlink` when
13159/// `resolve_gitlink_ref` fails).
13160fn uninitialized_gitlink_worktree_matches_index(sub_dir: &Path) -> bool {
13161    submodule_embedded_git_dir(sub_dir).is_none()
13162}
13163
13164fn read_submodule_head(sub_dir: &Path) -> Option<ObjectId> {
13165    read_submodule_head_oid(sub_dir)
13166}
13167
13168/// Resolve the embedded git directory for a submodule work tree (`sub_dir/.git`).
13169#[must_use]
13170pub fn submodule_embedded_git_dir(sub_dir: &Path) -> Option<PathBuf> {
13171    let gitfile = sub_dir.join(".git");
13172    let meta = fs::metadata(&gitfile).ok()?;
13173    if meta.is_file() {
13174        let content = fs::read_to_string(&gitfile).ok()?;
13175        let gitdir = content
13176            .lines()
13177            .find_map(|l| l.strip_prefix("gitdir: "))?
13178            .trim();
13179        Some(if Path::new(gitdir).is_absolute() {
13180            PathBuf::from(gitdir)
13181        } else {
13182            sub_dir.join(gitdir)
13183        })
13184    } else if meta.is_dir() {
13185        Some(gitfile)
13186    } else {
13187        None
13188    }
13189}
13190
13191/// Whether `abs` is a nested Git checkout (embedded `.git`) not already indexed as a gitlink.
13192///
13193/// Matches Git's `is_nonbare_repository_dir` / `is_git_directory`: the embedded git dir must look
13194/// like a real repository (valid `HEAD` plus `objects/`, or `objects/` and `refs/`), not a bare
13195/// `.git` directory placeholder.
13196#[must_use]
13197pub(crate) fn is_unregistered_embedded_git_repo(index: &Index, rel: &str, abs: &Path) -> bool {
13198    if rel.is_empty() || index.has_gitlink_at(rel.as_bytes()) {
13199        return false;
13200    }
13201    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
13202        return false;
13203    };
13204    crate::submodule_gitdir::is_valid_git_dir(&git_dir)
13205}
13206
13207/// Whether staging should record a gitlink, refuse, or treat the path as a normal directory.
13208pub(crate) enum EmbeddedGitRepoStage {
13209    /// Path is not an unregistered embedded repository.
13210    NotEmbedded,
13211    /// HEAD resolves to a commit that can be stored as mode `160000`.
13212    Gitlink(crate::objects::ObjectId),
13213}
13214
13215/// Classify a worktree path for embedded-repository staging.
13216///
13217/// Unlike [`is_unregistered_embedded_git_repo`], registered gitlinks (including unmerged
13218/// conflict stages) are still checked so an unborn nested HEAD cannot collapse conflicts.
13219///
13220/// Returns [`EmbeddedGitRepoStage::NotEmbedded`] when the path is not a nested checkout.
13221/// Returns [`EmbeddedGitRepoStage::Gitlink`] when HEAD resolves. Otherwise returns
13222/// [`crate::error::Error::EmbeddedRepoWithoutCommit`].
13223pub(crate) fn classify_embedded_git_repo_for_stage(
13224    _index: &Index,
13225    rel: &str,
13226    abs: &Path,
13227) -> crate::error::Result<EmbeddedGitRepoStage> {
13228    if rel.is_empty() {
13229        return Ok(EmbeddedGitRepoStage::NotEmbedded);
13230    }
13231    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
13232        return Ok(EmbeddedGitRepoStage::NotEmbedded);
13233    };
13234    if !crate::submodule_gitdir::is_valid_git_dir(&git_dir) {
13235        return Ok(EmbeddedGitRepoStage::NotEmbedded);
13236    }
13237    let path = rel.trim_end_matches('/').to_owned();
13238    match read_submodule_head_oid_for_stage(abs) {
13239        Some(oid) => Ok(EmbeddedGitRepoStage::Gitlink(oid)),
13240        None => Err(crate::error::Error::EmbeddedRepoWithoutCommit { path }),
13241    }
13242}
13243
13244/// Walk upward from `sub_dir` to find the nearest containing Git work tree.
13245fn find_superproject_git(sub_dir: &Path) -> Option<(PathBuf, PathBuf)> {
13246    let mut cur = sub_dir.parent()?;
13247    loop {
13248        let git_path = cur.join(".git");
13249        if git_path.exists() {
13250            let gd = if git_path.is_file() {
13251                let content = fs::read_to_string(&git_path).ok()?;
13252                let line = content
13253                    .lines()
13254                    .find_map(|l| l.strip_prefix("gitdir: "))?
13255                    .trim();
13256                if Path::new(line).is_absolute() {
13257                    PathBuf::from(line)
13258                } else {
13259                    cur.join(line)
13260                }
13261            } else {
13262                git_path
13263            };
13264            return Some((cur.to_path_buf(), gd));
13265        }
13266        cur = cur.parent()?;
13267    }
13268}
13269
13270fn submodule_git_dir_for_head_read(sub_dir: &Path) -> Option<PathBuf> {
13271    let mut git_dir = submodule_embedded_git_dir(sub_dir)?;
13272    if let Some((super_wt, super_git_dir)) = find_superproject_git(sub_dir) {
13273        let rel = sub_dir.strip_prefix(&super_wt).ok()?;
13274        let rel_str = rel.to_string_lossy().replace('\\', "/");
13275        let local_mod = super_git_dir
13276            .join("modules")
13277            .join(rel_str.trim_start_matches('/'));
13278        if local_mod.join("HEAD").exists() {
13279            let sg = super_git_dir
13280                .canonicalize_simplified()
13281                .unwrap_or(super_git_dir);
13282            let cur = git_dir
13283                .canonicalize_simplified()
13284                .unwrap_or_else(|_| git_dir.clone());
13285            if !cur.starts_with(&sg) {
13286                git_dir = local_mod;
13287            }
13288        }
13289    }
13290    Some(git_dir)
13291}
13292
13293fn read_submodule_head_oid_from_git_dir(
13294    git_dir: &Path,
13295    allow_branch_fallback: bool,
13296) -> Option<ObjectId> {
13297    let head_content = fs::read_to_string(git_dir.join("HEAD")).ok()?;
13298    let head_trimmed = head_content.trim();
13299    if head_trimmed.starts_with("ref: ") {
13300        match crate::refs::resolve_ref(git_dir, "HEAD") {
13301            Ok(oid) => Some(oid),
13302            Err(_) if allow_branch_fallback => {
13303                for branch in ["main", "master"] {
13304                    let p = git_dir.join("refs/heads").join(branch);
13305                    if let Ok(s) = fs::read_to_string(&p) {
13306                        if let Ok(o) = ObjectId::from_hex(s.trim()) {
13307                            return Some(o);
13308                        }
13309                    }
13310                }
13311                None
13312            }
13313            Err(_) => None,
13314        }
13315    } else {
13316        ObjectId::from_hex(head_trimmed).ok()
13317    }
13318}
13319
13320/// Read the HEAD commit OID from a submodule working tree directory.
13321///
13322/// Handles both embedded `.git` directories and `gitdir:` gitfiles pointing at
13323/// `.git/modules/...` (or other locations). Returns `None` if the path is not
13324/// a checkout or has no resolvable HEAD.
13325pub fn read_submodule_head_oid(sub_dir: &Path) -> Option<ObjectId> {
13326    let git_dir = submodule_git_dir_for_head_read(sub_dir)?;
13327    read_submodule_head_oid_from_git_dir(&git_dir, true)
13328}
13329
13330/// Like [`read_submodule_head_oid`], but requires `HEAD` itself to resolve (no `main`/`master`
13331/// fallback). Used when staging embedded repositories so a broken symref matches Git's refusal.
13332pub(crate) fn read_submodule_head_oid_for_stage(sub_dir: &Path) -> Option<ObjectId> {
13333    let git_dir = submodule_git_dir_for_head_read(sub_dir)?;
13334    read_submodule_head_oid_from_git_dir(&git_dir, false)
13335}
13336
13337/// True when a populated submodule checkout is *broken*: its `HEAD` resolves to a commit OID, but
13338/// that commit object cannot be read from the submodule's own object database.
13339///
13340/// This mirrors Git's `is_submodule_modified`, which shells out to `git status --porcelain=2`
13341/// inside the submodule; when the submodule's object store is corrupt (e.g. `rm -r .git/objects`),
13342/// that inner status fails and Git aborts the surrounding `status`/`diff`/`fetch`. We detect the
13343/// same condition in-process so the superproject operation can return a fatal error rather than
13344/// silently treating the submodule as clean (t5526 "fetching submodule into a broken repository").
13345///
13346/// Returns `false` when the submodule is not checked out, has no embedded git dir, or has an
13347/// unresolvable HEAD (those are handled separately by the unpopulated/placeholder logic).
13348#[must_use]
13349pub fn submodule_head_object_broken(sub_dir: &Path) -> bool {
13350    let Some(sub_git_dir) = submodule_embedded_git_dir(sub_dir) else {
13351        return false;
13352    };
13353    let Some(head_oid) = read_submodule_head_oid(sub_dir) else {
13354        return false;
13355    };
13356    let odb = Odb::new(&sub_git_dir.join("objects"));
13357    match odb.read(&head_oid) {
13358        // HEAD object present but not a commit would be a different kind of corruption; only the
13359        // missing-object case is the broken-repo scenario Git guards against here.
13360        Ok(obj) => parse_commit(&obj.data).is_err(),
13361        Err(_) => true,
13362    }
13363}
13364
13365/// Submodule dirty bits aligned with Git's `DIRTY_SUBMODULE_*` / porcelain v2 `S???` token.
13366#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13367pub struct SubmodulePorcelainFlags {
13368    /// Submodule checkout HEAD differs from the gitlink OID recorded in the parent index.
13369    pub new_commits: bool,
13370    /// The submodule has its own staged or unstaged changes (`DIRTY_SUBMODULE_MODIFIED`).
13371    pub modified: bool,
13372    /// The submodule work tree contains paths not in its index (`DIRTY_SUBMODULE_UNTRACKED`).
13373    pub untracked: bool,
13374}
13375
13376/// Inspect a checked-out submodule at `rel_path` (relative to `super_worktree`) and return
13377/// flags used for `git status --porcelain=v2` submodule tokens.
13378///
13379/// `recorded_oid` is the gitlink OID stored in the **parent** index (stage 0). When the
13380/// submodule is not checked out or cannot be opened, returns [`Default::default()`].
13381pub fn submodule_porcelain_flags(
13382    super_worktree: &Path,
13383    rel_path: &str,
13384    recorded_oid: ObjectId,
13385) -> SubmodulePorcelainFlags {
13386    let sub_dir = super_worktree.join(rel_path);
13387    let Some(sub_git_dir) = submodule_embedded_git_dir(&sub_dir) else {
13388        return SubmodulePorcelainFlags::default();
13389    };
13390    let Some(sub_head) = read_submodule_head_oid(&sub_dir) else {
13391        return SubmodulePorcelainFlags::default();
13392    };
13393
13394    let new_commits = sub_head != recorded_oid;
13395
13396    let index_path = sub_git_dir.join("index");
13397    let mut sub_index = match crate::index::Index::load(&index_path) {
13398        Ok(ix) => ix,
13399        Err(_) => {
13400            return SubmodulePorcelainFlags {
13401                new_commits,
13402                ..Default::default()
13403            }
13404        }
13405    };
13406
13407    let tracked: std::collections::BTreeSet<String> = sub_index
13408        .entries
13409        .iter()
13410        .filter(|e| e.stage() == 0)
13411        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
13412        .collect();
13413    let untracked = submodule_dir_has_untracked_inner(&sub_dir, &sub_dir, &tracked, &sub_index);
13414
13415    let objects_dir = sub_git_dir.join("objects");
13416    let odb = Odb::new(&objects_dir);
13417
13418    let sub_head_tree = (|| -> Option<ObjectId> {
13419        let h = fs::read_to_string(sub_git_dir.join("HEAD")).ok()?;
13420        let h_str = h.trim();
13421        let commit_oid = if let Some(r) = h_str.strip_prefix("ref: ") {
13422            let oid_hex = fs::read_to_string(sub_git_dir.join(r)).ok()?;
13423            ObjectId::from_hex(oid_hex.trim()).ok()?
13424        } else {
13425            ObjectId::from_hex(h_str).ok()?
13426        };
13427        let obj = odb.read(&commit_oid).ok()?;
13428        let commit = parse_commit(&obj.data).ok()?;
13429        Some(commit.tree)
13430    })();
13431
13432    let staged_dirty = sub_head_tree
13433        .as_ref()
13434        .map(|t| diff_index_to_tree(&odb, &sub_index, Some(t), false).map(|v| !v.is_empty()))
13435        .unwrap_or(Ok(false));
13436    let staged_dirty = staged_dirty.unwrap_or(false);
13437
13438    let unstaged_dirty = diff_index_to_worktree(&odb, &mut sub_index, &sub_dir, false, true)
13439        .map(|v| !v.is_empty())
13440        .unwrap_or(false);
13441
13442    let mut modified = staged_dirty || unstaged_dirty;
13443
13444    // Nested submodule has its own index: OR `modified` from immediate gitlink children so a
13445    // dirty nested checkout (e.g. staged `file` under `sub1/sub2`) marks the parent gitlink as
13446    // modified in the superproject (t7506). Do **not** OR `untracked` — untracked-only inside a
13447    // nested submodule must stay `S..U` on the parent, not `S.U` / `S.M.`.
13448    for e in &sub_index.entries {
13449        if e.stage() != 0 || e.mode != 0o160000 {
13450            continue;
13451        }
13452        let child = String::from_utf8_lossy(&e.path).into_owned();
13453        let full_rel = if rel_path.is_empty() {
13454            child
13455        } else {
13456            format!("{rel_path}/{child}")
13457        };
13458        let nested = submodule_porcelain_flags(super_worktree, &full_rel, e.oid);
13459        modified |= nested.modified;
13460    }
13461
13462    SubmodulePorcelainFlags {
13463        new_commits,
13464        modified,
13465        untracked,
13466    }
13467}
13468
13469fn submodule_dir_has_untracked_inner(
13470    dir: &Path,
13471    root: &Path,
13472    tracked: &std::collections::BTreeSet<String>,
13473    owning_index: &Index,
13474) -> bool {
13475    let entries = match fs::read_dir(dir) {
13476        Ok(e) => e,
13477        Err(_) => return false,
13478    };
13479    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
13480    sorted.sort_by_key(|e| e.file_name());
13481
13482    for entry in sorted {
13483        let name = entry.file_name().to_string_lossy().to_string();
13484        if name == ".git" {
13485            continue;
13486        }
13487        let path = entry.path();
13488        let rel = path
13489            .strip_prefix(root)
13490            .map(|p| p.to_string_lossy().to_string())
13491            .unwrap_or_else(|_| name.clone());
13492
13493        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
13494        if is_dir {
13495            let is_gitlink = owning_index
13496                .get(rel.as_bytes(), 0)
13497                .is_some_and(|e| e.mode == 0o160000);
13498            if is_gitlink {
13499                let Some(nested_git) = submodule_embedded_git_dir(&path) else {
13500                    continue;
13501                };
13502                let nested_index_path = nested_git.join("index");
13503                let Ok(nested_ix) = crate::index::Index::load(&nested_index_path) else {
13504                    continue;
13505                };
13506                let nested_tracked: std::collections::BTreeSet<String> = nested_ix
13507                    .entries
13508                    .iter()
13509                    .filter(|e| e.stage() == 0)
13510                    .map(|e| String::from_utf8_lossy(&e.path).into_owned())
13511                    .collect();
13512                if submodule_dir_has_untracked_inner(&path, &path, &nested_tracked, &nested_ix) {
13513                    return true;
13514                }
13515            } else if submodule_dir_has_untracked_inner(&path, root, tracked, owning_index) {
13516                return true;
13517            }
13518        } else if !tracked.contains(&rel) {
13519            return true;
13520        }
13521    }
13522    false
13523}
13524
13525#[cfg(test)]
13526mod line_compare_key_tests {
13527    use super::*;
13528    use crate::diff::unified_emit::line_diff_token;
13529    use crate::diff::WhitespaceMode;
13530
13531    #[test]
13532    fn borrowed_keys_match_owned_line_tokens() {
13533        let texts = [
13534            "",
13535            "\n",
13536            "a",
13537            "a\n",
13538            "a\nb",
13539            "a\r\nb\r\n",
13540            "a\r\nb\r",
13541            "\n\n\nlast",
13542            "x\n\r\n\ry\n",
13543        ];
13544        for text in texts {
13545            let side = LineSide::from_text(text);
13546            let mut normalized = Vec::new();
13547            let keys = line_compare_keys(&side, WhitespaceMode::default(), &mut normalized);
13548            let expected: Vec<String> = side.lines().map(|l| line_diff_token(&l)).collect();
13549            assert_eq!(keys, expected, "text {text:?}");
13550            let source = text.as_bytes().as_ptr_range();
13551            assert!(
13552                keys.iter()
13553                    .all(|key| source.contains(&key.as_ptr()) || key.is_empty()),
13554                "text {text:?}"
13555            );
13556        }
13557    }
13558
13559    #[test]
13560    fn whitespace_modes_compare_normalized_bodies() {
13561        let text = "a  b\nab \n";
13562        let side = LineSide::from_text(text);
13563        let ws = WhitespaceMode {
13564            ignore_all_space: true,
13565            ..WhitespaceMode::default()
13566        };
13567        let mut normalized = Vec::new();
13568        let keys = line_compare_keys(&side, ws, &mut normalized);
13569        assert_eq!(keys, vec!["ab", "ab"]);
13570    }
13571
13572    #[test]
13573    fn xdiff_marks_only_edited_lines_in_large_file() {
13574        let old: String = (0..5_000).map(|i| format!("line{i}\n")).collect();
13575        let new: String = (0..5_000)
13576            .map(|i| {
13577                if i % 100 == 0 {
13578                    format!("LINE{i}\n")
13579                } else {
13580                    format!("line{i}\n")
13581                }
13582            })
13583            .collect();
13584        let body =
13585            git_xdiff_unified_body_raw(&old, &new, 3, 0, true, false, WhitespaceMode::default());
13586        assert_eq!(body.matches("\n@@ ").count() + 1, 50);
13587        assert_eq!(body.lines().filter(|l| l.starts_with('-')).count(), 50);
13588        assert_eq!(body.lines().filter(|l| l.starts_with('+')).count(), 50);
13589        assert!(body.starts_with("@@ -1,4 +1,4 @@\n-line0\n+LINE0\n line1\n"));
13590    }
13591}
13592
13593#[cfg(test)]
13594mod entry_order_tests {
13595    use super::*;
13596
13597    fn entry(status: DiffStatus, path: &str, score: Option<u32>) -> DiffEntry {
13598        let mode = |present: bool| {
13599            if present {
13600                FileMode::REGULAR
13601            } else {
13602                FileMode::ABSENT
13603            }
13604        };
13605        DiffEntry {
13606            status,
13607            old_path: (status != DiffStatus::Added).then(|| path.to_owned()),
13608            new_path: (status != DiffStatus::Deleted).then(|| path.to_owned()),
13609            old_mode: mode(status != DiffStatus::Added),
13610            new_mode: mode(status != DiffStatus::Deleted),
13611            old_oid: ObjectId::zero(),
13612            new_oid: ObjectId::zero(),
13613            score,
13614        }
13615    }
13616
13617    #[test]
13618    fn sort_entries_by_path_orders_by_path_and_keeps_equal_paths_in_place() {
13619        let mut entries = vec![
13620            entry(DiffStatus::Modified, "b", None),
13621            entry(DiffStatus::Deleted, "a/x", Some(1)),
13622            entry(DiffStatus::Added, "a/x", Some(2)),
13623            entry(DiffStatus::Added, "a", None),
13624        ];
13625        sort_entries_by_path(&mut entries);
13626        let order: Vec<_> = entries.iter().map(|e| (e.path(), e.score)).collect();
13627        assert_eq!(
13628            order,
13629            [("a", None), ("a/x", Some(1)), ("a/x", Some(2)), ("b", None)]
13630        );
13631        let sorted = entries.clone();
13632        sort_entries_by_path(&mut entries);
13633        assert_eq!(entries, sorted);
13634        sort_entries_by_path(&mut []);
13635    }
13636
13637    #[test]
13638    fn sort_entries_by_path_compares_non_utf8_paths_by_raw_bytes() {
13639        let raw = crate::repo_relpath::format_path_stored("", b"raw-\xff");
13640        assert!(raw.starts_with("grit+pathhex:"));
13641        let mut entries = vec![
13642            entry(DiffStatus::Modified, &raw, None),
13643            entry(DiffStatus::Modified, "node/leaf", None),
13644            entry(DiffStatus::Modified, "zeta", None),
13645        ];
13646        sort_entries_by_path(&mut entries);
13647        let paths: Vec<_> = entries.iter().map(DiffEntry::path).collect();
13648        assert_eq!(paths, ["node/leaf", raw.as_str(), "zeta"]);
13649    }
13650
13651    #[test]
13652    fn rename_pass_returns_unpairable_lists_in_path_order() {
13653        let config = ConfigSet::new();
13654        let policy = ThreadPolicy::default();
13655        let odb = Odb::new(Path::new("/nonexistent-objects"));
13656        let run = |entries: Vec<DiffEntry>| {
13657            detect_renames_with_policy(
13658                &odb,
13659                None,
13660                entries,
13661                50,
13662                &config,
13663                &policy,
13664                None,
13665                DetectRenamesOptions::default(),
13666            )
13667            .expect("no blob is read without both adds and deletes")
13668            .entries
13669        };
13670        let sorted = vec![
13671            entry(DiffStatus::Deleted, "a", None),
13672            entry(DiffStatus::Modified, "b", None),
13673            entry(DiffStatus::Deleted, "c", None),
13674        ];
13675        assert_eq!(run(sorted.clone()), sorted);
13676        let unsorted = vec![
13677            entry(DiffStatus::Added, "z", None),
13678            entry(DiffStatus::Modified, "m", None),
13679            entry(DiffStatus::Added, "a", None),
13680        ];
13681        let paths: Vec<_> = run(unsorted).iter().map(|e| e.path().to_owned()).collect();
13682        assert_eq!(paths, ["a", "m", "z"]);
13683        // A duplicate path takes the regrouping path: modifications before additions.
13684        let duplicate = vec![
13685            entry(DiffStatus::Added, "p", None),
13686            entry(DiffStatus::Modified, "p", None),
13687        ];
13688        let statuses: Vec<_> = run(duplicate).iter().map(|e| e.status).collect();
13689        assert_eq!(statuses, [DiffStatus::Modified, DiffStatus::Added]);
13690    }
13691}
13692
13693#[cfg(test)]
13694mod diff_index_to_tree_cache_tree_tests {
13695    use std::rc::Rc;
13696    use std::sync::atomic::{AtomicUsize, Ordering};
13697
13698    use super::*;
13699    use crate::index::{Index, IndexEntry, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
13700    use crate::objects::ObjectKind;
13701    use crate::write_tree::{build_cache_tree_from_index, write_tree_from_index};
13702    use tempfile::TempDir;
13703
13704    struct TreeReadCounter {
13705        counter: Rc<AtomicUsize>,
13706    }
13707
13708    impl TreeReadCounter {
13709        fn start() -> Self {
13710            let counter = Rc::new(AtomicUsize::new(0));
13711            set_diff_tree_read_hook_for_tests(Some(counter.clone()));
13712            Self { counter }
13713        }
13714
13715        fn get(&self) -> usize {
13716            self.counter.load(Ordering::Relaxed)
13717        }
13718    }
13719
13720    impl Drop for TreeReadCounter {
13721        fn drop(&mut self) {
13722            set_diff_tree_read_hook_for_tests(None);
13723        }
13724    }
13725
13726    fn diff_index_to_tree_flatten_reference(
13727        odb: &Odb,
13728        index: &Index,
13729        head_tree: &ObjectId,
13730        ignore_submodules: bool,
13731    ) -> Result<Vec<DiffEntry>> {
13732        let mut flat_index = index.clone();
13733        flat_index.clear_cache_tree();
13734        diff_index_to_tree(odb, &flat_index, Some(head_tree), ignore_submodules)
13735    }
13736
13737    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
13738        IndexEntry {
13739            ctime_sec: 0,
13740            ctime_nsec: 0,
13741            mtime_sec: 0,
13742            mtime_nsec: 0,
13743            dev: 0,
13744            ino: 0,
13745            mode,
13746            uid: 0,
13747            gid: 0,
13748            size: 0,
13749            oid,
13750            flags: path.len().min(0xfff) as u16,
13751            flags_extended: None,
13752            path: path.as_bytes().to_vec(),
13753            base_index_pos: 0,
13754        }
13755    }
13756
13757    fn repo_with_tree(depth: usize, width: usize) -> (TempDir, Odb, ObjectId, Index) {
13758        let temp = TempDir::new().expect("tempdir");
13759        let odb = Odb::new(temp.path());
13760        let mut index = Index::new();
13761        for d in 0..depth {
13762            for w in 0..width {
13763                let path = format!("unchanged/d{d}/f{w}.txt");
13764                let oid = odb
13765                    .write(ObjectKind::Blob, format!("body-{d}-{w}").as_bytes())
13766                    .expect("blob");
13767                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
13768            }
13769        }
13770        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
13771        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
13772        index.set_cache_tree(cache);
13773        index.cache_tree_root = Some(head_tree);
13774        (temp, odb, head_tree, index)
13775    }
13776
13777    #[test]
13778    fn diff_index_to_tree_cache_tree_fast_path_matches_slow() {
13779        let (_temp, odb, head_tree, mut index) = repo_with_tree(4, 5);
13780        assert!(index.cache_tree.as_ref().is_some_and(|c| c.is_valid()));
13781
13782        let mut state: u64 = 0xDEAD_BEEF;
13783        for _ in 0..24 {
13784            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
13785            let d = (state % 4) as usize;
13786            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
13787            let w = (state % 5) as usize;
13788            let path = format!("unchanged/d{d}/f{w}.txt");
13789            index.invalidate_cache_tree_for_path(path.as_bytes());
13790            assert!(
13791                index.cache_tree_root.is_none(),
13792                "partial invalidation clears cache_tree_root like Index::add"
13793            );
13794            assert!(
13795                index.cache_tree.as_ref().is_some_and(|r| !r.is_valid()),
13796                "root cache node must be invalid after partial invalidation"
13797            );
13798            if let Some(e) = index.get_mut(path.as_bytes(), 0) {
13799                let new_oid = odb
13800                    .write(ObjectKind::Blob, format!("new-{d}-{w}").as_bytes())
13801                    .expect("blob");
13802                e.oid = new_oid;
13803            }
13804            let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
13805            let flat = diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false)
13806                .expect("flat");
13807            assert_eq!(fast, flat, "mismatch after mutating {path}");
13808        }
13809    }
13810
13811    #[test]
13812    fn diff_index_to_tree_skips_unchanged_subtrees() {
13813        let (_temp, odb, head_tree, index) = repo_with_tree(5, 6);
13814        let reads = TreeReadCounter::start();
13815        diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("cached diff");
13816        let cached_reads = reads.get();
13817
13818        let mut no_cache = index.clone();
13819        no_cache.clear_cache_tree();
13820        let reads = TreeReadCounter::start();
13821        diff_index_to_tree(&odb, &no_cache, Some(&head_tree), false).expect("flat diff");
13822        let flat_reads = reads.get();
13823
13824        assert!(
13825            cached_reads < flat_reads,
13826            "expected fewer tree reads with cache-tree (cached={cached_reads}, flat={flat_reads})"
13827        );
13828        assert_eq!(
13829            cached_reads, 1,
13830            "unchanged repo should only read the root tree"
13831        );
13832    }
13833
13834    #[test]
13835    fn diff_index_to_tree_partial_invalidation_skips_sibling_subtree() {
13836        let (_temp, odb, head_tree, mut index) = repo_with_tree(5, 6);
13837        let path = "unchanged/d1/f2.txt";
13838        index.invalidate_cache_tree_for_path(path.as_bytes());
13839        let new_oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
13840        index.get_mut(path.as_bytes(), 0).expect("entry").oid = new_oid;
13841
13842        let flat =
13843            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
13844        let reads = TreeReadCounter::start();
13845        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
13846        let cached_reads = reads.get();
13847        assert_eq!(fast, flat);
13848
13849        let reads = TreeReadCounter::start();
13850        diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
13851        let flat_reads = reads.get();
13852
13853        assert!(
13854            cached_reads < flat_reads,
13855            "partial cache-tree should skip sibling dirs (cached={cached_reads}, flat={flat_reads})"
13856        );
13857    }
13858
13859    fn cache_leaf(name: &str) -> CacheTreeNode {
13860        CacheTreeNode::valid(name.as_bytes().to_vec(), 1, ObjectId::zero(), Vec::new())
13861    }
13862
13863    #[test]
13864    fn cache_children_lookup_matches_linear_scan_on_wide_unsorted_nodes() {
13865        let mut children: Vec<CacheTreeNode> = (0..CACHE_CHILD_LINEAR_SCAN_MAX * 3)
13866            .rev()
13867            .map(|i| cache_leaf(&format!("d{i:03}")))
13868            .collect();
13869        let mut first_dup = cache_leaf("d007");
13870        first_dup.entry_count = 7;
13871        children.insert(3, first_dup);
13872        let root = CacheTreeNode::valid(Vec::new(), 0, ObjectId::zero(), children);
13873
13874        let lookup = CacheChildren::new(Some(&root));
13875        assert!(
13876            !lookup.sorted.is_empty(),
13877            "wide nodes must use the sorted lookup"
13878        );
13879        for name in ["d000", "d007", "d020", "d047", "zzz", "d", ""] {
13880            let linear = root.children.iter().find(|c| c.name == name.as_bytes());
13881            let fast = lookup.get(name.as_bytes());
13882            assert_eq!(
13883                fast.map(|c| (&c.name, c.entry_count)),
13884                linear.map(|c| (&c.name, c.entry_count)),
13885                "lookup of {name:?}"
13886            );
13887        }
13888        assert_eq!(lookup.get(b"d007").map(|c| c.entry_count), Some(7));
13889
13890        let narrow = CacheTreeNode::valid(
13891            Vec::new(),
13892            0,
13893            ObjectId::zero(),
13894            vec![cache_leaf("b"), cache_leaf("a")],
13895        );
13896        let lookup = CacheChildren::new(Some(&narrow));
13897        assert!(lookup.sorted.is_empty());
13898        assert_eq!(lookup.get(b"a").map(|c| c.name.as_slice()), Some(&b"a"[..]));
13899        assert!(lookup.get(b"c").is_none());
13900        assert!(CacheChildren::new(None).get(b"a").is_none());
13901    }
13902
13903    #[test]
13904    fn index_relative_in_dir_requires_a_component_boundary() {
13905        assert_eq!(index_relative_in_dir(b"a/b", b""), Some(&b"a/b"[..]));
13906        assert_eq!(index_relative_in_dir(b"a/b", b"a"), Some(&b"b"[..]));
13907        assert_eq!(index_relative_in_dir(b"a", b"a"), Some(&b""[..]));
13908        assert_eq!(index_relative_in_dir(b"ab/c", b"a"), None);
13909        assert_eq!(index_relative_in_dir(b"a.c", b"a"), None);
13910        assert_eq!(index_relative_in_dir(b"b", b"a"), None);
13911        assert_eq!(join_index_path(b"", b"x"), b"x".to_vec());
13912        assert_eq!(join_index_path(b"a/b", b"x"), b"a/b/x".to_vec());
13913    }
13914
13915    /// Wide root (more children than the linear-scan cutoff), cache-tree children stored in
13916    /// reverse order, a few changed paths, plus additions and deletions at both levels.
13917    #[test]
13918    fn diff_index_to_tree_wide_unsorted_cache_tree_matches_flatten() {
13919        let temp = TempDir::new().expect("tempdir");
13920        let odb = Odb::new(temp.path());
13921        let mut index = Index::new();
13922        let width = CACHE_CHILD_LINEAR_SCAN_MAX * 4;
13923        for d in 0..width {
13924            for f in 0..2 {
13925                let path = format!("dir{d:03}/sub/f{f}.txt");
13926                let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
13927                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
13928            }
13929        }
13930        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
13931        let mut cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
13932        cache.children.reverse();
13933        index.set_cache_tree(cache);
13934        index.cache_tree_root = Some(head_tree);
13935
13936        let changed = [
13937            "dir003/sub/f1.txt",
13938            "dir031/sub/f0.txt",
13939            "dir063/sub/f1.txt",
13940        ];
13941        for path in changed {
13942            index.invalidate_cache_tree_for_path(path.as_bytes());
13943            let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
13944            index.get_mut(path.as_bytes(), 0).expect("entry").oid = oid;
13945        }
13946        for path in ["dir010/sub/new.txt", "dir999/new.txt", "top.txt"] {
13947            index.invalidate_cache_tree_for_path(path.as_bytes());
13948            let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
13949            index.add_or_replace(index_entry(path, MODE_REGULAR, oid));
13950        }
13951        for path in [
13952            "dir020/sub/f0.txt",
13953            "dir020/sub/f1.txt",
13954            "dir021/sub/f0.txt",
13955        ] {
13956            index.invalidate_cache_tree_for_path(path.as_bytes());
13957            index.remove(path.as_bytes());
13958        }
13959
13960        let flat =
13961            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
13962        let reads = TreeReadCounter::start();
13963        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
13964        let cached_reads = reads.get();
13965        assert_eq!(fast, flat);
13966        assert_eq!(fast.len(), changed.len() + 3 + 3);
13967        assert!(
13968            cached_reads <= 1 + 2 * 6,
13969            "unchanged siblings must be skipped through the cache-tree (reads={cached_reads})"
13970        );
13971    }
13972
13973    #[test]
13974    fn diff_index_to_tree_cache_tree_handles_non_utf8_directory_names() {
13975        let temp = TempDir::new().expect("tempdir");
13976        let odb = Odb::new(temp.path());
13977        let mut index = Index::new();
13978        let raw_dirs: [&[u8]; 3] = [b"caf\xe9", b"caf\xef\xbf\xbd", b"plain"];
13979        for dir in raw_dirs {
13980            for f in [&b"a.txt"[..], b"b.txt"] {
13981                let path = [dir, b"/", f].concat();
13982                let oid = odb.write(ObjectKind::Blob, &path).expect("blob");
13983                let mut entry = index_entry("x", MODE_REGULAR, oid);
13984                entry.flags = path.len().min(0xfff) as u16;
13985                entry.path = path;
13986                index.add_or_replace(entry);
13987            }
13988        }
13989        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
13990        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
13991        index.set_cache_tree(cache);
13992        index.cache_tree_root = Some(head_tree);
13993
13994        let changed: &[u8] = b"caf\xe9/b.txt";
13995        index.invalidate_cache_tree_for_path(changed);
13996        let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
13997        index.get_mut(changed, 0).expect("entry").oid = oid;
13998
13999        // Directory prefixes are compared as raw bytes, so `caf\xe9` and a directory literally
14000        // named `caf\u{fffd}` stay distinct even though they print the same.
14001        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
14002        assert_eq!(fast.len(), 1, "{fast:?}");
14003        assert_eq!(fast[0].status, DiffStatus::Modified);
14004        assert_eq!(
14005            fast[0].old_oid,
14006            odb.write(ObjectKind::Blob, changed).expect("blob")
14007        );
14008        assert_eq!(fast[0].new_oid, oid);
14009    }
14010
14011    #[test]
14012    fn diff_index_to_tree_reports_mode_gitlink_and_intent_to_add() {
14013        let temp = TempDir::new().expect("tempdir");
14014        let odb = Odb::new(temp.path());
14015        let blob = odb.write(ObjectKind::Blob, b"x").expect("blob");
14016        let mut index = Index::new();
14017        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, blob));
14018        let head_tree = write_tree_from_index(&odb, &index, "").expect("tree");
14019        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
14020        index.set_cache_tree(cache);
14021
14022        let mode_entry = index_entry("tracked.txt", MODE_EXECUTABLE, blob);
14023        index.add_or_replace(mode_entry);
14024        index.invalidate_cache_tree_for_path(b"tracked.txt");
14025
14026        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
14027        assert_eq!(diffs.len(), 1);
14028        assert_eq!(diffs[0].status, DiffStatus::Modified);
14029        assert_eq!(diffs[0].old_mode, FileMode::new(MODE_REGULAR));
14030        assert_eq!(diffs[0].new_mode, FileMode::new(MODE_EXECUTABLE));
14031
14032        // Intent-to-add is omitted from staged diff.
14033        let mut ita = index_entry("new.txt", MODE_REGULAR, blob);
14034        ita.set_intent_to_add(true);
14035        index.add_or_replace(ita);
14036        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
14037        assert!(
14038            diffs.iter().all(|d| d.path() != "new.txt"),
14039            "intent-to-add must not appear in staged diff"
14040        );
14041
14042        // Gitlink with ignore_submodules skips diff entry but still matches tree.
14043        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14044            .parse()
14045            .expect("oid");
14046        let mut sub_index = Index::new();
14047        sub_index.add_or_replace(index_entry("sub", MODE_GITLINK, gitlink));
14048        let sub_tree = write_tree_from_index(&odb, &sub_index, "").expect("tree");
14049        let sub_cache = build_cache_tree_from_index(&odb, &sub_index).expect("cache");
14050        sub_index.set_cache_tree(sub_cache);
14051        let diffs =
14052            diff_index_to_tree(&odb, &sub_index, Some(&sub_tree), true).expect("gitlink diff");
14053        assert!(
14054            diffs.is_empty(),
14055            "ignored gitlink should not diff: {diffs:?}"
14056        );
14057    }
14058
14059    #[test]
14060    fn ignore_submodules_skips_gitlink_in_added_directory_cache_walk() {
14061        let temp = TempDir::new().expect("tempdir");
14062        let odb = Odb::new(temp.path());
14063        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14064            .parse()
14065            .expect("oid");
14066        let head_tree = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
14067        let mut index = Index::new();
14068        index.add_or_replace(index_entry("newdir/sub", MODE_GITLINK, gitlink));
14069        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
14070        index.set_cache_tree(cache);
14071        index.invalidate_cache_tree_for_path(b"newdir/sub");
14072
14073        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
14074        let flat =
14075            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
14076        assert_eq!(fast, flat);
14077        assert!(
14078            fast.is_empty(),
14079            "nested gitlinks in added dirs must be ignored: {fast:?}"
14080        );
14081    }
14082
14083    #[test]
14084    fn ignore_submodules_skips_gitlink_in_deleted_directory_cache_walk() {
14085        let temp = TempDir::new().expect("tempdir");
14086        let odb = Odb::new(temp.path());
14087        let blob = odb.write(ObjectKind::Blob, b"keep").expect("blob");
14088        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14089            .parse()
14090            .expect("oid");
14091        let mut index = Index::new();
14092        index.add_or_replace(index_entry("keep.txt", MODE_REGULAR, blob));
14093        index.add_or_replace(index_entry("olddir/sub", MODE_GITLINK, gitlink));
14094        let head_tree = write_tree_from_index(&odb, &index, "").expect("head");
14095        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
14096        index.set_cache_tree(cache);
14097        index
14098            .entries
14099            .retain(|e| !String::from_utf8_lossy(&e.path).starts_with("olddir/"));
14100        index.invalidate_cache_tree_for_path(b"olddir/sub");
14101
14102        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
14103        let flat =
14104            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
14105        assert_eq!(fast, flat);
14106        assert!(
14107            fast.is_empty(),
14108            "nested gitlinks in deleted dirs must be ignored: {fast:?}"
14109        );
14110    }
14111
14112    #[test]
14113    fn diff_trees_skips_identical_subtree_oids() {
14114        let temp = TempDir::new().expect("tempdir");
14115        let odb = Odb::new(temp.path());
14116        let leaf = odb.write(ObjectKind::Blob, b"leaf").expect("blob");
14117        let mut deep = Index::new();
14118        for i in 0..20 {
14119            deep.add_or_replace(index_entry(&format!("deep/n{i}.txt"), MODE_REGULAR, leaf));
14120        }
14121        let tree = write_tree_from_index(&odb, &deep, "").expect("tree");
14122        let reads = TreeReadCounter::start();
14123        let entries = diff_trees(&odb, Some(&tree), Some(&tree), "").expect("diff");
14124        assert!(entries.is_empty());
14125        assert_eq!(
14126            reads.get(),
14127            0,
14128            "identical tree oids should short-circuit without reading tree objects"
14129        );
14130    }
14131
14132    #[test]
14133    fn diff_tree_path_change_count_matches_diff_trees() {
14134        let temp = TempDir::new().expect("tempdir");
14135        let odb = Odb::new(temp.path());
14136        let a = odb.write(ObjectKind::Blob, b"a").expect("blob");
14137        let b = odb.write(ObjectKind::Blob, b"b").expect("blob");
14138        let mut left = Index::new();
14139        left.add_or_replace(index_entry("f.txt", MODE_REGULAR, a));
14140        let mut right = Index::new();
14141        right.add_or_replace(index_entry("f.txt", MODE_REGULAR, b));
14142        right.add_or_replace(index_entry("g.txt", MODE_REGULAR, a));
14143        let old_tree = write_tree_from_index(&odb, &left, "").expect("old");
14144        let new_tree = write_tree_from_index(&odb, &right, "").expect("new");
14145        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
14146        let count =
14147            diff_tree_path_change_count(&odb, Some(&old_tree), Some(&new_tree), "").expect("count");
14148        assert_eq!(count, entries.len());
14149        assert_eq!(count, 2);
14150    }
14151
14152    #[test]
14153    fn diff_trees_matches_whether_trees_were_cached_parsed_or_raw() {
14154        let temp = TempDir::new().expect("tempdir");
14155        let a = Odb::new(temp.path())
14156            .write(ObjectKind::Blob, b"a")
14157            .expect("blob");
14158        let b = Odb::new(temp.path())
14159            .write(ObjectKind::Blob, b"b")
14160            .expect("blob");
14161        let mut left = Index::new();
14162        let mut right = Index::new();
14163        for i in 0..50 {
14164            left.add_or_replace(index_entry(&format!("same{i:02}.txt"), MODE_REGULAR, a));
14165            right.add_or_replace(index_entry(&format!("same{i:02}.txt"), MODE_REGULAR, a));
14166        }
14167        left.add_or_replace(index_entry("dir/changed.txt", MODE_REGULAR, a));
14168        left.add_or_replace(index_entry("dir/kept.txt", MODE_REGULAR, a));
14169        left.add_or_replace(index_entry("gone/x.txt", MODE_REGULAR, a));
14170        left.add_or_replace(index_entry("mode.sh", MODE_REGULAR, a));
14171        left.add_or_replace(index_entry("swap", MODE_REGULAR, a));
14172        right.add_or_replace(index_entry("dir/changed.txt", MODE_REGULAR, b));
14173        right.add_or_replace(index_entry("dir/kept.txt", MODE_REGULAR, a));
14174        right.add_or_replace(index_entry("mode.sh", MODE_EXECUTABLE, a));
14175        right.add_or_replace(index_entry("new/y.txt", MODE_REGULAR, b));
14176        right.add_or_replace(index_entry("swap/inner.txt", MODE_REGULAR, b));
14177        let setup = Odb::new(temp.path());
14178        let old_tree = write_tree_from_index(&setup, &left, "").expect("old");
14179        let new_tree = write_tree_from_index(&setup, &right, "").expect("new");
14180
14181        let raw = diff_trees(&Odb::new(temp.path()), Some(&old_tree), Some(&new_tree), "")
14182            .expect("raw diff");
14183        let changed: Vec<(DiffStatus, &str)> = raw.iter().map(|e| (e.status, e.path())).collect();
14184        assert_eq!(
14185            changed,
14186            vec![
14187                (DiffStatus::Modified, "dir/changed.txt"),
14188                (DiffStatus::Deleted, "gone/x.txt"),
14189                (DiffStatus::Modified, "mode.sh"),
14190                (DiffStatus::Added, "new/y.txt"),
14191                (DiffStatus::Deleted, "swap"),
14192                (DiffStatus::Added, "swap/inner.txt"),
14193            ]
14194        );
14195
14196        let parsed = Odb::new(temp.path());
14197        parsed.cached_tree_entries(&old_tree).expect("parse old");
14198        parsed.cached_tree_entries(&new_tree).expect("parse new");
14199        assert_eq!(
14200            diff_trees(&parsed, Some(&old_tree), Some(&new_tree), "").expect("parsed diff"),
14201            raw
14202        );
14203
14204        let mixed = Odb::new(temp.path());
14205        mixed.cached_tree_entries(&new_tree).expect("parse new");
14206        assert_eq!(
14207            diff_trees(&mixed, Some(&old_tree), Some(&new_tree), "").expect("mixed diff"),
14208            raw
14209        );
14210        let reparsed = mixed
14211            .cached_tree_entries(&old_tree)
14212            .expect("parse cached raw");
14213        assert_eq!(
14214            reparsed.as_slice(),
14215            crate::objects::parse_tree(&mixed.read(&old_tree).expect("read").data)
14216                .expect("parse")
14217                .as_slice(),
14218            "a tree cached raw by a diff parses to the same entries"
14219        );
14220    }
14221
14222    #[test]
14223    fn diff_trees_reports_a_corrupt_tree() {
14224        let temp = TempDir::new().expect("tempdir");
14225        let odb = Odb::new(temp.path());
14226        let good = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
14227        let corrupt = odb
14228            .write(ObjectKind::Tree, b"100644 name-without-oid")
14229            .expect("tree");
14230        let err = diff_trees(&odb, Some(&good), Some(&corrupt), "").expect_err("corrupt");
14231        assert!(matches!(err, Error::CorruptObject(_)), "{err:?}");
14232    }
14233}
14234
14235#[cfg(test)]
14236mod changed_flags_shared_tests {
14237    use super::git_xdiff;
14238    use crate::line_interner::LineInterner;
14239
14240    /// Cutting identical leading and trailing records out and passing their occurrence counts
14241    /// gives the same changed records as diffing the whole files.
14242    #[test]
14243    fn shared_counts_stand_in_for_records_left_out() {
14244        // `}` is frequent only outside the cut, which makes xdiff discard the middle `}` between
14245        // unique lines instead of matching it.
14246        let mut old: Vec<String> = Vec::new();
14247        let mut new: Vec<String> = Vec::new();
14248        for i in 0..60 {
14249            for side in [&mut old, &mut new] {
14250                side.push(format!("fn f{i}() {{\n"));
14251                side.push("}\n".to_owned());
14252            }
14253        }
14254        old.extend(
14255            [
14256                "u1\n", "u2\n", "u3\n", "u4\n", "}\n", "u5\n", "u6\n", "u7\n",
14257            ]
14258            .map(str::to_owned),
14259        );
14260        new.extend(
14261            [
14262                "v1\n", "v2\n", "v3\n", "v4\n", "}\n", "v5\n", "v6\n", "v7\n",
14263            ]
14264            .map(str::to_owned),
14265        );
14266        for i in 0..40 {
14267            for side in [&mut old, &mut new] {
14268                side.push(format!("fn g{i}() {{\n"));
14269                side.push("}\n".to_owned());
14270            }
14271        }
14272        let old: Vec<&str> = old.iter().map(String::as_str).collect();
14273        let new: Vec<&str> = new.iter().map(String::as_str).collect();
14274        let (head, tail) = (120, 80);
14275
14276        let mut interner = LineInterner::with_capacity(old.len() + new.len());
14277        let ids1 = interner.intern_all(&old);
14278        let ids2 = interner.intern_all(&new);
14279        let (full1, full2) = git_xdiff::changed_flags(&ids1, &ids2, false);
14280
14281        let mut cut = LineInterner::default();
14282        let cut1 = cut.intern_all(&old[head..old.len() - tail]);
14283        let cut2 = cut.intern_all(&new[head..new.len() - tail]);
14284        let mut shared = vec![0u32; cut.len()];
14285        for line in old[..head].iter().chain(&old[old.len() - tail..]) {
14286            if let Some(id) = cut.get(line) {
14287                shared[id as usize] += 1;
14288            }
14289        }
14290        let (part1, part2) =
14291            git_xdiff::changed_flags_with_shared(&cut1, &cut2, &shared, head + tail, false);
14292        assert_eq!(part1, full1[head..old.len() - tail]);
14293        assert_eq!(part2, full2[head..new.len() - tail]);
14294        assert!(full1[..head]
14295            .iter()
14296            .chain(&full1[old.len() - tail..])
14297            .all(|c| !c));
14298        // Like `git diff`, the whole middle is one change: the `}` among unique lines is
14299        // discarded because it is frequent in the whole file.
14300        assert!(full1[head..old.len() - tail].iter().all(|&c| c));
14301        let (alone1, _) = git_xdiff::changed_flags(&cut1, &cut2, false);
14302        assert!(
14303            !alone1[4],
14304            "counted alone, the cut keeps the `}}` as a match"
14305        );
14306    }
14307}
14308
14309#[cfg(test)]
14310mod gitlink_tree_worktree_tests {
14311    use std::fs;
14312
14313    use crate::index::{Index, IndexEntry, MODE_GITLINK};
14314    use crate::objects::{ObjectId, ObjectKind};
14315    use crate::repo::init_repository;
14316
14317    use super::diff_tree_to_worktree;
14318
14319    fn encode_tree_gitlink(name: &str, oid: &ObjectId) -> Vec<u8> {
14320        let mut data = format!("160000 {name}\0").into_bytes();
14321        data.extend_from_slice(oid.as_bytes());
14322        data
14323    }
14324
14325    #[test]
14326    fn unpopulated_gitlink_matching_index_produces_no_diff_entry() {
14327        let dir = tempfile::tempdir().expect("tempdir");
14328        let work_tree = dir.path();
14329        fs::create_dir(work_tree.join("sub")).expect("sub placeholder");
14330
14331        let repo = init_repository(
14332            work_tree,
14333            false,
14334            "main",
14335            None,
14336            crate::RefStorageFormat::Files,
14337        )
14338        .expect("init");
14339        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14340            .parse()
14341            .expect("gitlink oid");
14342        let tree_oid = repo
14343            .odb()
14344            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
14345            .expect("write tree");
14346
14347        let path = b"sub".to_vec();
14348        let mut index = Index::new();
14349        index.add_or_replace(IndexEntry {
14350            ctime_sec: 0,
14351            ctime_nsec: 0,
14352            mtime_sec: 0,
14353            mtime_nsec: 0,
14354            dev: 0,
14355            ino: 0,
14356            mode: MODE_GITLINK,
14357            uid: 0,
14358            gid: 0,
14359            size: 0,
14360            oid: gitlink,
14361            flags: (path.len().min(0xfff)) as u16,
14362            flags_extended: None,
14363            path,
14364            base_index_pos: 0,
14365        });
14366
14367        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
14368            .expect("diff_tree_to_worktree");
14369        assert!(
14370            entries.is_empty(),
14371            "clean uninitialized gitlink should not appear in tree→worktree diff: {entries:?}"
14372        );
14373    }
14374
14375    #[test]
14376    fn unpopulated_gitlink_with_stray_file_produces_no_diff_entry() {
14377        let dir = tempfile::tempdir().expect("tempdir");
14378        let work_tree = dir.path();
14379        let sub = work_tree.join("sub");
14380        fs::create_dir(&sub).expect("sub placeholder");
14381        fs::write(sub.join("x.txt"), b"stray\n").expect("stray file");
14382
14383        let repo = init_repository(
14384            work_tree,
14385            false,
14386            "main",
14387            None,
14388            crate::RefStorageFormat::Files,
14389        )
14390        .expect("init");
14391        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
14392            .parse()
14393            .expect("gitlink oid");
14394        let tree_oid = repo
14395            .odb()
14396            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
14397            .expect("write tree");
14398
14399        let path = b"sub".to_vec();
14400        let mut index = Index::new();
14401        index.add_or_replace(IndexEntry {
14402            ctime_sec: 0,
14403            ctime_nsec: 0,
14404            mtime_sec: 0,
14405            mtime_nsec: 0,
14406            dev: 0,
14407            ino: 0,
14408            mode: MODE_GITLINK,
14409            uid: 0,
14410            gid: 0,
14411            size: 0,
14412            oid: gitlink,
14413            flags: (path.len().min(0xfff)) as u16,
14414            flags_extended: None,
14415            path,
14416            base_index_pos: 0,
14417        });
14418
14419        let tree_entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
14420            .expect("diff_tree_to_worktree");
14421        assert!(
14422            tree_entries.is_empty(),
14423            "uninitialized gitlink with stray files should not diff: {tree_entries:?}"
14424        );
14425
14426        let wt_entries =
14427            super::diff_index_to_worktree(&repo.odb(), &mut index.clone(), work_tree, false, true)
14428                .expect("diff_index_to_worktree");
14429        assert!(
14430            wt_entries.is_empty(),
14431            "uninitialized gitlink with stray files should not diff: {wt_entries:?}"
14432        );
14433
14434        let ie = index.entries()[0].clone();
14435        assert!(
14436            !super::worktree_differs_from_index_entry(
14437                repo.odb(),
14438                repo.git_dir(),
14439                work_tree,
14440                &ie,
14441                false,
14442                None,
14443                None,
14444            )
14445            .expect("differs"),
14446            "stray files in uninitialized submodule should not differ from index"
14447        );
14448    }
14449
14450    #[cfg_attr(
14451        not(feature = "full-tests"),
14452        ignore = "starts system git or is heavy; run with --features full-tests"
14453    )]
14454    #[test]
14455    fn untracked_only_in_checked_out_submodule_produces_no_diff_entry() {
14456        use std::process::Command;
14457
14458        let dir = tempfile::tempdir().expect("tempdir");
14459        let work_tree = dir.path();
14460        fs::create_dir(work_tree.join("sub")).expect("sub dir");
14461
14462        let repo = init_repository(
14463            work_tree,
14464            false,
14465            "main",
14466            None,
14467            crate::RefStorageFormat::Files,
14468        )
14469        .expect("init");
14470        let sub = work_tree.join("sub");
14471        for args in [
14472            &["init", "-q"][..],
14473            &["config", "user.name", "T"][..],
14474            &["config", "user.email", "t@e.com"][..],
14475        ] {
14476            let status = Command::new("git")
14477                .current_dir(&sub)
14478                .args(args)
14479                .status()
14480                .expect("git");
14481            assert!(status.success(), "git {args:?} failed");
14482        }
14483        fs::write(sub.join("tracked.txt"), b"v1\n").expect("write");
14484        for args in [&["add", "tracked.txt"][..], &["commit", "-qm", "sub"][..]] {
14485            let status = Command::new("git")
14486                .current_dir(&sub)
14487                .args(args)
14488                .status()
14489                .expect("git");
14490            assert!(status.success());
14491        }
14492        let head_out = Command::new("git")
14493            .current_dir(&sub)
14494            .args(["rev-parse", "HEAD"])
14495            .output()
14496            .expect("rev-parse");
14497        let gitlink = std::str::from_utf8(&head_out.stdout)
14498            .expect("utf8")
14499            .trim()
14500            .parse::<ObjectId>()
14501            .expect("oid");
14502
14503        let tree_oid = repo
14504            .odb()
14505            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
14506            .expect("tree");
14507        let path = b"sub".to_vec();
14508        let mut index = Index::new();
14509        index.add_or_replace(IndexEntry {
14510            ctime_sec: 0,
14511            ctime_nsec: 0,
14512            mtime_sec: 0,
14513            mtime_nsec: 0,
14514            dev: 0,
14515            ino: 0,
14516            mode: MODE_GITLINK,
14517            uid: 0,
14518            gid: 0,
14519            size: 0,
14520            oid: gitlink,
14521            flags: (path.len().min(0xfff)) as u16,
14522            flags_extended: None,
14523            path,
14524            base_index_pos: 0,
14525        });
14526
14527        fs::write(sub.join("untracked.txt"), b"extra\n").expect("untracked");
14528
14529        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
14530            .expect("diff_tree_to_worktree");
14531        assert!(
14532            entries.is_empty(),
14533            "untracked-only submodule dirt must not appear in default tree→worktree diff: {entries:?}"
14534        );
14535    }
14536}
14537
14538#[cfg(test)]
14539mod racy_index_mtime_diff_tests {
14540    use super::*;
14541    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
14542    use crate::objects::{HashAlgo, ObjectKind};
14543    use filetime::FileTime;
14544    use std::fs;
14545    use std::path::PathBuf;
14546
14547    const INDEX_MTIME: (u32, u32) = (1_700_000_000, 123_456_789);
14548
14549    fn pin_mtime(path: &Path, sec: u32, nsec: u32) {
14550        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
14551            .expect("set mtime");
14552    }
14553
14554    fn minimal_sha256_repo(work_tree: &Path) -> (PathBuf, Odb) {
14555        let git_dir = work_tree.join(".git");
14556        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14557        fs::write(
14558            git_dir.join("config"),
14559            "[extensions]\n\tobjectformat = sha256\n",
14560        )
14561        .expect("config");
14562        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
14563        (git_dir, odb)
14564    }
14565
14566    #[test]
14567    fn detects_same_size_change_when_entry_mtime_matches_index_mtime() {
14568        let td = tempfile::tempdir().expect("tempdir");
14569        let wt = td.path();
14570        let content_a = b"same length!!";
14571        let content_b = b"same length??";
14572        assert_eq!(content_a.len(), content_b.len());
14573
14574        fs::write(wt.join("f.txt"), content_a).expect("write");
14575        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
14576        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14577        let git_dir = wt.join(".git");
14578        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14579        let odb = Odb::new(&git_dir.join("objects"));
14580        let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
14581        let entry = entry_from_metadata(&meta, b"f.txt", oid_a, MODE_REGULAR);
14582
14583        let mut index = Index::new();
14584        index.source_mtime = Some(INDEX_MTIME);
14585        index.entries.push(entry);
14586
14587        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14588        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
14589
14590        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
14591        assert_eq!(
14592            diff.len(),
14593            1,
14594            "racy entry must be re-hashed, not stat-trusted"
14595        );
14596        assert_eq!(diff[0].path(), "f.txt");
14597    }
14598
14599    #[test]
14600    #[cfg(unix)]
14601    fn non_utf8_symlink_clean_racy_equal_mtime_reports_no_diff() {
14602        use crate::index::MODE_SYMLINK;
14603        use std::os::unix::ffi::OsStrExt as _;
14604        use std::os::unix::fs::symlink;
14605
14606        let td = tempfile::tempdir().expect("tempdir");
14607        let wt = td.path();
14608        let target = std::ffi::OsStr::from_bytes(b"bad-\xff-target");
14609        let link_path = wt.join("link");
14610        symlink(target, &link_path).expect("symlink");
14611
14612        let ft = FileTime::from_unix_time(i64::from(INDEX_MTIME.0), INDEX_MTIME.1);
14613        filetime::set_symlink_file_times(&link_path, ft, ft).expect("set symlink mtime");
14614        let meta = fs::symlink_metadata(&link_path).expect("stat");
14615        let git_dir = wt.join(".git");
14616        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14617        let odb = Odb::new(&git_dir.join("objects"));
14618        let oid = odb.hash(ObjectKind::Blob, target.as_bytes()).expect("hash");
14619        let entry = entry_from_metadata(&meta, b"link", oid, MODE_SYMLINK);
14620
14621        let mut index = Index::new();
14622        index.source_mtime = Some(INDEX_MTIME);
14623        index.entries.push(entry);
14624
14625        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
14626        assert!(
14627            diff.is_empty(),
14628            "clean non-UTF-8 symlink must not be reported modified: {diff:?}"
14629        );
14630    }
14631
14632    #[test]
14633    fn sha256_clean_racy_equal_mtime_reports_no_diff() {
14634        let td = tempfile::tempdir().expect("tempdir");
14635        let wt = td.path();
14636        let (_git_dir, odb) = minimal_sha256_repo(wt);
14637        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
14638
14639        let bytes = b"sha256 width!!";
14640        fs::write(wt.join("f.txt"), bytes).expect("write");
14641        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
14642        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14643        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
14644        let entry = entry_from_metadata(&meta, b"f.txt", oid, MODE_REGULAR);
14645
14646        let mut index = Index::new();
14647        index.hash_algo = HashAlgo::Sha256;
14648        index.source_mtime = Some(INDEX_MTIME);
14649        index.entries.push(entry);
14650
14651        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
14652        assert!(
14653            diff.is_empty(),
14654            "clean SHA-256 racy entry must not be reported modified: {diff:?}"
14655        );
14656    }
14657
14658    /// Racy entries are hashed with their own path's attributes even when the paths
14659    /// alternate between attribute sets: CRLF files clean to LF, binary files hash as is.
14660    #[test]
14661    fn racy_entries_hash_with_their_own_attributes() {
14662        let td = tempfile::tempdir().expect("tempdir");
14663        let wt = td.path();
14664        let git_dir = wt.join(".git");
14665        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14666        fs::write(git_dir.join("config"), "[core]\n").expect("config");
14667        fs::write(wt.join(".gitattributes"), "*.crlf eol=crlf\n*.bin -text\n").expect("attrs");
14668        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
14669
14670        let files: [(&str, &[u8], &[u8]); 5] = [
14671            ("a.crlf", b"one\r\ntwo\r\n", b"one\ntwo\n"),
14672            ("b.bin", b"raw\r\nbytes\r\n", b"raw\r\nbytes\r\n"),
14673            ("c.crlf", b"three\r\n", b"three\n"),
14674            ("d.txt", b"plain\n", b"plain\n"),
14675            ("e.bin", b"more\r\n", b"more\r\n"),
14676        ];
14677        let mut index = Index::new();
14678        index.source_mtime = Some(INDEX_MTIME);
14679        for (path, on_disk, blob) in files {
14680            let file = wt.join(path);
14681            fs::write(&file, on_disk).expect("write");
14682            pin_mtime(&file, INDEX_MTIME.0, INDEX_MTIME.1);
14683            let meta = fs::symlink_metadata(&file).expect("stat");
14684            let oid = odb.hash(ObjectKind::Blob, blob).expect("hash");
14685            index.entries.push(entry_from_metadata(
14686                &meta,
14687                path.as_bytes(),
14688                oid,
14689                MODE_REGULAR,
14690            ));
14691        }
14692
14693        let status_diff = |index: &mut Index| {
14694            diff_index_to_worktree_with_options(
14695                &odb,
14696                index,
14697                wt,
14698                DiffIndexToWorktreeOptions {
14699                    index_mtime: Some(INDEX_MTIME),
14700                    repository_git_dir: Some(git_dir.clone()),
14701                    refresh_index_stat_in_pass: true,
14702                    for_status: true,
14703                    ..Default::default()
14704                },
14705            )
14706            .expect("diff")
14707            .0
14708        };
14709        let diff = status_diff(&mut index.clone());
14710        assert!(diff.is_empty(), "clean racy entries reported: {diff:?}");
14711
14712        fs::write(wt.join("c.crlf"), b"thrEe\r\n").expect("rewrite");
14713        pin_mtime(&wt.join("c.crlf"), INDEX_MTIME.0, INDEX_MTIME.1);
14714        let diff = status_diff(&mut index);
14715        let paths: Vec<_> = diff.iter().map(DiffEntry::path).collect();
14716        assert_eq!(paths, ["c.crlf"]);
14717    }
14718}
14719
14720#[cfg(test)]
14721mod smudge_racily_clean_tests {
14722    use super::*;
14723    use crate::config::ConfigSet;
14724    use crate::crlf::{self, FileAttrs};
14725    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
14726    use crate::objects::{HashAlgo, ObjectKind};
14727    use filetime::FileTime;
14728    use std::fs;
14729    use std::path::PathBuf;
14730
14731    const INDEX_MTIME_PIN: (u32, u32) = (1_700_000_000, 123_456_789);
14732
14733    fn mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
14734        #[cfg(unix)]
14735        {
14736            use std::os::unix::fs::MetadataExt;
14737            (meta.mtime() as u32, meta.mtime_nsec() as u32)
14738        }
14739        #[cfg(not(unix))]
14740        {
14741            use std::time::UNIX_EPOCH;
14742            let mtime = meta
14743                .modified()
14744                .ok()
14745                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
14746                .unwrap_or_default();
14747            (mtime.as_secs() as u32, mtime.subsec_nanos())
14748        }
14749    }
14750
14751    /// Pin mtime then read back the value the index would store (NTFS quantizes subseconds).
14752    fn pin_mtime_and_sample(path: &Path, sec: u32, nsec: u32) -> (u32, u32) {
14753        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
14754            .expect("set mtime");
14755        let meta = fs::symlink_metadata(path).expect("stat file");
14756        mtime_from_metadata(&meta)
14757    }
14758
14759    /// Sample the on-disk index mtime used in racy-git tests (matches filesystem quantization).
14760    fn setup_index_mtime(work_tree: &Path) -> (u32, u32) {
14761        let probe = work_tree.join(".grit-index-mtime-probe");
14762        fs::write(&probe, b"").expect("probe file");
14763        pin_mtime_and_sample(&probe, INDEX_MTIME_PIN.0, INDEX_MTIME_PIN.1)
14764    }
14765
14766    fn minimal_repo(work_tree: &Path, config: &str) -> (PathBuf, Odb) {
14767        let git_dir = work_tree.join(".git");
14768        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
14769        fs::write(git_dir.join("config"), config).expect("config");
14770        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
14771        (git_dir, odb)
14772    }
14773
14774    fn staged_entry_for_file(
14775        odb: &Odb,
14776        work_tree: &Path,
14777        rel: &str,
14778        bytes: &[u8],
14779        mtime: (u32, u32),
14780    ) -> IndexEntry {
14781        let path = work_tree.join(rel);
14782        fs::write(&path, bytes).expect("write file");
14783        pin_mtime_and_sample(&path, mtime.0, mtime.1);
14784        let meta = fs::symlink_metadata(&path).expect("stat file");
14785        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
14786        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
14787    }
14788
14789    fn staged_entry_with_oid(
14790        work_tree: &Path,
14791        rel: &str,
14792        worktree_bytes: &[u8],
14793        oid: crate::objects::ObjectId,
14794        mtime: (u32, u32),
14795    ) -> IndexEntry {
14796        let path = work_tree.join(rel);
14797        fs::write(&path, worktree_bytes).expect("write file");
14798        pin_mtime_and_sample(&path, mtime.0, mtime.1);
14799        let meta = fs::symlink_metadata(&path).expect("stat file");
14800        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
14801    }
14802
14803    #[test]
14804    fn index_stat_cache_trustworthy_rejects_unknown_mtime() {
14805        assert!(!super::index_stat_cache_trustworthy(None));
14806        assert!(!super::index_stat_cache_trustworthy(Some((0, 0))));
14807        assert!(super::index_stat_cache_trustworthy(Some((1, 0))));
14808    }
14809
14810    #[test]
14811    fn index_entry_skips_content_hash_after_stat_match_when_trusted_and_not_racy() {
14812        use crate::index::StatMatchPolicy;
14813        let td = tempfile::tempdir().expect("tempdir");
14814        let wt = td.path();
14815        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = true\n");
14816        let rel = "trusted.txt";
14817        let file_mtime = (1_700_000_000, 0);
14818        let mut entry = staged_entry_for_file(&odb, wt, rel, b"payload\n", file_mtime);
14819        let index_mtime = (file_mtime.0 + 1, 0);
14820        let policy = StatMatchPolicy::from_config(None);
14821        assert!(
14822            super::index_entry_skips_worktree_content_hash_after_stat_match(
14823                &entry,
14824                Some(index_mtime),
14825                policy,
14826            )
14827        );
14828        entry.size = 0;
14829        entry.mtime_sec = 0;
14830        entry.mtime_nsec = 0;
14831        assert!(
14832            !super::index_entry_skips_worktree_content_hash_after_stat_match(
14833                &entry,
14834                Some(index_mtime),
14835                policy,
14836            )
14837        );
14838        let _ = git_dir;
14839    }
14840
14841    #[cfg(unix)]
14842    #[test]
14843    fn symlink_dir_cache_detects_symlinked_ancestors_across_threads() {
14844        let td = tempfile::tempdir().expect("tempdir");
14845        let wt = td.path();
14846        fs::create_dir_all(wt.join("real/deep")).expect("mkdir");
14847        fs::create_dir_all(wt.join("target/deep")).expect("mkdir");
14848        std::os::unix::fs::symlink(wt.join("target"), wt.join("linked")).expect("symlink");
14849        std::thread::scope(|scope| {
14850            for _ in 0..4 {
14851                scope.spawn(|| {
14852                    let mut cache = super::SymlinkDirCache::default();
14853                    assert!(!cache.has_symlink_in_path_bytes(wt, b"top.txt", false, false));
14854                    assert!(!cache.has_symlink_in_path_bytes(wt, b"linked", false, false));
14855                    assert!(!cache.has_symlink_in_path_bytes(wt, b"real/deep/f", false, false));
14856                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/f", false, false));
14857                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/deep/f", false, false));
14858                    assert!(!cache.has_symlink_in_path_bytes(wt, b"\xff\xfe", false, false));
14859                });
14860            }
14861        });
14862    }
14863
14864    #[cfg(unix)]
14865    #[test]
14866    fn symlink_dir_cache_accepts_a_work_tree_reached_through_a_symlink() {
14867        let td = tempfile::tempdir().expect("tempdir");
14868        let real = td.path().join("real");
14869        fs::create_dir_all(real.join("a/b/c")).expect("mkdir");
14870        fs::create_dir_all(real.join("t/b/c")).expect("mkdir");
14871        std::os::unix::fs::symlink(real.join("t"), real.join("l")).expect("symlink");
14872        let wt = td.path().join("alias");
14873        std::os::unix::fs::symlink(&real, &wt).expect("symlink");
14874        let mut cache = super::SymlinkDirCache::default();
14875        assert!(!cache.has_symlink_in_path_bytes(&wt, b"a/b/c/f", false, false));
14876        assert!(cache.has_symlink_in_path_bytes(&wt, b"l/b/c/f", false, false));
14877        assert!(!cache.has_symlink_in_path_bytes(&real, b"a/b/c/f", false, false));
14878        assert!(cache.has_symlink_in_path_bytes(&real, b"l/b/c/f", false, false));
14879    }
14880
14881    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
14882    #[test]
14883    fn real_dir_chain_confirms_only_symlink_free_directories() {
14884        let td = tempfile::tempdir().expect("tempdir");
14885        let wt = td.path();
14886        fs::create_dir_all(wt.join("a/b/c")).expect("mkdir");
14887        fs::create_dir_all(wt.join("t/c")).expect("mkdir");
14888        fs::write(wt.join("a/file"), b"f").expect("write");
14889        std::os::unix::fs::symlink(wt.join("t"), wt.join("a/l")).expect("symlink");
14890        let mut chain = crate::worktree_scan::RealDirChain::open(wt);
14891        assert_eq!(chain.work_tree(), wt);
14892        assert!(chain.confirms(b"a"));
14893        assert!(chain.confirms(b"a/b/c"));
14894        assert!(!chain.confirms(b"a/l"));
14895        assert!(!chain.confirms(b"a/l/c"));
14896        assert!(!chain.confirms(b"a/file"));
14897        assert!(!chain.confirms(b"a/missing/c"));
14898        assert!(!chain.confirms(b""));
14899        assert!(!chain.confirms(b"a/\0b"));
14900        assert!(chain.confirms(b"a/b"));
14901    }
14902
14903    #[test]
14904    fn symlink_dir_cache_probes_each_new_directory_once_in_index_order() {
14905        let td = tempfile::tempdir().expect("tempdir");
14906        let wt = td.path();
14907        let depth = 12;
14908        let chains = 20;
14909        let mut paths = Vec::new();
14910        for chain in 0..chains {
14911            let dir: Vec<String> = (0..depth)
14912                .map(|level| format!("c{chain:02}l{level:02}"))
14913                .collect();
14914            let dir = format!("shared/{}", dir.join("/"));
14915            fs::create_dir_all(wt.join(&dir)).expect("mkdir");
14916            paths.extend((0..3).map(|file| format!("{dir}/f{file}")));
14917        }
14918        paths.sort();
14919        let mut cache = super::SymlinkDirCache::default();
14920        for rel in &paths {
14921            assert!(!cache.has_symlink_in_path_bytes(wt, rel.as_bytes(), false, false));
14922        }
14923        // Each chain's directories are new; `shared` and sibling files are already known.
14924        let unique_dirs = 1 + chains * depth;
14925        assert!(
14926            cache.fs_queries <= unique_dirs,
14927            "{} filesystem queries for {unique_dirs} directories",
14928            cache.fs_queries
14929        );
14930        #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
14931        assert!(
14932            cache.fs_queries <= chains + 1,
14933            "one chain walk per new directory: {} queries for {chains} chains",
14934            cache.fs_queries
14935        );
14936    }
14937
14938    #[test]
14939    fn common_dir_prefix_len_stops_at_whole_components() {
14940        use super::{common_dir_prefix_len, path_within_dir};
14941        assert_eq!(common_dir_prefix_len(b"a/b/c", b"a/b/c"), 5);
14942        assert_eq!(common_dir_prefix_len(b"a/b/c", b"a/b"), 3);
14943        assert_eq!(common_dir_prefix_len(b"a/b", b"a/b/c"), 3);
14944        assert_eq!(common_dir_prefix_len(b"a/bc/d", b"a/b"), 1);
14945        assert_eq!(common_dir_prefix_len(b"a/b", b"a/bc/d"), 1);
14946        assert_eq!(common_dir_prefix_len(b"ab", b"a"), 0);
14947        assert_eq!(common_dir_prefix_len(b"a/b", b""), 0);
14948        assert!(path_within_dir(b"a/b", b"a/b"));
14949        assert!(path_within_dir(b"a/b/c", b"a/b"));
14950        assert!(!path_within_dir(b"a/bc", b"a/b"));
14951        assert!(!path_within_dir(b"a", b"a/b"));
14952    }
14953
14954    /// Answer the ancestor-symlink question without any caching, one probe per ancestor.
14955    #[cfg(unix)]
14956    fn naive_has_symlink_ancestor(wt: &Path, rel: &str) -> bool {
14957        let Some((dir, _)) = rel.rsplit_once('/') else {
14958            return false;
14959        };
14960        let mut prefix = wt.to_path_buf();
14961        dir.split('/').any(|component| {
14962            prefix.push(component);
14963            fs::symlink_metadata(&prefix).is_ok_and(|m| m.file_type().is_symlink())
14964        })
14965    }
14966
14967    #[cfg(unix)]
14968    #[test]
14969    fn symlink_dir_cache_matches_uncached_probe_in_any_query_order() {
14970        let td = tempfile::tempdir().expect("tempdir");
14971        let wt = td.path();
14972        fs::create_dir_all(wt.join("a/b/c/d")).expect("mkdir");
14973        fs::create_dir_all(wt.join("a/bb/c")).expect("mkdir");
14974        fs::create_dir_all(wt.join("a/b2")).expect("mkdir");
14975        fs::create_dir_all(wt.join("t/c/d")).expect("mkdir");
14976        fs::create_dir_all(wt.join("x/y")).expect("mkdir");
14977        fs::write(wt.join("a/file"), b"f").expect("write");
14978        fs::write(wt.join("x/y/plain"), b"f").expect("write");
14979        std::os::unix::fs::symlink(wt.join("t"), wt.join("a/b/link")).expect("symlink");
14980        std::os::unix::fs::symlink(wt.join("t"), wt.join("x/l")).expect("symlink");
14981        std::os::unix::fs::symlink(wt.join("t"), wt.join("top")).expect("symlink");
14982
14983        let paths = [
14984            "a/b/c/d/f",
14985            "a/b/c/f",
14986            "a/b/f",
14987            "a/b/link/f",
14988            "a/b/link/c/d/f",
14989            "a/b/linker/f",
14990            "a/b2/f",
14991            "a/bb/c/f",
14992            "a/file/under/f",
14993            "a/missing/deeper/f",
14994            "a/missing/f",
14995            "a/f",
14996            "f",
14997            "top/c/f",
14998            "top/f",
14999            "topper/f",
15000            "x/l/c/d/f",
15001            "x/y/f",
15002            "x/y/plain/f",
15003        ];
15004        let mut orders: Vec<Vec<&str>> = vec![paths.to_vec()];
15005        orders.push(paths.iter().rev().copied().collect());
15006        orders.push(
15007            paths
15008                .iter()
15009                .step_by(2)
15010                .chain(paths.iter().skip(1).step_by(2))
15011                .copied()
15012                .collect(),
15013        );
15014        orders.push(paths.iter().flat_map(|p| [*p, *p]).collect());
15015        for order in orders {
15016            let mut cache = super::SymlinkDirCache::default();
15017            for rel in order {
15018                assert_eq!(
15019                    cache.has_symlink_in_path_bytes(wt, rel.as_bytes(), false, false),
15020                    naive_has_symlink_ancestor(wt, rel),
15021                    "ancestor symlink answer for {rel}"
15022                );
15023            }
15024        }
15025    }
15026
15027    #[test]
15028    fn content_hash_skip_trusts_empty_blob_but_not_smudged_size() {
15029        use crate::index::StatMatchPolicy;
15030        let td = tempfile::tempdir().expect("tempdir");
15031        let wt = td.path();
15032        let (_git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
15033        let file_mtime = (1_700_000_000, 0);
15034        let index_mtime = Some((file_mtime.0 + 1, 0));
15035        let policy = StatMatchPolicy::from_config(None);
15036
15037        let empty = staged_entry_for_file(&odb, wt, "empty.txt", b"", file_mtime);
15038        assert_eq!(empty.size, 0);
15039        assert!(
15040            super::index_entry_skips_worktree_content_hash_after_stat_match(
15041                &empty,
15042                index_mtime,
15043                policy,
15044            )
15045        );
15046
15047        let smudged_oid = odb.hash(ObjectKind::Blob, b"abc\n").expect("hash");
15048        let smudged = staged_entry_with_oid(wt, "smudged.txt", b"", smudged_oid, file_mtime);
15049        assert_eq!(smudged.size, 0);
15050        assert!(
15051            !super::index_entry_skips_worktree_content_hash_after_stat_match(
15052                &smudged,
15053                index_mtime,
15054                policy,
15055            )
15056        );
15057    }
15058
15059    #[test]
15060    fn status_diff_reports_smudged_entry_whose_file_became_empty() {
15061        let td = tempfile::tempdir().expect("tempdir");
15062        let wt = td.path();
15063        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
15064        let oid = odb.write(ObjectKind::Blob, b"abc\n").expect("write blob");
15065        let file_mtime = (1_700_000_000, 0);
15066        let mut index = Index::new();
15067        index.entries.push(staged_entry_with_oid(
15068            wt,
15069            "smudged.txt",
15070            b"",
15071            oid,
15072            file_mtime,
15073        ));
15074        let (entries, _) = super::diff_index_to_worktree_with_options(
15075            &odb,
15076            &mut index,
15077            wt,
15078            super::DiffIndexToWorktreeOptions {
15079                repository_git_dir: Some(git_dir),
15080                index_mtime: Some((file_mtime.0 + 1, 0)),
15081                for_status: true,
15082                stat_parallel_threads: Some(1),
15083                ..Default::default()
15084            },
15085        )
15086        .expect("diff");
15087        assert_eq!(entries.len(), 1, "{entries:?}");
15088        assert_eq!(entries[0].status, DiffStatus::Modified);
15089    }
15090
15091    #[test]
15092    fn status_diff_verifies_content_when_index_stat_cache_unset() {
15093        let td = tempfile::tempdir().expect("tempdir");
15094        let wt = td.path();
15095        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15096        let rel = "unset-stat.txt";
15097        let bytes = b"same bytes\n";
15098        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
15099        fs::write(wt.join(rel), bytes).expect("write");
15100        let mut index = Index::new();
15101        let mut entry = staged_entry_for_file(&odb, wt, rel, bytes, (100, 0));
15102        entry.size = 0;
15103        entry.mtime_sec = 0;
15104        entry.mtime_nsec = 0;
15105        entry.oid = oid;
15106        index.entries.push(entry);
15107        let (entries, _) = super::diff_index_to_worktree_with_options(
15108            &odb,
15109            &mut index,
15110            wt,
15111            super::DiffIndexToWorktreeOptions {
15112                repository_git_dir: Some(git_dir),
15113                for_status: true,
15114                ..Default::default()
15115            },
15116        )
15117        .expect("diff");
15118        assert!(
15119            entries.is_empty(),
15120            "unset stat must not size-mismatch a matching worktree: {entries:?}"
15121        );
15122    }
15123
15124    #[cfg_attr(
15125        not(feature = "full-tests"),
15126        ignore = "starts system git or is heavy; run with --features full-tests"
15127    )]
15128    #[test]
15129    fn status_reports_modified_when_ctime_differs_but_mtime_sec_and_size_match_non_racy() {
15130        let td = tempfile::tempdir().expect("tempdir");
15131        let wt = td.path();
15132        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15133        let rel = "tracked.txt";
15134        let original = b"original\n";
15135        let modified = b"modified\n";
15136        assert_eq!(original.len(), modified.len());
15137
15138        let entry_mtime = (1_700_000_000u32, 0u32);
15139        let index_file_mtime = (1_700_000_100u32, 0u32);
15140
15141        std::process::Command::new("git")
15142            .args(["-C", &wt.to_string_lossy(), "init", "-q"])
15143            .status()
15144            .expect("git init");
15145        std::process::Command::new("git")
15146            .args([
15147                "-C",
15148                &wt.to_string_lossy(),
15149                "config",
15150                "user.email",
15151                "t@example.com",
15152            ])
15153            .status()
15154            .expect("git config email");
15155        std::process::Command::new("git")
15156            .args(["-C", &wt.to_string_lossy(), "config", "user.name", "T"])
15157            .status()
15158            .expect("git config name");
15159
15160        fs::write(wt.join(rel), original).expect("write original");
15161        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
15162        std::process::Command::new("git")
15163            .args(["-C", &wt.to_string_lossy(), "add", rel])
15164            .status()
15165            .expect("git add");
15166        std::process::Command::new("git")
15167            .args([
15168                "-C",
15169                &wt.to_string_lossy(),
15170                "commit",
15171                "-qm",
15172                "seed",
15173                "--no-gpg-sign",
15174            ])
15175            .status()
15176            .expect("git commit");
15177
15178        let mut index = Index::new();
15179        index.source_mtime = Some(index_file_mtime);
15180        let staged = staged_entry_for_file(&odb, wt, rel, original, entry_mtime);
15181        let oid_original = staged.oid;
15182        index.entries.push(staged);
15183
15184        fs::write(wt.join(rel), modified).expect("modify worktree");
15185        let git_dirty = std::process::Command::new("git")
15186            .args([
15187                "-C",
15188                &wt.to_string_lossy(),
15189                "--no-optional-locks",
15190                "status",
15191                "--short",
15192            ])
15193            .output()
15194            .expect("git status");
15195        assert!(
15196            git_dirty.status.success(),
15197            "git status failed: {}",
15198            String::from_utf8_lossy(&git_dirty.stderr)
15199        );
15200        assert!(
15201            String::from_utf8_lossy(&git_dirty.stdout).contains("tracked.txt"),
15202            "system git must see modification before mtime pin: {}",
15203            String::from_utf8_lossy(&git_dirty.stdout)
15204        );
15205
15206        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
15207
15208        let meta = fs::symlink_metadata(wt.join(rel)).expect("stat");
15209        let ie = &mut index.entries[0];
15210        assert_eq!(ie.oid, oid_original);
15211        if stat_matches(ie, &meta) {
15212            ie.ctime_sec = ie.ctime_sec.wrapping_sub(3600);
15213        }
15214        assert!(
15215            !stat_matches(ie, &meta),
15216            "ctime/inode change must fail full stat match"
15217        );
15218        let stat_policy = crate::index::StatMatchPolicy::default();
15219        assert!(
15220            !entry_is_racy_with_policy(ie, Some(index_file_mtime), stat_policy),
15221            "fixture must be non-racy"
15222        );
15223
15224        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
15225            &odb,
15226            &mut index,
15227            wt,
15228            super::DiffIndexToWorktreeOptions {
15229                index_mtime: Some(index_file_mtime),
15230                repository_git_dir: Some(git_dir),
15231                refresh_index_stat_in_pass: true,
15232                for_status: true,
15233                ..Default::default()
15234            },
15235        )
15236        .expect("diff");
15237        assert_eq!(
15238            entries.len(),
15239            1,
15240            "must report modified, not refresh stat clean: {entries:?}, index_changed={index_changed}"
15241        );
15242        assert_eq!(entries[0].path(), rel);
15243    }
15244
15245    #[test]
15246    fn diff_name_status_skips_big_worktree_read_on_size_mismatch() {
15247        let td = tempfile::tempdir().expect("tempdir");
15248        let wt = td.path();
15249        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tbigFileThreshold = 4k\n");
15250        let rel = "big.bin";
15251        let indexed = vec![b'x'; 100];
15252        let mut index = Index::new();
15253        index
15254            .entries
15255            .push(staged_entry_for_file(&odb, wt, rel, &indexed, (100, 0)));
15256        fs::write(wt.join(rel), vec![b'y'; 5000]).expect("rewrite bigger");
15257        let metrics = odb.hot_path_metrics();
15258        metrics.reset_blob_content_reads();
15259        metrics.set_blob_counting(true);
15260        let (entries, _) = super::diff_index_to_worktree_with_options(
15261            &odb,
15262            &mut index,
15263            wt,
15264            super::DiffIndexToWorktreeOptions {
15265                repository_git_dir: Some(git_dir),
15266                for_status: false,
15267                ..Default::default()
15268            },
15269        )
15270        .expect("diff");
15271        metrics.set_blob_counting(false);
15272        assert_eq!(entries.len(), 1);
15273        assert_eq!(entries[0].status, super::DiffStatus::Modified);
15274        assert_eq!(entries[0].new_oid, super::zero_oid());
15275        assert_eq!(
15276            metrics.blob_content_reads(),
15277            0,
15278            "large size-only drift must not hash or read the worktree"
15279        );
15280    }
15281
15282    #[test]
15283    fn status_diff_skips_hash_when_worktree_size_differs() {
15284        let td = tempfile::tempdir().expect("tempdir");
15285        let wt = td.path();
15286        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15287        let rel = "size-change.txt";
15288        let original = b"short\n";
15289        let mut index = Index::new();
15290        index
15291            .entries
15292            .push(staged_entry_for_file(&odb, wt, rel, original, (100, 0)));
15293        fs::write(wt.join(rel), b"longer content\n").expect("rewrite");
15294        let metrics = odb.hot_path_metrics();
15295        metrics.reset_blob_content_reads();
15296        metrics.set_blob_counting(true);
15297        let (entries, _) = super::diff_index_to_worktree_with_options(
15298            &odb,
15299            &mut index,
15300            wt,
15301            super::DiffIndexToWorktreeOptions {
15302                repository_git_dir: Some(git_dir),
15303                for_status: true,
15304                ..Default::default()
15305            },
15306        )
15307        .expect("diff");
15308        metrics.set_blob_counting(false);
15309        assert_eq!(entries.len(), 1);
15310        assert_eq!(entries[0].status, super::DiffStatus::Modified);
15311        assert_eq!(entries[0].path(), rel);
15312        assert_eq!(entries[0].old_oid, index.entries[0].oid);
15313        assert_eq!(entries[0].new_oid, super::zero_oid());
15314        assert_eq!(
15315            metrics.blob_content_reads(),
15316            0,
15317            "identity conversion: a size change alone proves the file modified"
15318        );
15319    }
15320
15321    #[test]
15322    fn status_diff_hashes_size_change_when_eol_conversion_restores_blob() {
15323        for refresh_index_stat_in_pass in [false, true] {
15324            let td = tempfile::tempdir().expect("tempdir");
15325            let wt = td.path();
15326            let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15327            fs::write(wt.join(".gitattributes"), "*.txt text eol=crlf\n").expect("attrs");
15328            let rel = "n.txt";
15329            let blob = odb
15330                .write(ObjectKind::Blob, b"one\ntwo\n")
15331                .expect("write blob");
15332            let mut index = Index::new();
15333            index.entries.push(staged_entry_with_oid(
15334                wt,
15335                rel,
15336                b"one\r\ntwo\r\n",
15337                blob,
15338                (100, 0),
15339            ));
15340            fs::write(wt.join(rel), b"one\ntwo\n").expect("rewrite as LF");
15341            let (entries, index_changed) = super::diff_index_to_worktree_with_options(
15342                &odb,
15343                &mut index,
15344                wt,
15345                super::DiffIndexToWorktreeOptions {
15346                    index_mtime: Some((200, 0)),
15347                    repository_git_dir: Some(git_dir),
15348                    refresh_index_stat_in_pass,
15349                    for_status: true,
15350                    ..Default::default()
15351                },
15352            )
15353            .expect("diff");
15354            assert!(
15355                entries.is_empty(),
15356                "refresh={refresh_index_stat_in_pass}: LF worktree cleans to the indexed blob: \
15357                 {entries:?}"
15358            );
15359            if refresh_index_stat_in_pass {
15360                assert!(index_changed, "verified entry adopts the new stat");
15361                assert_eq!(index.entries[0].size, 8);
15362            }
15363        }
15364    }
15365
15366    #[test]
15367    fn status_diff_reads_size_changed_converted_file_once() {
15368        for refresh_index_stat_in_pass in [false, true] {
15369            let td = tempfile::tempdir().expect("tempdir");
15370            let wt = td.path();
15371            let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15372            fs::write(wt.join(".gitattributes"), "*.txt text eol=crlf\n").expect("attrs");
15373            let rel = "n.txt";
15374            let blob = odb
15375                .write(ObjectKind::Blob, b"one\ntwo\n")
15376                .expect("write blob");
15377            let mut index = Index::new();
15378            index.entries.push(staged_entry_with_oid(
15379                wt,
15380                rel,
15381                b"one\r\ntwo\r\n",
15382                blob,
15383                (100, 0),
15384            ));
15385            fs::write(wt.join(rel), b"one\ntwo\nthree\n").expect("real edit");
15386            let metrics = odb.hot_path_metrics();
15387            metrics.reset_blob_content_reads();
15388            metrics.set_blob_counting(true);
15389            let (entries, _) = super::diff_index_to_worktree_with_options(
15390                &odb,
15391                &mut index,
15392                wt,
15393                super::DiffIndexToWorktreeOptions {
15394                    index_mtime: Some((200, 0)),
15395                    repository_git_dir: Some(git_dir),
15396                    refresh_index_stat_in_pass,
15397                    for_status: true,
15398                    ..Default::default()
15399                },
15400            )
15401            .expect("diff");
15402            metrics.set_blob_counting(false);
15403            assert_eq!(entries.len(), 1, "refresh={refresh_index_stat_in_pass}");
15404            assert_eq!(entries[0].status, super::DiffStatus::Modified);
15405            assert_eq!(entries[0].new_oid, zero_oid());
15406            assert_eq!(
15407                index.entries[0].size, 10,
15408                "a modified file keeps its stale stat"
15409            );
15410            assert_eq!(
15411                metrics.blob_content_reads(),
15412                1,
15413                "refresh={refresh_index_stat_in_pass}: read once, not again after the refresh"
15414            );
15415        }
15416    }
15417
15418    #[test]
15419    fn stat_matches_ignores_subsecond_nsec_by_default() {
15420        let td = tempfile::tempdir().expect("tempdir");
15421        let wt = td.path();
15422        fs::write(wt.join("f.txt"), b"x\n").expect("write");
15423        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
15424        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15425        let mut entry = entry_from_metadata(&meta, b"f.txt", zero_oid(), MODE_REGULAR);
15426        entry.mtime_nsec = entry.mtime_nsec.wrapping_add(1_000_000);
15427        assert!(stat_matches(&entry, &meta));
15428    }
15429
15430    #[test]
15431    fn classify_add_up_to_date_when_stat_matches() {
15432        let td = tempfile::tempdir().expect("tempdir");
15433        let wt = td.path();
15434        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
15435        let bytes = b"hello world\n";
15436        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, (100, 0));
15437        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15438        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15439        let attrs = FileAttrs::default();
15440        let params = WorktreeAddRefreshParams {
15441            odb: &odb,
15442            ie: &entry,
15443            meta: &meta,
15444            abs_path: &wt.join("f.txt"),
15445            rel_path: "f.txt",
15446            conv: &conv,
15447            file_attrs: &attrs,
15448            index_mtime: Some((200, 0)),
15449            staged_mode: MODE_REGULAR,
15450            filter_process: None,
15451        };
15452        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
15453        assert_eq!(refresh, WorktreeAddRefresh::UpToDate);
15454    }
15455
15456    #[test]
15457    fn classify_add_rehashes_when_index_mtime_unknown_and_stat_matches() {
15458        let td = tempfile::tempdir().expect("tempdir");
15459        let wt = td.path();
15460        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
15461        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
15462        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
15463        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
15464        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15465        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15466        let attrs = FileAttrs::default();
15467        let params = WorktreeAddRefreshParams {
15468            odb: &odb,
15469            ie: &entry,
15470            meta: &meta,
15471            abs_path: &wt.join("f.txt"),
15472            rel_path: "f.txt",
15473            conv: &conv,
15474            file_attrs: &attrs,
15475            index_mtime: None,
15476            staged_mode: MODE_REGULAR,
15477            filter_process: None,
15478        };
15479        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
15480        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
15481    }
15482
15483    #[test]
15484    fn classify_add_needs_restaging_when_content_differs() {
15485        let td = tempfile::tempdir().expect("tempdir");
15486        let wt = td.path();
15487        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
15488        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
15489        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
15490        pin_mtime_and_sample(&wt.join("f.txt"), 101, 0);
15491        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15492        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15493        let attrs = FileAttrs::default();
15494        let params = WorktreeAddRefreshParams {
15495            odb: &odb,
15496            ie: &entry,
15497            meta: &meta,
15498            abs_path: &wt.join("f.txt"),
15499            rel_path: "f.txt",
15500            conv: &conv,
15501            file_attrs: &attrs,
15502            index_mtime: Some((200, 0)),
15503            staged_mode: MODE_REGULAR,
15504            filter_process: None,
15505        };
15506        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
15507        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
15508    }
15509
15510    #[test]
15511    fn smudges_racily_clean_modified_entry() {
15512        let td = tempfile::tempdir().expect("tempdir");
15513        let wt = td.path();
15514        let index_mtime = setup_index_mtime(wt);
15515        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15516        let content_a = b"same length!!";
15517        let content_b = b"same length??";
15518        assert_eq!(content_a.len(), content_b.len());
15519
15520        let mut index = Index::new();
15521        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
15522        let stale_oid = entry.oid;
15523
15524        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
15525        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15526        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15527        // Stat was refreshed from the worktree but the blob OID is still stale (racy window).
15528        let entry = entry_from_metadata(&meta, b"f.txt", stale_oid, MODE_REGULAR);
15529        index.entries.push(entry.clone());
15530
15531        assert!(stat_matches(&entry, &meta));
15532        assert!(entry_is_racy(&entry, Some(index_mtime)));
15533
15534        let smudged = smudge_racily_clean_entries(
15535            &odb,
15536            &git_dir,
15537            &mut index,
15538            wt,
15539            Some(index_mtime),
15540            None,
15541            None,
15542        );
15543        assert!(smudged);
15544        assert_eq!(index.entries[0].size, 0);
15545
15546        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
15547        assert_eq!(diff.len(), 1);
15548        assert_eq!(diff[0].path(), "f.txt");
15549    }
15550
15551    /// A racy index row plus the git dir and odb it lives in; the worktree no longer matches.
15552    fn racy_stale_entry(wt: &Path) -> (PathBuf, Odb, Index, (u32, u32), ObjectId) {
15553        let index_mtime = setup_index_mtime(wt);
15554        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15555        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
15556        let stale_oid = entry.oid;
15557        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
15558        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15559        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
15560        let mut index = Index::new();
15561        index.entries.push(entry_from_metadata(
15562            &meta,
15563            b"f.txt",
15564            stale_oid,
15565            MODE_REGULAR,
15566        ));
15567        (git_dir, odb, index, index_mtime, stale_oid)
15568    }
15569
15570    #[test]
15571    fn content_verified_entry_is_not_reread_and_the_mark_is_consumed() {
15572        let td = tempfile::tempdir().expect("tempdir");
15573        let wt = td.path();
15574        let (git_dir, odb, mut index, index_mtime, stale_oid) = racy_stale_entry(wt);
15575        index.mark_content_verified(b"f.txt", stale_oid);
15576
15577        let smudge = |index: &mut Index| {
15578            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
15579        };
15580        assert!(!smudge(&mut index), "a verified entry is trusted");
15581        assert_ne!(index.entries[0].size, 0);
15582        assert!(index.content_verified.is_empty());
15583        assert!(smudge(&mut index), "without the mark the entry is checked");
15584        assert_eq!(index.entries[0].size, 0);
15585    }
15586
15587    #[test]
15588    fn content_verified_entry_bit_is_trusted_and_consumed() {
15589        let td = tempfile::tempdir().expect("tempdir");
15590        let wt = td.path();
15591        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
15592        index.entries[0].mark_content_verified();
15593
15594        let smudge = |index: &mut Index| {
15595            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
15596        };
15597        assert!(!smudge(&mut index), "a marked entry is trusted");
15598        assert_ne!(index.entries[0].size, 0);
15599        assert_eq!(
15600            index.entries[0].flags_extended, None,
15601            "the mark is consumed"
15602        );
15603        assert!(smudge(&mut index), "without the mark the entry is checked");
15604        assert_eq!(index.entries[0].size, 0);
15605    }
15606
15607    #[test]
15608    fn content_verified_mark_for_another_oid_does_not_skip_the_check() {
15609        let td = tempfile::tempdir().expect("tempdir");
15610        let wt = td.path();
15611        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
15612        let other = odb.hash(ObjectKind::Blob, b"other").expect("hash");
15613        index.mark_content_verified(b"f.txt", other);
15614        assert!(smudge_racily_clean_entries(
15615            &odb,
15616            &git_dir,
15617            &mut index,
15618            wt,
15619            Some(index_mtime),
15620            None,
15621            None,
15622        ));
15623        assert_eq!(index.entries[0].size, 0);
15624    }
15625
15626    #[test]
15627    fn leaves_non_racy_entry_alone() {
15628        let td = tempfile::tempdir().expect("tempdir");
15629        let wt = td.path();
15630        let index_mtime = setup_index_mtime(wt);
15631        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15632        let content_a = b"same length!!";
15633        let content_b = b"same length??";
15634        let entry_mtime = (index_mtime.0 - 10, 0);
15635
15636        let mut index = Index::new();
15637        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, entry_mtime);
15638        index.entries.push(entry.clone());
15639
15640        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
15641        pin_mtime_and_sample(&wt.join("f.txt"), entry_mtime.0, entry_mtime.1);
15642
15643        assert!(!entry_is_racy(&entry, Some(index_mtime)));
15644        let smudged = smudge_racily_clean_entries(
15645            &odb,
15646            &git_dir,
15647            &mut index,
15648            wt,
15649            Some(index_mtime),
15650            None,
15651            None,
15652        );
15653        assert!(!smudged);
15654        assert_eq!(index.entries[0].size, entry.size);
15655    }
15656
15657    #[test]
15658    fn leaves_matching_content_alone() {
15659        let td = tempfile::tempdir().expect("tempdir");
15660        let wt = td.path();
15661        let index_mtime = setup_index_mtime(wt);
15662        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15663        let bytes = b"unchanged!!!!";
15664
15665        let mut index = Index::new();
15666        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
15667        index.entries.push(entry.clone());
15668
15669        assert!(entry_is_racy(&entry, Some(index_mtime)));
15670        let smudged = smudge_racily_clean_entries(
15671            &odb,
15672            &git_dir,
15673            &mut index,
15674            wt,
15675            Some(index_mtime),
15676            None,
15677            None,
15678        );
15679        assert!(!smudged);
15680        assert_eq!(index.entries[0].size, entry.size);
15681    }
15682
15683    #[test]
15684    fn status_diff_skips_refresh_rewrite_for_racy_clean_matching_stat() {
15685        let td = tempfile::tempdir().expect("tempdir");
15686        let wt = td.path();
15687        let index_mtime = setup_index_mtime(wt);
15688        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15689        let bytes = b"racy clean payload\n";
15690
15691        let mut index = Index::new();
15692        index.source_mtime = Some(index_mtime);
15693        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
15694        index.entries.push(entry.clone());
15695        assert!(entry_is_racy(&entry, Some(index_mtime)));
15696
15697        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
15698            &odb,
15699            &mut index,
15700            wt,
15701            super::DiffIndexToWorktreeOptions {
15702                repository_git_dir: Some(git_dir),
15703                index_mtime: Some(index_mtime),
15704                refresh_index_stat_in_pass: true,
15705                for_status: true,
15706                ..Default::default()
15707            },
15708        )
15709        .expect("status diff");
15710        assert!(entries.is_empty(), "unexpected unstaged: {entries:?}");
15711        assert!(
15712            !index_changed,
15713            "matching stat on a racy entry must skip refresh without rewriting the index"
15714        );
15715    }
15716
15717    #[test]
15718    fn leaves_matching_sha256_racy_content_alone() {
15719        let td = tempfile::tempdir().expect("tempdir");
15720        let wt = td.path();
15721        let index_mtime = setup_index_mtime(wt);
15722        let (git_dir, odb) = minimal_repo(wt, "[extensions]\n\tobjectformat = sha256\n");
15723        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
15724
15725        let bytes = b"sha256 width!!";
15726        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
15727        let mut index = Index::new();
15728        let entry = staged_entry_with_oid(wt, "f.txt", bytes, oid, index_mtime);
15729        index.entries.push(entry.clone());
15730
15731        assert!(entry_is_racy(&entry, Some(index_mtime)));
15732        let smudged = smudge_racily_clean_entries(
15733            &odb,
15734            &git_dir,
15735            &mut index,
15736            wt,
15737            Some(index_mtime),
15738            None,
15739            None,
15740        );
15741        assert!(!smudged, "matching SHA-256 blob must not be smudged");
15742        assert_eq!(index.entries[0].size, entry.size);
15743    }
15744
15745    #[test]
15746    fn materialize_big_binary_under_autocrlf_streams_into_pack() {
15747        use std::io::Write;
15748
15749        let td = tempfile::tempdir().expect("tempdir");
15750        let wt = td.path();
15751        let (git_dir, odb) =
15752            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 64k\n");
15753        let path = wt.join("big.bin");
15754        {
15755            let mut f = fs::File::create(&path).expect("create");
15756            f.write_all(&[0u8; 9000]).expect("nul prefix");
15757            let chunk = vec![1u8; 256 * 1024];
15758            for _ in 0..20 {
15759                f.write_all(&chunk).expect("chunk");
15760            }
15761        }
15762        let meta = fs::metadata(&path).expect("stat");
15763        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15764        conv.autocrlf = crlf::AutoCrlf::True;
15765        let attrs = FileAttrs::default();
15766        let oid =
15767            materialize_worktree_blob(&odb, &path, &meta, &conv, &attrs, "big.bin", None, None)
15768                .expect("materialize");
15769        assert!(
15770            !odb.test_loose_object_path(&oid).exists(),
15771            "big binary under autocrlf should pack, not write loose"
15772        );
15773        let pack_dir = git_dir.join("objects/pack");
15774        let names: Vec<_> = fs::read_dir(&pack_dir)
15775            .expect("pack dir")
15776            .map(|e| e.expect("entry").file_name())
15777            .collect();
15778        assert!(
15779            names.iter().any(|n| n.to_string_lossy().ends_with(".pack")),
15780            "{names:?}"
15781        );
15782        assert!(odb.read(&oid).expect("read blob").data.len() > 9000);
15783    }
15784
15785    #[test]
15786    fn indexed_binary_under_autocrlf_rehash_streams() {
15787        use std::io::Write;
15788
15789        let td = tempfile::tempdir().expect("tempdir");
15790        let wt = td.path();
15791        let (_git_dir, odb) =
15792            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 1m\n");
15793        let path = wt.join("big.bin");
15794        {
15795            let mut f = fs::File::create(&path).expect("create");
15796            f.write_all(&[0u8; 9000]).expect("nul prefix");
15797            let chunk = vec![1u8; 512 * 1024];
15798            for _ in 0..3 {
15799                f.write_all(&chunk).expect("chunk");
15800            }
15801        }
15802        let meta = fs::symlink_metadata(&path).expect("stat");
15803        let oid = hash_worktree_file(
15804            &odb,
15805            &path,
15806            &meta,
15807            &crlf::ConversionConfig::from_config(&ConfigSet::new()),
15808            &FileAttrs::default(),
15809            "big.bin",
15810            None,
15811            None,
15812        )
15813        .expect("hash");
15814        let entry = entry_from_metadata(&meta, b"big.bin", oid, MODE_REGULAR);
15815        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
15816        conv.autocrlf = crlf::AutoCrlf::True;
15817        let attrs = FileAttrs::default();
15818        let content = read_regular_worktree_blob(
15819            &odb,
15820            &path,
15821            &conv,
15822            &attrs,
15823            "big.bin",
15824            Some(&entry),
15825            None,
15826            1024 * 1024,
15827        )
15828        .expect("read blob");
15829        assert!(
15830            matches!(content, WorktreeBlobContent::Stream { .. }),
15831            "indexed binary re-hash must stream, not buffer"
15832        );
15833        assert_eq!(content.hash(&odb).expect("hash"), entry.oid);
15834    }
15835
15836    #[test]
15837    fn leaves_matching_crlf_worktree_content_alone() {
15838        let td = tempfile::tempdir().expect("tempdir");
15839        let wt = td.path();
15840        let index_mtime = setup_index_mtime(wt);
15841        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tautocrlf = true\n");
15842        let git_form = b"line one\nline two\n";
15843        let worktree_form = b"line one\r\nline two\r\n";
15844        let oid = odb.hash(ObjectKind::Blob, git_form).expect("hash");
15845
15846        let mut index = Index::new();
15847        let entry = staged_entry_with_oid(wt, "f.txt", worktree_form, oid, index_mtime);
15848        index.entries.push(entry.clone());
15849
15850        assert!(entry_is_racy(&entry, Some(index_mtime)));
15851        let smudged = smudge_racily_clean_entries(
15852            &odb,
15853            &git_dir,
15854            &mut index,
15855            wt,
15856            Some(index_mtime),
15857            None,
15858            None,
15859        );
15860        assert!(
15861            !smudged,
15862            "CRLF worktree matching clean index OID must not be smudged"
15863        );
15864        assert_eq!(index.entries[0].size, entry.size);
15865    }
15866
15867    #[test]
15868    fn skips_skip_worktree_and_intent_to_add() {
15869        let td = tempfile::tempdir().expect("tempdir");
15870        let wt = td.path();
15871        let index_mtime = setup_index_mtime(wt);
15872        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15873        let content_a = b"same length!!";
15874        let content_b = b"same length??";
15875
15876        let mut index = Index::new();
15877        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
15878        entry.set_skip_worktree(true);
15879        index.entries.push(entry);
15880        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
15881        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15882        assert!(!smudge_racily_clean_entries(
15883            &odb,
15884            &git_dir,
15885            &mut index,
15886            wt,
15887            Some(index_mtime),
15888            None,
15889            None,
15890        ));
15891        assert_ne!(index.entries[0].size, 0);
15892
15893        let mut index = Index::new();
15894        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
15895        entry.set_intent_to_add(true);
15896        index.entries.push(entry);
15897        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
15898        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15899        assert!(!smudge_racily_clean_entries(
15900            &odb,
15901            &git_dir,
15902            &mut index,
15903            wt,
15904            Some(index_mtime),
15905            None,
15906            None,
15907        ));
15908        assert_ne!(index.entries[0].size, 0);
15909    }
15910
15911    #[test]
15912    fn smudges_nothing_without_prior_index_mtime() {
15913        let td = tempfile::tempdir().expect("tempdir");
15914        let wt = td.path();
15915        let index_mtime = setup_index_mtime(wt);
15916        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
15917        let mut index = Index::new();
15918        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
15919        index.entries.push(entry);
15920
15921        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
15922        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
15923
15924        let smudged = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, None, None);
15925        assert!(!smudged);
15926        assert_ne!(index.entries[0].size, 0);
15927    }
15928}
15929
15930#[cfg(all(test, unix))]
15931mod read_worktree_bytes_for_mode_tests {
15932    use super::*;
15933    use crate::index::MODE_SYMLINK;
15934    use std::fs;
15935    use std::os::unix::fs::symlink;
15936
15937    #[test]
15938    fn symlink_mode_reads_link_target_not_followed_file() {
15939        let td = tempfile::tempdir().expect("tempdir");
15940        let wt = td.path();
15941        fs::write(wt.join("payload.txt"), b"secret\n").expect("write");
15942        symlink("payload.txt", wt.join("link")).expect("symlink");
15943        let bytes =
15944            read_worktree_bytes_for_mode(&wt.join("link"), MODE_SYMLINK).expect("read link");
15945        assert_eq!(bytes, b"payload.txt");
15946    }
15947
15948    #[test]
15949    fn symlink_mode_reads_plain_file_holding_the_target() {
15950        let td = tempfile::tempdir().expect("tempdir");
15951        let path = td.path().join("link");
15952        fs::write(&path, b"payload.txt").expect("write");
15953        let bytes = read_worktree_bytes_for_mode(&path, MODE_SYMLINK).expect("read plain link");
15954        assert_eq!(bytes, b"payload.txt");
15955    }
15956
15957    #[test]
15958    fn regular_mode_reads_file_contents() {
15959        let td = tempfile::tempdir().expect("tempdir");
15960        let path = td.path().join("f.txt");
15961        fs::write(&path, b"hello\n").expect("write");
15962        let bytes = read_worktree_bytes_for_mode(&path, crate::index::MODE_REGULAR).expect("read");
15963        assert_eq!(bytes, b"hello\n");
15964    }
15965
15966    #[test]
15967    fn annotate_marks_context_line_when_eof_line_is_unchanged() {
15968        let old = "a\nb\nc";
15969        let new = "a\nx\nc";
15970        let raw = super::unified_diff_histogram_hunks_only(old, new, 3, 0, false);
15971        let out = annotate_unified_diff_no_newline_at_eof(&raw, old, new);
15972        assert!(
15973            out.contains(" c\n\\ No newline at end of file"),
15974            "histogram hunk should mark trailing context line:\n{out}"
15975        );
15976    }
15977
15978    #[test]
15979    fn annotate_does_not_treat_removed_dash_lines_as_file_headers() {
15980        use super::unified_diff_with_prefix_and_funcname_and_algorithm;
15981        use similar::Algorithm;
15982
15983        let old = "--old";
15984        let new = "--new";
15985        let raw = unified_diff_with_prefix_and_funcname_and_algorithm(
15986            old,
15987            new,
15988            "a/x",
15989            "b/x",
15990            3,
15991            0,
15992            "",
15993            "",
15994            None,
15995            Algorithm::Myers,
15996            false,
15997            false,
15998            false,
15999            false,
16000        );
16001        assert_eq!(
16002            raw.matches("\\ No newline at end of file").count(),
16003            2,
16004            "expected two markers like git, got:\n{raw}"
16005        );
16006    }
16007}
16008
16009#[cfg(test)]
16010mod annotate_no_newline_tests {
16011    use super::annotate_unified_diff_no_newline_at_eof;
16012
16013    #[test]
16014    fn annotate_marks_both_sides_without_trailing_newline() {
16015        let raw = "@@ -1 +1 @@\n-foo\n+f o o\n";
16016        let out = annotate_unified_diff_no_newline_at_eof(raw, "foo", "f o o");
16017        assert!(out.contains("\\ No newline at end of file"));
16018        assert_eq!(out.matches("\\ No newline at end of file").count(), 2);
16019    }
16020}
16021
16022#[cfg(test)]
16023mod blob_diff_algorithm_smoke {
16024    use super::{
16025        anchored_unified_diff, count_changes_with_algorithm, unified_diff_histogram_hunks_only,
16026        unified_diff_with_prefix_and_funcname_and_algorithm, unified_hunk_body_for_diff_options,
16027    };
16028    use similar::Algorithm;
16029
16030    const OLD: &str = "alpha\nbeta\ngamma\n";
16031    const NEW: &str = "alpha\nBETA\ngamma\ndelta\n";
16032
16033    #[test]
16034    fn seed15_histogram_u0_body_matches_git() {
16035        use crate::diff::options::{DiffAlgorithm, DiffOptions};
16036        use std::collections::hash_map::DefaultHasher;
16037        use std::hash::{Hash, Hasher};
16038        let seed = 15u64;
16039        let mut hasher = DefaultHasher::new();
16040        seed.hash(&mut hasher);
16041        let h = hasher.finish();
16042        let line_count = 4 + (h as usize % 12);
16043        let mut old = String::new();
16044        let mut new = String::new();
16045        for i in 0..line_count {
16046            let token = (h.wrapping_add(i as u64 * 131) % 7) as u8 + b'a';
16047            let ch = token as char;
16048            old.push(ch);
16049            if (h >> (i % 8)) & 1 == 1 {
16050                new.push(((token as u8).wrapping_add(1)) as char);
16051            } else {
16052                new.push(ch);
16053            }
16054            old.push('\n');
16055            new.push('\n');
16056        }
16057        let opts = DiffOptions {
16058            context_lines: 0,
16059            algorithm: DiffAlgorithm::Histogram,
16060            indent_heuristic: false,
16061            ..DiffOptions::default()
16062        };
16063        let body = unified_hunk_body_for_diff_options(&old, &new, &opts);
16064        assert_eq!(
16065            body.matches("@@ ").count(),
16066            2,
16067            "git 2.55 splits u0 histogram hunks at seed 15: {body}"
16068        );
16069    }
16070
16071    #[test]
16072    fn t4071_histogram_changed_flags_git_myers_completes() {
16073        let old: Vec<&str> = "x\nx\nx\nx\n".lines().collect();
16074        let new: Vec<&str> = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n".lines().collect();
16075        let (c1, c2) = super::git_xdiff::histogram_changed_flags(&old, &new);
16076        assert_eq!(c1.len(), 4);
16077        assert_eq!(c2.len(), 11);
16078        let ops = super::changed_flags_to_ops(&c1, &c2, old.len(), new.len());
16079        assert!(!ops.is_empty());
16080    }
16081
16082    #[test]
16083    fn t4071_histogram_compacted_ops_completes() {
16084        use crate::diff::WhitespaceMode;
16085        let old = "x\nx\nx\nx\n";
16086        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
16087        let ops = super::histogram_compacted_line_ops(old, new, false, WhitespaceMode::default());
16088        assert!(!ops.is_empty(), "expected diff ops");
16089    }
16090
16091    #[test]
16092    fn t4071_histogram_u0_body_completes() {
16093        use crate::diff::options::{DiffAlgorithm, DiffOptions};
16094        let old = "x\nx\nx\nx\n";
16095        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
16096        let opts = DiffOptions {
16097            context_lines: 0,
16098            algorithm: DiffAlgorithm::Histogram,
16099            indent_heuristic: false,
16100            ..DiffOptions::default()
16101        };
16102        let body = unified_hunk_body_for_diff_options(old, new, &opts);
16103        assert!(body.contains("@@"), "expected hunk header: {body}");
16104    }
16105
16106    #[test]
16107    fn t4071_minimal_u0_matches_git_hunk_headers() {
16108        use crate::diff::options::{DiffAlgorithm, DiffOptions};
16109        let old = "x\nx\nx\nx\n";
16110        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
16111        let opts = DiffOptions {
16112            context_lines: 0,
16113            algorithm: DiffAlgorithm::Minimal,
16114            indent_heuristic: false,
16115            ..DiffOptions::default()
16116        };
16117        let body = unified_hunk_body_for_diff_options(old, new, &opts);
16118        assert_eq!(
16119            body.matches("@@ ").count(),
16120            2,
16121            "git 2.55+ splits u0 minimal hunks at repeated anchors: {body}"
16122        );
16123    }
16124
16125    #[test]
16126    fn hunks_do_not_slide_onto_final_line_without_newline() {
16127        use crate::diff::options::{DiffAlgorithm, DiffOptions};
16128
16129        let old = "{\n  \"a\": 1\n}\n";
16130        let new = "{\n  \"a\": 2\n}\n{\n  \"b\": 2\n}";
16131        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";
16132        for algorithm in [
16133            DiffAlgorithm::Myers,
16134            DiffAlgorithm::Minimal,
16135            DiffAlgorithm::Patience,
16136            DiffAlgorithm::Histogram,
16137        ] {
16138            for indent_heuristic in [true, false] {
16139                let options = DiffOptions {
16140                    algorithm,
16141                    indent_heuristic,
16142                    ..DiffOptions::default()
16143                };
16144                assert_eq!(
16145                    unified_hunk_body_for_diff_options(old, new, &options),
16146                    expected,
16147                    "{algorithm:?}, indent heuristic {indent_heuristic}"
16148                );
16149            }
16150        }
16151    }
16152
16153    #[test]
16154    fn myers_patience_histogram_minimal_emit_unified_hunks() {
16155        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
16156            let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
16157                OLD,
16158                NEW,
16159                "a/old.txt",
16160                "b/new.txt",
16161                3,
16162                0,
16163                "a/",
16164                "b/",
16165                None,
16166                algo,
16167                false,
16168                false,
16169                true,
16170                false,
16171            );
16172            assert!(patch.contains("@@"), "algo {algo:?} should emit hunks");
16173            assert!(patch.contains("-beta") || patch.contains("-BETA"));
16174        }
16175    }
16176
16177    #[test]
16178    fn git_histogram_printer_and_anchored_diff_smoke() {
16179        let hist = unified_diff_histogram_hunks_only(OLD, NEW, 3, 0, false);
16180        assert!(!hist.is_empty());
16181        let anchors = vec!["beta".to_string()];
16182        let anchored = anchored_unified_diff(
16183            OLD,
16184            NEW,
16185            "old",
16186            "new",
16187            3,
16188            &anchors,
16189            Algorithm::Myers,
16190            false,
16191            true,
16192            false,
16193        );
16194        assert!(anchored.contains("@@"));
16195    }
16196
16197    #[test]
16198    fn count_changes_git_histogram_vs_similar() {
16199        let hist = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, true);
16200        let myers = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, false);
16201        assert!(hist.0 + hist.1 > 0);
16202        assert!(myers.0 + myers.1 > 0);
16203    }
16204}
16205
16206#[cfg(test)]
16207mod line_coverage_tests {
16208    use super::*;
16209    use crate::config::ConfigSet;
16210    use crate::index::StatMatchPolicy;
16211    use crate::index::{Index, IndexEntry, MODE_REGULAR};
16212    use crate::objects::ObjectKind;
16213    use crate::parallel::ThreadPolicy;
16214    use crate::write_tree::write_tree_from_index;
16215    use similar::Algorithm;
16216    use tempfile::TempDir;
16217
16218    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
16219        IndexEntry {
16220            ctime_sec: 0,
16221            ctime_nsec: 0,
16222            mtime_sec: 0,
16223            mtime_nsec: 0,
16224            dev: 0,
16225            ino: 0,
16226            mode,
16227            uid: 0,
16228            gid: 0,
16229            size: 0,
16230            oid,
16231            flags: path.len().min(0xfff) as u16,
16232            flags_extended: None,
16233            path: path.as_bytes().to_vec(),
16234            base_index_pos: 0,
16235        }
16236    }
16237
16238    fn write_tree(odb: &Odb, entries: &[(&str, ObjectId)]) -> ObjectId {
16239        let mut body = Vec::new();
16240        for (name, oid) in entries {
16241            body.extend_from_slice(format!("100644 {name}\0").as_bytes());
16242            body.extend_from_slice(oid.as_bytes());
16243        }
16244        odb.write(ObjectKind::Tree, &body).expect("tree")
16245    }
16246
16247    fn pair_of_trees_with_rename() -> (TempDir, Odb, ObjectId, ObjectId) {
16248        let temp = TempDir::new().expect("tempdir");
16249        let odb = Odb::new(temp.path());
16250        let old_blob = odb
16251            .write(ObjectKind::Blob, b"line one\nline two\n")
16252            .expect("old");
16253        let new_blob = odb
16254            .write(ObjectKind::Blob, b"line one\nline TWO\nline three\n")
16255            .expect("new");
16256        let old_tree = write_tree(&odb, &[("old.txt", old_blob)]);
16257        let new_tree = write_tree(&odb, &[("new.txt", new_blob), ("kept.txt", old_blob)]);
16258        (temp, odb, old_tree, new_tree)
16259    }
16260
16261    #[test]
16262    fn detect_copies_reports_source_modes_without_tree_entries() {
16263        let temp = TempDir::new().expect("tempdir");
16264        let odb = Odb::new(temp.path());
16265        let content = odb
16266            .write(ObjectKind::Blob, b"shared content\n")
16267            .expect("blob");
16268        let changed = odb.write(ObjectKind::Blob, b"changed\n").expect("blob");
16269        let old_tree = write_tree(&odb, &[("gone.txt", content), ("mod.txt", content)]);
16270        let new_tree = write_tree(
16271            &odb,
16272            &[
16273                ("copy.txt", content),
16274                ("mod.txt", changed),
16275                ("moved.txt", content),
16276            ],
16277        );
16278        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
16279        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
16280        let pairs: Vec<_> = result
16281            .iter()
16282            .filter(|e| matches!(e.status, DiffStatus::Renamed | DiffStatus::Copied))
16283            .map(|e| (e.old_mode, e.new_mode))
16284            .collect();
16285        assert_eq!(pairs.len(), 2, "{result:?}");
16286        assert!(
16287            pairs
16288                .iter()
16289                .all(|p| *p == (FileMode::REGULAR, FileMode::REGULAR)),
16290            "{pairs:?}"
16291        );
16292    }
16293
16294    #[test]
16295    fn exact_rename_detection_pairs_only_equal_blobs_among_many() -> Result<()> {
16296        let temp = TempDir::new().expect("tempdir");
16297        let odb = Odb::new(temp.path());
16298        const N: usize = 200;
16299        let shared = odb.write(ObjectKind::Blob, b"shared\n").expect("blob");
16300        let mut old_names = Vec::with_capacity(N + 1);
16301        let mut old_oids = Vec::with_capacity(N + 1);
16302        for i in 0..N {
16303            old_names.push(format!("old/{i:03}.txt"));
16304            old_oids.push(
16305                odb.write(ObjectKind::Blob, format!("old {i}\n").as_bytes())
16306                    .expect("blob"),
16307            );
16308        }
16309        old_names.push("shared-old.txt".to_owned());
16310        old_oids.push(shared);
16311        let old_pairs: Vec<(&str, ObjectId)> = old_names
16312            .iter()
16313            .zip(old_oids.iter().copied())
16314            .map(|(name, oid)| (name.as_str(), oid))
16315            .collect();
16316
16317        let mut new_names = Vec::with_capacity(N + 1);
16318        let mut new_oids = Vec::with_capacity(N + 1);
16319        for i in 0..N {
16320            new_names.push(format!("new/{i:03}.txt"));
16321            new_oids.push(
16322                odb.write(ObjectKind::Blob, format!("new {i}\n").as_bytes())
16323                    .expect("blob"),
16324            );
16325        }
16326        new_names.push("shared-new.txt".to_owned());
16327        new_oids.push(shared);
16328        let new_pairs: Vec<(&str, ObjectId)> = new_names
16329            .iter()
16330            .zip(new_oids.iter().copied())
16331            .map(|(name, oid)| (name.as_str(), oid))
16332            .collect();
16333
16334        let old_tree = write_tree(&odb, &old_pairs);
16335        let new_tree = write_tree(&odb, &new_pairs);
16336        let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
16337        assert_eq!(raw.len(), (N + 1) * 2);
16338        let config = ConfigSet::new();
16339        let policy = ThreadPolicy::default();
16340        let renamed = detect_renames_with_policy(
16341            &odb,
16342            None,
16343            raw,
16344            50,
16345            &config,
16346            &policy,
16347            None,
16348            DetectRenamesOptions::default(),
16349        )?;
16350        assert_eq!(
16351            renamed
16352                .entries
16353                .iter()
16354                .filter(|e| e.status == DiffStatus::Renamed)
16355                .count(),
16356            1
16357        );
16358        Ok(())
16359    }
16360
16361    #[test]
16362    fn pathspec_limited_diff_does_not_pair_outside_pathspec() -> Result<()> {
16363        let temp = TempDir::new().expect("tempdir");
16364        let odb = Odb::new(temp.path());
16365        let shared = odb.write(ObjectKind::Blob, b"payload\n").expect("blob");
16366        let mut old_names = Vec::new();
16367        let mut old_oids = Vec::new();
16368        for i in 0..50 {
16369            old_names.push(format!("bulk/old/{i:02}.txt"));
16370            old_oids.push(
16371                odb.write(ObjectKind::Blob, format!("bulk old {i}\n").as_bytes())
16372                    .expect("blob"),
16373            );
16374        }
16375        old_names.push("target.txt".to_owned());
16376        old_oids.push(shared);
16377        let old_pairs: Vec<(&str, ObjectId)> = old_names
16378            .iter()
16379            .zip(old_oids.iter().copied())
16380            .map(|(name, oid)| (name.as_str(), oid))
16381            .collect();
16382
16383        let mut new_names = Vec::new();
16384        let mut new_oids = Vec::new();
16385        for i in 0..50 {
16386            new_names.push(format!("bulk/new/{i:02}.txt"));
16387            new_oids.push(
16388                odb.write(ObjectKind::Blob, format!("bulk new {i}\n").as_bytes())
16389                    .expect("blob"),
16390            );
16391        }
16392        new_names.push("elsewhere.txt".to_owned());
16393        new_oids.push(shared);
16394        let new_pairs: Vec<(&str, ObjectId)> = new_names
16395            .iter()
16396            .zip(new_oids.iter().copied())
16397            .map(|(name, oid)| (name.as_str(), oid))
16398            .collect();
16399
16400        let old_tree = write_tree(&odb, &old_pairs);
16401        let new_tree = write_tree(&odb, &new_pairs);
16402        let config = ConfigSet::new();
16403        let policy = ThreadPolicy::default();
16404        let pathspecs = vec!["target.txt".to_owned()];
16405        let entries = diff_trees_with_renames_for_pathspecs_and_policy(
16406            &odb,
16407            Some(&old_tree),
16408            Some(&new_tree),
16409            "",
16410            &pathspecs,
16411            None,
16412            50,
16413            &config,
16414            &policy,
16415        )?;
16416        assert_eq!(entries.len(), 1);
16417        assert_eq!(entries[0].status, DiffStatus::Deleted);
16418        assert_eq!(entries[0].path(), "target.txt");
16419        Ok(())
16420    }
16421
16422    #[test]
16423    fn detect_copies_keeps_modifications_as_modifications() {
16424        // `git diff -C` reports `b` as modified even though its new content equals the
16425        // old content of the also-modified `a`: only created paths become copies.
16426        let temp = TempDir::new().expect("tempdir");
16427        let odb = Odb::new(temp.path());
16428        let one = odb.write(ObjectKind::Blob, b"1\n").expect("blob");
16429        let two = odb.write(ObjectKind::Blob, b"2\n").expect("blob");
16430        let old_tree = write_tree(&odb, &[("a", one), ("b", two)]);
16431        let new_tree = write_tree(&odb, &[("a", two), ("b", one)]);
16432        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
16433        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
16434        let statuses: Vec<_> = result.iter().map(|e| (e.path(), e.status)).collect();
16435        assert_eq!(
16436            statuses,
16437            [("a", DiffStatus::Modified), ("b", DiffStatus::Modified)]
16438        );
16439    }
16440
16441    #[test]
16442    fn diff_helper_and_format_sweep() {
16443        let cfg = ConfigSet::new();
16444        assert!(resolve_indent_heuristic(&cfg, false, false));
16445        assert!(!resolve_indent_heuristic(&cfg, false, true));
16446
16447        let old = "alpha beta\ngamma\n";
16448        let new = "alpha BETA\ngamma delta\n";
16449        let _ = normalize_ignore_space_change(old);
16450        let _ = normalize_ignore_space_change_line("- alpha  beta ");
16451        let _ = count_git_lines(old.as_bytes());
16452        let _ = diffcore_count_changes(old.as_bytes(), new.as_bytes());
16453        assert!(parse_diff_rename_score_token("50%").is_some());
16454        let _ = rewrite_merge_score(b"a", b"b");
16455        let _ = rewrite_dissimilarity_index_percent(b"abc", b"abd");
16456        assert_eq!(format_mode(0o100644), "100644");
16457
16458        let old_lines = ["a", "b", "c"];
16459        let new_lines = ["a", "b", "d"];
16460        let compact = diff_slice_ops_compacted(&old_lines, &new_lines, Algorithm::Myers, false);
16461        assert!(!compact.is_empty());
16462    }
16463
16464    #[test]
16465    fn tree_diff_rename_copy_postprocess_matrix() -> Result<()> {
16466        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
16467        let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
16468        assert!(!raw.is_empty());
16469        let with_renames = diff_trees_with_renames(&odb, Some(&old_tree), Some(&new_tree), "", 50)?;
16470        assert!(!with_renames.is_empty());
16471        let config = ConfigSet::new();
16472        let policy = ThreadPolicy::default();
16473        let detected = detect_renames_with_policy(
16474            &odb,
16475            None,
16476            raw.clone(),
16477            50,
16478            &config,
16479            &policy,
16480            None,
16481            DetectRenamesOptions::default(),
16482        )?
16483        .entries;
16484        let source_flat = flatten_tree_entries(&odb, &old_tree)?;
16485        let _ = detect_copies_with_policy(
16486            &odb,
16487            None,
16488            detected.clone(),
16489            50,
16490            true,
16491            &source_flat,
16492            &config,
16493            &policy,
16494        )?;
16495        let opts = DiffPostprocessOptions {
16496            break_score: Some(50),
16497            rename_threshold: 50,
16498            copy_threshold: Some(50),
16499            find_copies_harder: true,
16500            materialize_break_worktree_new_oid: false,
16501        };
16502        let processed = postprocess_diff_entries(
16503            &odb,
16504            raw,
16505            Some(&old_tree),
16506            None,
16507            None,
16508            &config,
16509            &policy,
16510            &opts,
16511            None,
16512        )?;
16513        assert!(!processed.is_empty());
16514        let _ = status_apply_rename_copy_detection(
16515            &odb,
16516            with_renames.clone(),
16517            50,
16518            true,
16519            Some(&old_tree),
16520            &config,
16521            &policy,
16522            None,
16523        )?;
16524        let flat = flatten_tree_entries(&odb, &new_tree)?;
16525        assert!(!flat.is_empty());
16526        let _ = diff_trees_show_tree_entries(&odb, Some(&old_tree), Some(&new_tree), "")?;
16527        Ok(())
16528    }
16529
16530    #[test]
16531    fn unified_diff_flag_matrix() {
16532        let old = "fn main() {\n    println!(\"hi\");\n}\n";
16533        let new = "fn main() {\n    println!(\"bye\");\n}\n";
16534        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
16535            for (ws, ws_all, fc, ig) in [(true, false, false, false), (false, true, true, true)] {
16536                let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
16537                    old,
16538                    new,
16539                    "a/main.rs",
16540                    "b/main.rs",
16541                    3,
16542                    0,
16543                    "a/",
16544                    "b/",
16545                    None,
16546                    algo,
16547                    ws,
16548                    ws_all,
16549                    fc,
16550                    ig,
16551                );
16552                assert!(patch.contains("@@"), "algo {algo:?}");
16553            }
16554        }
16555        let hist = unified_diff_histogram_ignore_with_prefix_and_funcname(
16556            old,
16557            new,
16558            "a/x",
16559            "b/x",
16560            3,
16561            0,
16562            "",
16563            "",
16564            None,
16565            false,
16566            false,
16567            false,
16568            |_old, _new| false,
16569        );
16570        assert!(!hist.is_empty());
16571        let raw = format_raw_abbrev(
16572            &DiffEntry {
16573                status: DiffStatus::Modified,
16574                old_path: Some("x".into()),
16575                new_path: Some("x".into()),
16576                old_mode: FileMode::REGULAR,
16577                new_mode: FileMode::REGULAR,
16578                old_oid: empty_blob_oid(),
16579                new_oid: empty_blob_oid(),
16580                score: Some(80),
16581            },
16582            7,
16583        );
16584        assert!(raw.contains('M') || raw.contains('x'));
16585    }
16586
16587    #[test]
16588    fn index_and_commit_diff_paths() -> Result<()> {
16589        let temp = TempDir::new().expect("tempdir");
16590        let odb = Odb::new(temp.path());
16591        let b1 = odb.write(ObjectKind::Blob, b"v1\n").expect("b1");
16592        let b2 = odb.write(ObjectKind::Blob, b"v2\n").expect("b2");
16593        let b3 = odb.write(ObjectKind::Blob, b"v3\n").expect("b3");
16594        let mut index = Index::new();
16595        index.add_or_replace(index_entry("file.txt", MODE_REGULAR, b2));
16596        let head = write_tree_from_index(&odb, &index, "").expect("tree");
16597        index.entries_mut()[0].oid = b1;
16598        let idx_diff = diff_index_to_tree(&odb, &index, Some(&head), false)?;
16599        assert!(!idx_diff.is_empty());
16600        let commit_body = format!(
16601            "tree {head}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
16602        );
16603        let c1 = odb
16604            .write(ObjectKind::Commit, commit_body.as_bytes())
16605            .expect("c1");
16606        let mut index2 = Index::new();
16607        index2.add_or_replace(index_entry("file.txt", MODE_REGULAR, b3));
16608        let head2 = write_tree_from_index(&odb, &index2, "").expect("head2");
16609        let c2_body = format!(
16610            "tree {head2}\nparent {c1}\nauthor T <t@e.com> 2 +0000\ncommitter T <t@e.com> 2 +0000\n\nm2\n"
16611        );
16612        let c2 = odb
16613            .write(ObjectKind::Commit, c2_body.as_bytes())
16614            .expect("c2");
16615        let commit_diff = diff_commits_with_renames(&odb, &c1, &c2, 50)?;
16616        assert!(!commit_diff.is_empty());
16617        let _ = tree_oid_of_commit(&odb, &c2)?;
16618        let mtime = index_mtime_for_diff(&index, Some((1, 0)));
16619        let _ = entry_is_racy(&index.entries()[0], mtime);
16620        let _ = entry_is_racy_with_policy(&index.entries()[0], mtime, StatMatchPolicy::default());
16621        Ok(())
16622    }
16623
16624    #[test]
16625    fn tree_oid_of_commit_reports_invalid_ref_for_non_commits() {
16626        let temp = TempDir::new().expect("tempdir");
16627        let odb = Odb::new(temp.path());
16628        let tree = odb.write(ObjectKind::Tree, &[]).expect("tree");
16629        let body = format!(
16630            "tree {tree}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
16631        );
16632        let commit = odb
16633            .write(ObjectKind::Commit, body.as_bytes())
16634            .expect("commit");
16635        let blob = odb.write(ObjectKind::Blob, b"x\n").expect("blob");
16636
16637        assert_eq!(tree_oid_of_commit(&odb, &commit).expect("tree"), tree);
16638        assert!(matches!(
16639            tree_oid_of_commit(&odb, &blob),
16640            Err(Error::InvalidRef(_))
16641        ));
16642        assert!(matches!(
16643            tree_oid_of_commit(&odb, &tree),
16644            Err(Error::InvalidRef(_))
16645        ));
16646    }
16647
16648    #[test]
16649    fn worktree_index_diff_and_rename_score() -> Result<()> {
16650        let temp = TempDir::new().expect("tempdir");
16651        let wt = temp.path();
16652        let git_dir = wt.join(".git");
16653        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
16654        let objects_dir = git_dir.join("objects");
16655        let odb = Odb::new(&objects_dir);
16656        let content = b"hello world\n";
16657        std::fs::write(wt.join("tracked.txt"), content).expect("write");
16658        let oid = odb.write(ObjectKind::Blob, content).expect("blob");
16659        let mut index = Index::new();
16660        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, oid));
16661        let head = write_tree_from_index(&odb, &index, "").expect("tree");
16662        std::fs::write(wt.join("tracked.txt"), b"hello WORLD\n").expect("mutate");
16663        let mut index_mut = index.clone();
16664        let diff = diff_index_to_worktree(&odb, &mut index_mut, wt, false, false)?;
16665        assert!(!diff.is_empty());
16666        let _ = diff_tree_to_worktree(&odb, Some(&head), wt, &index)?;
16667        assert_eq!(rename_similarity_score(b"a", b"a\n"), 0);
16668        assert_eq!(rename_similarity_score(b"a\n", b"a"), 0);
16669        let score = rename_similarity_score(b"line one\nline two\n", b"line one\nline TWO\n");
16670        assert!(score >= 50);
16671        let _ = detect_renames(&odb, Some(wt), diff, 50);
16672        Ok(())
16673    }
16674
16675    #[test]
16676    fn extended_diff_helpers_sweep() -> Result<()> {
16677        use crate::crlf::{ConversionConfig, FileAttrs};
16678        let temp = TempDir::new().expect("tempdir");
16679        let wt = temp.path();
16680        let git_dir = wt.join(".git");
16681        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
16682        let objects_dir = git_dir.join("objects");
16683        let odb = Odb::new(&objects_dir);
16684        let b_old = odb.write(ObjectKind::Blob, b"alpha\nbeta\n").expect("old");
16685        let b_new = odb
16686            .write(ObjectKind::Blob, b"alpha\nBETA\ngamma\n")
16687            .expect("new");
16688        let old_tree = write_tree(&odb, &[("a.txt", b_old), ("b.txt", b_old)]);
16689        let new_tree = write_tree(
16690            &odb,
16691            &[("a.txt", b_new), ("b.txt", b_new), ("c.txt", b_new)],
16692        );
16693        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
16694        assert!(entries.len() >= 2);
16695
16696        let cfg = ConfigSet::new();
16697        assert!(indent_heuristic_from_config(&cfg));
16698        let _ = unified_diff_histogram_hunks_only("a\nb\n", "a\nB\n", 3, 0, false);
16699        let _ = unified_diff_histogram_with_prefix_and_funcname_indent(
16700            "x\n", "y\n", "a/f", "b/f", 3, 0, "", "", None, false, false,
16701        );
16702        let _ = count_changes_with_algorithm("a\n", "b\n", Algorithm::Myers, false);
16703        let _ = should_break_rewrite_pair(b"short", b"totally different body", 50);
16704        let _ = head_path_states(&odb, Some(&new_tree))?;
16705        std::fs::write(wt.join("tracked.txt"), b"hello\n").expect("wt");
16706        let abs = wt.join("tracked.txt");
16707        let meta = std::fs::metadata(&abs).expect("meta");
16708        let mut index = Index::new();
16709        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, b_old));
16710        let _ = stat_matches_with_policy(&index.entries()[0], &meta, StatMatchPolicy::default());
16711        let _ = worktree_differs_from_index_entry(
16712            &odb,
16713            &git_dir,
16714            wt,
16715            &index.entries()[0],
16716            false,
16717            None,
16718            None,
16719        )?;
16720        let conv = ConversionConfig::from_config(&cfg);
16721        let attrs = FileAttrs::default();
16722        let params = WorktreeAddRefreshParams {
16723            odb: &odb,
16724            ie: &index.entries()[0],
16725            meta: &meta,
16726            abs_path: &abs,
16727            rel_path: "tracked.txt",
16728            conv: &conv,
16729            file_attrs: &attrs,
16730            index_mtime: None,
16731            staged_mode: MODE_REGULAR,
16732            filter_process: None,
16733        };
16734        let _ = classify_worktree_entry_for_add(&params)?;
16735        let _ = hash_worktree_file(
16736            &odb,
16737            &abs,
16738            &meta,
16739            &conv,
16740            &attrs,
16741            "tracked.txt",
16742            Some(&index.entries()[0]),
16743            None,
16744        )?;
16745        let _ = materialize_worktree_blob(
16746            &odb,
16747            &abs,
16748            &meta,
16749            &conv,
16750            &attrs,
16751            "tracked.txt",
16752            Some(&index.entries()[0]),
16753            None,
16754        )?;
16755        let _ = read_worktree_bytes_for_mode(&abs, MODE_REGULAR);
16756        let _ = mode_from_metadata(&meta);
16757        let _ = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, Some(&cfg), None);
16758        let _ = refresh_index_stat_content_verified(
16759            &odb,
16760            &git_dir,
16761            &mut index,
16762            wt,
16763            None,
16764            Some(&cfg),
16765            None,
16766        )?;
16767        let _ = refresh_index_stat_content_verified_with_rules(
16768            &odb,
16769            &git_dir,
16770            &mut index,
16771            wt,
16772            None,
16773            Some(&cfg),
16774            None,
16775            None,
16776        )?;
16777        let _ = path_checkout_skip_blob_write_when_up_to_date_with_config(
16778            &odb,
16779            &git_dir,
16780            wt,
16781            &index,
16782            &index.entries()[0],
16783            &b_old,
16784            MODE_REGULAR,
16785            "tracked.txt",
16786            None,
16787            None,
16788        )?;
16789        let patch = anchored_unified_diff(
16790            "a\nb\n",
16791            "a\nB\n",
16792            "f",
16793            "f",
16794            3,
16795            &[String::from("b")],
16796            Algorithm::Myers,
16797            false,
16798            true,
16799            false,
16800        );
16801        assert!(patch.contains("@@"));
16802        let _ = annotate_unified_diff_no_newline_at_eof("@@\n+a\n\\ No newline\n", "a", "a\n");
16803        Ok(())
16804    }
16805
16806    #[test]
16807    fn unmerged_only_tree_worktree_diff_and_func_heuristics() -> Result<()> {
16808        use similar::DiffOp;
16809        assert!(inserted_block_starts_with_c_like_function_definition(
16810            "static int foo(int bar) {"
16811        ));
16812        assert!(!inserted_block_starts_with_c_like_function_definition(
16813            "printf(hi);"
16814        ));
16815        let ops = vec![
16816            DiffOp::Equal {
16817                old_index: 0,
16818                new_index: 0,
16819                len: 4,
16820            },
16821            DiffOp::Insert {
16822                old_index: 4,
16823                new_index: 4,
16824                new_len: 2,
16825            },
16826        ];
16827        let new_lines = ["a\n", "b\n", "c\n", "d\n", "void added() {\n", "}\n"];
16828        assert!(append_with_whole_function_added(
16829            &ops, 4, 6, &new_lines, None
16830        ));
16831        let _ = hunk_old_change_end_exclusive(&ops);
16832
16833        let temp = TempDir::new().expect("tempdir");
16834        let wt = temp.path();
16835        let git_dir = wt.join(".git");
16836        let objects_dir = git_dir.join("objects");
16837        std::fs::create_dir_all(&objects_dir).expect("objects");
16838        let odb = Odb::new(&objects_dir);
16839        let tree_blob = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
16840        std::fs::write(wt.join("conflict.txt"), b"worktree\n").expect("wt");
16841        let tree = write_tree(&odb, &[("conflict.txt", tree_blob)]);
16842        let mut index = Index::new();
16843        let mut stage1 = index_entry("conflict.txt", MODE_REGULAR, tree_blob);
16844        stage1.flags = (stage1.flags & 0x0FFF) | (1 << 12);
16845        index.entries_mut().push(stage1);
16846        let cfg = ConfigSet::new();
16847        let diff = diff_tree_to_worktree_with_git_dir(
16848            &odb,
16849            Some(&tree),
16850            wt,
16851            &git_dir,
16852            &index,
16853            Some(&cfg),
16854        )?;
16855        assert!(!diff.is_empty());
16856        Ok(())
16857    }
16858
16859    #[test]
16860    fn high_volume_unified_diff_and_postprocess() -> Result<()> {
16861        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
16862        let config = ConfigSet::new();
16863        let policy = ThreadPolicy::default();
16864        for i in 0..600u32 {
16865            let old = format!("seed {i}\nalpha\n");
16866            let new = format!("seed {i}\nalpha\nvoid fn_{i}(int arg) {{\n  return arg;\n}}\n");
16867            let _ = unified_diff_with_prefix_and_funcname_and_algorithm(
16868                &old,
16869                &new,
16870                "a/f.c",
16871                "b/f.c",
16872                3,
16873                0,
16874                "a/",
16875                "b/",
16876                None,
16877                Algorithm::Myers,
16878                i % 2 == 0,
16879                i % 3 == 0,
16880                i % 5 == 0,
16881                i % 7 == 0,
16882            );
16883            let threshold = 30 + (i % 40);
16884            let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
16885            let opts = DiffPostprocessOptions {
16886                rename_threshold: threshold,
16887                copy_threshold: Some(threshold),
16888                find_copies_harder: i % 2 == 0,
16889                break_score: Some(threshold as u64),
16890                materialize_break_worktree_new_oid: i % 4 == 0,
16891            };
16892            let _ = postprocess_diff_entries(
16893                &odb,
16894                raw,
16895                Some(&old_tree),
16896                None,
16897                None,
16898                &config,
16899                &policy,
16900                &opts,
16901                None,
16902            )?;
16903        }
16904        Ok(())
16905    }
16906}
16907
16908#[cfg(test)]
16909mod refresh_checks_tests {
16910    use super::{DistinctFileAttrs, RefreshChecks, RefreshContent};
16911    use crate::crlf::{EolAttr, FileAttrs, TextAttr};
16912    use crate::objects::ObjectId;
16913
16914    fn attrs_with_eol(eol: EolAttr) -> FileAttrs {
16915        FileAttrs {
16916            eol,
16917            ..FileAttrs::default()
16918        }
16919    }
16920
16921    #[test]
16922    fn equal_attributes_share_one_slot() {
16923        let mut attrs = DistinctFileAttrs::default();
16924        assert_eq!(attrs.intern(FileAttrs::default()), 0);
16925        assert_eq!(attrs.intern(attrs_with_eol(EolAttr::Crlf)), 1);
16926        assert_eq!(attrs.intern(FileAttrs::default()), 0);
16927        assert_eq!(attrs.intern(attrs_with_eol(EolAttr::Crlf)), 1);
16928        let binary = FileAttrs {
16929            text: TextAttr::Unset,
16930            ..FileAttrs::default()
16931        };
16932        assert_eq!(attrs.intern(binary.clone()), 2);
16933        assert_eq!(attrs.intern(binary), 2);
16934        assert_eq!(attrs.0.len(), 3);
16935        assert_eq!(attrs.0[1], attrs_with_eol(EolAttr::Crlf));
16936    }
16937
16938    #[test]
16939    fn sets_past_the_search_limit_are_stored_again() {
16940        let mut attrs = DistinctFileAttrs::default();
16941        let first = attrs.intern(FileAttrs::default());
16942        for n in 0..DistinctFileAttrs::SEARCH_LIMIT {
16943            let distinct = FileAttrs {
16944                whitespace: Some(format!("tabwidth={}", n + 1)),
16945                ..FileAttrs::default()
16946            };
16947            assert_eq!(attrs.intern(distinct), n + 1);
16948        }
16949        let again = attrs.intern(FileAttrs::default());
16950        assert_ne!(again, first, "only the most recent sets are searched");
16951        assert_eq!(attrs.0[again], attrs.0[first]);
16952        assert_eq!(attrs.intern(FileAttrs::default()), again);
16953    }
16954
16955    #[test]
16956    fn empty_checks_answer_none_for_every_entry() {
16957        let checks = RefreshChecks::default();
16958        assert!(checks.0.is_empty());
16959        assert!(checks.get(0).is_none());
16960        assert!(checks.get(9_999).is_none());
16961    }
16962
16963    #[test]
16964    fn filled_checks_answer_by_entry_position() {
16965        let oid = ObjectId::from_bytes(&[7u8; 20]).unwrap();
16966        let other = ObjectId::from_bytes(&[8u8; 20]).unwrap();
16967        let checks = RefreshChecks(vec![None, Some(RefreshContent::Hashed(oid)), None]);
16968        assert!(checks.get(0).is_none());
16969        assert!(RefreshContent::matches(checks.get(1), &oid));
16970        assert!(RefreshContent::differs(checks.get(1), &other));
16971        assert!(
16972            checks.get(3).is_none(),
16973            "positions past the end have no check"
16974        );
16975    }
16976}