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};
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::userdiff::FuncnameMatcher;
46
47use super::rename_lines::RenameLineTable;
48use super::unified_emit::{
49    emit_unified_body_from_edits, line_ending_only_edits, EditSpan, LineEnding, LineSide,
50    UnifiedEmitOptions,
51};
52
53/// Comparison keys for every line of `side`, which must be [`LineSide::from_text`] of `text`.
54///
55/// Without whitespace normalization a key is the raw line including its `\n` (the same bytes as
56/// `line_diff_token`), borrowed from `text`. Under `-w`/`-b`/`--ignore-space-at-eol` the keys
57/// are normalized line bodies, kept in `normalized` so the returned slices can borrow them.
58fn line_compare_keys<'a>(
59    text: &'a str,
60    side: &LineSide<'a>,
61    ws: super::WhitespaceMode,
62    normalized: &'a mut Vec<Cow<'a, str>>,
63) -> Vec<&'a str> {
64    if ws.normalizes_line_compare() {
65        // Git -w/-b compare normalized bodies only; EOF newline is whitespace.
66        normalized.extend(
67            side.lines
68                .iter()
69                .map(|line| ws.normalize_line_for_compare(line.body)),
70        );
71        return normalized.iter().map(Cow::as_ref).collect();
72    }
73    // Each body is a subslice of `text` that stops just before its `\n`, so the key is the body
74    // widened by that one byte.
75    let base = text.as_ptr() as usize;
76    side.lines
77        .iter()
78        .map(|line| {
79            let start = line.body.as_ptr() as usize - base;
80            let end = start + line.body.len() + usize::from(line.ending != LineEnding::Missing);
81            &text[start..end]
82        })
83        .collect()
84}
85
86#[path = "../structured_diff.rs"]
87pub mod structured;
88
89pub(crate) fn imara_unified_hunk_slices(body: &str) -> Vec<&str> {
90    let mut starts: Vec<usize> = Vec::new();
91    if body.starts_with("@@") {
92        starts.push(0);
93    }
94    for (idx, _) in body.match_indices("\n@@ ") {
95        starts.push(idx + 1);
96    }
97    starts.push(body.len());
98    starts.windows(2).map(|w| &body[w[0]..w[1]]).collect()
99}
100
101/// Histogram line alignment (Git `xhistogram.c`), then `xdl_change_compact` and indent heuristic.
102pub(crate) fn histogram_compacted_line_ops(
103    old_content: &str,
104    new_content: &str,
105    indent_heuristic: bool,
106    ws: super::WhitespaceMode,
107) -> Vec<similar::DiffOp> {
108    let before = LineSide::from_text(old_content);
109    let after = LineSide::from_text(new_content);
110    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
111    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
112    let old_len = old_lines.len();
113    let new_len = new_lines.len();
114    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
115    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
116    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
117
118    let (changed1, changed2) = git_xdiff::histogram_changed_flags(&old_compare, &new_compare);
119
120    if !changed1.iter().any(|c| *c) && !changed2.iter().any(|c| *c) {
121        return Vec::new();
122    }
123    let raw = changed_flags_to_ops(&changed1, &changed2, old_len, new_len);
124    diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
125        &raw,
126        diff_indent_heuristic::CompactSide {
127            lines: &old_lines,
128            keys: &old_compare,
129            compare_ids: None,
130        },
131        diff_indent_heuristic::CompactSide {
132            lines: &new_lines,
133            keys: &new_compare,
134            compare_ids: None,
135        },
136        indent_heuristic,
137        ws,
138        true,
139    )
140}
141
142pub(crate) fn histogram_unified_body_raw(
143    old_content: &str,
144    new_content: &str,
145    context_lines: usize,
146    inter_hunk_context: usize,
147    indent_heuristic: bool,
148    ws: super::WhitespaceMode,
149) -> String {
150    let before_side = LineSide::from_text(old_content);
151    let after_side = LineSide::from_text(new_content);
152    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
153    let mut edits = diff_ops_to_edit_spans(&ops);
154    if edits.is_empty() {
155        if old_content != new_content && !ws.normalizes_line_compare() {
156            edits = line_ending_only_edits(&before_side, &after_side);
157        }
158        if edits.is_empty() {
159            return String::new();
160        }
161    }
162    emit_unified_body_from_edits(
163        &before_side,
164        &after_side,
165        &edits,
166        UnifiedEmitOptions {
167            context_lines,
168            inter_hunk_context,
169            function_context: false,
170        },
171        None,
172    )
173}
174
175/// Build the unified-diff body (no `---`/`+++` header) using Git's histogram
176/// algorithm, but applying `--ignore-blank-lines` / `-I` semantics through a
177/// Git-compatible implementation of the xdiff change-record machinery
178/// (`xdl_mark_ignorable_lines` + `xdl_get_hunk` + `xdl_emit_diff`).
179///
180/// `is_ignorable_change` is called with the slices of removed and added lines
181/// for one change record (each item is the line text without its trailing
182/// newline); it returns `true` when the whole change is ignorable (Git's
183/// `xch->ignore`). Ignorable changes that are far from substantive changes are
184/// dropped, and hunk boundaries are computed with Git's `max_ignorable`
185/// distance rules so the line numbers and surviving context match Git exactly.
186///
187/// Returns the body only; an empty string means no substantive change survives.
188/// Optional `--function-context` (`-W`) hunk expansion is applied *after*
189/// ignorable change-record pruning so the boundaries/funcname header match Git.
190#[allow(clippy::too_many_arguments)]
191fn diff_ops_to_ignorable_edit_spans<F>(
192    ops: &[similar::DiffOp],
193    old_lines: &[&str],
194    new_lines: &[&str],
195    is_ignorable_change: F,
196) -> Vec<EditSpan>
197where
198    F: Fn(&[&str], &[&str]) -> bool,
199{
200    use similar::DiffOp;
201    let mut out = Vec::new();
202    for op in ops {
203        match *op {
204            DiffOp::Equal { .. } => {}
205            DiffOp::Delete {
206                old_index,
207                old_len,
208                new_index,
209            } => {
210                let removed = &old_lines[old_index..old_index + old_len];
211                out.push(EditSpan {
212                    before_start: old_index as i64,
213                    before_len: old_len as i64,
214                    after_start: new_index as i64,
215                    after_len: 0,
216                    skip_in_output: is_ignorable_change(removed, &[]),
217                });
218            }
219            DiffOp::Insert {
220                old_index,
221                new_index,
222                new_len,
223            } => {
224                let added = &new_lines[new_index..new_index + new_len];
225                out.push(EditSpan {
226                    before_start: old_index as i64,
227                    before_len: 0,
228                    after_start: new_index as i64,
229                    after_len: new_len as i64,
230                    skip_in_output: is_ignorable_change(&[], added),
231                });
232            }
233            DiffOp::Replace {
234                old_index,
235                old_len,
236                new_index,
237                new_len,
238            } => {
239                let removed = &old_lines[old_index..old_index + old_len];
240                let added = &new_lines[new_index..new_index + new_len];
241                out.push(EditSpan {
242                    before_start: old_index as i64,
243                    before_len: old_len as i64,
244                    after_start: new_index as i64,
245                    after_len: new_len as i64,
246                    skip_in_output: is_ignorable_change(removed, added),
247                });
248            }
249        }
250    }
251    out
252}
253
254#[allow(clippy::too_many_arguments)]
255pub(crate) fn histogram_unified_body_ignore_fc<F>(
256    old_content: &str,
257    new_content: &str,
258    context_lines: usize,
259    inter_hunk_context: usize,
260    function_context: bool,
261    funcname_matcher: Option<&FuncnameMatcher>,
262    indent_heuristic: bool,
263    ws: super::WhitespaceMode,
264    is_ignorable_change: F,
265) -> String
266where
267    F: Fn(&[&str], &[&str]) -> bool,
268{
269    let before = LineSide::from_text(old_content);
270    let after = LineSide::from_text(new_content);
271    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
272    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
273    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
274    if ops.is_empty() {
275        return String::new();
276    }
277    let edits = diff_ops_to_ignorable_edit_spans(&ops, &old_lines, &new_lines, is_ignorable_change);
278    emit_unified_body_from_edits(
279        &before,
280        &after,
281        &edits,
282        UnifiedEmitOptions {
283            context_lines,
284            inter_hunk_context,
285            function_context,
286        },
287        funcname_matcher,
288    )
289}
290
291/// Line bodies of `side`, as the change compaction reads them.
292fn line_bodies<'a>(side: &LineSide<'a>) -> Vec<&'a str> {
293    side.lines.iter().map(|l| l.body).collect()
294}
295
296/// Count insertions and deletions from compacted line diff ops (patch/stat alignment).
297#[must_use]
298fn count_diff_ops(ops: &[similar::DiffOp]) -> (usize, usize) {
299    use similar::DiffOp;
300    let mut insertions = 0usize;
301    let mut deletions = 0usize;
302    for op in ops {
303        match *op {
304            DiffOp::Equal { .. } => {}
305            DiffOp::Delete { old_len, .. } => deletions += old_len,
306            DiffOp::Insert { new_len, .. } => insertions += new_len,
307            DiffOp::Replace {
308                old_len, new_len, ..
309            } => {
310                deletions += old_len;
311                insertions += new_len;
312            }
313        }
314    }
315    (insertions, deletions)
316}
317
318/// Line alignment ops shared by unified patches, `--numstat`, and `-G`.
319pub(crate) fn compacted_line_ops_for_options(
320    old_content: &str,
321    new_content: &str,
322    options: &crate::diff::options::DiffOptions,
323) -> Vec<similar::DiffOp> {
324    use crate::diff::options::DiffAlgorithm;
325
326    let ws = options.whitespace;
327    match options.algorithm {
328        DiffAlgorithm::Histogram => {
329            histogram_compacted_line_ops(old_content, new_content, options.indent_heuristic, ws)
330        }
331        DiffAlgorithm::Patience => {
332            let before = LineSide::from_text(old_content);
333            let after = LineSide::from_text(new_content);
334            if before.is_empty() && after.is_empty() {
335                return Vec::new();
336            }
337            let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
338            let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
339            let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
340            let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
341            let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
342            let (changed1, changed2) =
343                git_xdiff::patience_changed_flags(&old_compare, &new_compare);
344            let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
345            diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
346                &raw,
347                diff_indent_heuristic::CompactSide {
348                    lines: &old_refs,
349                    keys: &old_compare,
350                    compare_ids: None,
351                },
352                diff_indent_heuristic::CompactSide {
353                    lines: &new_refs,
354                    keys: &new_compare,
355                    compare_ids: None,
356                },
357                options.indent_heuristic,
358                ws,
359                false,
360            )
361        }
362        DiffAlgorithm::Myers | DiffAlgorithm::Minimal => {
363            let before = LineSide::from_text(old_content);
364            let after = LineSide::from_text(new_content);
365            if before.is_empty() && after.is_empty() {
366                return Vec::new();
367            }
368            let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
369            let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
370            let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
371            git_xdiff_compacted_line_ops(
372                &old_compare,
373                &new_compare,
374                &before,
375                &after,
376                options.indent_heuristic,
377                options.algorithm == DiffAlgorithm::Minimal,
378            )
379        }
380    }
381}
382
383/// Count changed lines using the same alignment as structured patches and `--numstat`.
384#[must_use]
385pub(crate) fn count_line_changes_with_options(
386    old_content: &str,
387    new_content: &str,
388    options: &crate::diff::options::DiffOptions,
389) -> (usize, usize) {
390    let ws = options.whitespace;
391    let ops = compacted_line_ops_for_options(old_content, new_content, options);
392    let (mut insertions, mut deletions) = count_diff_ops(&ops);
393    if insertions == 0
394        && deletions == 0
395        && old_content != new_content
396        && !ws.normalizes_line_compare()
397    {
398        let before = LineSide::from_text(old_content);
399        let after = LineSide::from_text(new_content);
400        for edit in line_ending_only_edits(&before, &after) {
401            insertions += edit.after_len as usize;
402            deletions += edit.before_len as usize;
403        }
404    }
405    (insertions, deletions)
406}
407
408/// Myers changed flags (via [`git_xdiff::changed_flags_with_shared`]) plus interned compare keys.
409type XdiffTrimmedIds = ((Vec<bool>, Vec<bool>), Vec<u32>, Vec<u32>);
410
411fn xdiff_changed_flags_trimmed_ids(
412    old_compare: &[&str],
413    new_compare: &[&str],
414    need_min: bool,
415) -> XdiffTrimmedIds {
416    use crate::line_interner::LineInterner;
417
418    let nrec1 = old_compare.len();
419    let nrec2 = new_compare.len();
420    if nrec1 == 0 && nrec2 == 0 {
421        return ((Vec::new(), Vec::new()), Vec::new(), Vec::new());
422    }
423    let mut interner = LineInterner::with_capacity(nrec1 + nrec2);
424    let ids1_full = interner.intern_all(old_compare);
425    let ids2_full = interner.intern_all(new_compare);
426
427    let lim = nrec1.min(nrec2);
428    let mut head = 0usize;
429    while head < lim && ids1_full[head] == ids2_full[head] {
430        head += 1;
431    }
432    let mut tail = 0usize;
433    let rem = lim - head;
434    while tail < rem && ids1_full[nrec1 - 1 - tail] == ids2_full[nrec2 - 1 - tail] {
435        tail += 1;
436    }
437    let shared_records = head + tail;
438    let (full1, full2) = if shared_records == 0 {
439        git_xdiff::changed_flags(&ids1_full, &ids2_full, need_min)
440    } else {
441        let cut_ids1 = &ids1_full[head..nrec1 - tail];
442        let cut_ids2 = &ids2_full[head..nrec2 - tail];
443        let nclasses = ids1_full
444            .iter()
445            .chain(&ids2_full)
446            .max()
447            .map_or(0, |&max| max as usize + 1);
448        let mut shared_counts = vec![0u32; nclasses];
449        for &id in &ids1_full[..head] {
450            shared_counts[id as usize] += 1;
451        }
452        for &id in &ids1_full[nrec1 - tail..] {
453            shared_counts[id as usize] += 1;
454        }
455        let (mid1, mid2) = git_xdiff::changed_flags_with_shared(
456            cut_ids1,
457            cut_ids2,
458            &shared_counts,
459            shared_records,
460            need_min,
461        );
462        let mut full1 = vec![false; nrec1];
463        let mut full2 = vec![false; nrec2];
464        if !mid1.is_empty() {
465            full1[head..nrec1 - tail].copy_from_slice(&mid1);
466        }
467        if !mid2.is_empty() {
468            full2[head..nrec2 - tail].copy_from_slice(&mid2);
469        }
470        (full1, full2)
471    };
472    ((full1, full2), ids1_full, ids2_full)
473}
474
475pub(crate) fn git_xdiff_compacted_line_ops(
476    old_compare: &[&str],
477    new_compare: &[&str],
478    before: &LineSide<'_>,
479    after: &LineSide<'_>,
480    indent_heuristic: bool,
481    need_min: bool,
482) -> Vec<similar::DiffOp> {
483    let ((changed1, changed2), old_key_ids, new_key_ids) =
484        xdiff_changed_flags_trimmed_ids(old_compare, new_compare, need_min);
485    let old_lines = line_bodies(before);
486    let new_lines = line_bodies(after);
487    diff_indent_heuristic::apply_change_compact_to_masks(
488        changed1,
489        changed2,
490        diff_indent_heuristic::CompactSide {
491            lines: &old_lines,
492            keys: old_compare,
493            compare_ids: Some(&old_key_ids),
494        },
495        diff_indent_heuristic::CompactSide {
496            lines: &new_lines,
497            keys: new_compare,
498            compare_ids: Some(&new_key_ids),
499        },
500        indent_heuristic,
501    )
502}
503
504fn diff_ops_to_edit_spans(ops: &[similar::DiffOp]) -> Vec<EditSpan> {
505    use similar::DiffOp;
506    let mut out = Vec::new();
507    for op in ops {
508        match *op {
509            DiffOp::Equal { .. } => {}
510            DiffOp::Delete {
511                old_index,
512                old_len,
513                new_index,
514            } => out.push(EditSpan {
515                before_start: old_index as i64,
516                before_len: old_len as i64,
517                after_start: new_index as i64,
518                after_len: 0,
519                skip_in_output: false,
520            }),
521            DiffOp::Insert {
522                old_index,
523                new_index,
524                new_len,
525            } => out.push(EditSpan {
526                before_start: old_index as i64,
527                before_len: 0,
528                after_start: new_index as i64,
529                after_len: new_len as i64,
530                skip_in_output: false,
531            }),
532            DiffOp::Replace {
533                old_index,
534                old_len,
535                new_index,
536                new_len,
537            } => out.push(EditSpan {
538                before_start: old_index as i64,
539                before_len: old_len as i64,
540                after_start: new_index as i64,
541                after_len: new_len as i64,
542                skip_in_output: false,
543            }),
544        }
545    }
546    out
547}
548
549/// Unified hunk body using Git's xdiff engine (no `---` / `+++`).
550fn git_xdiff_unified_body_raw(
551    old_content: &str,
552    new_content: &str,
553    context_lines: usize,
554    inter_hunk_context: usize,
555    indent_heuristic: bool,
556    need_min: bool,
557    ws: super::WhitespaceMode,
558) -> String {
559    git_xdiff_unified_body_raw_with_ignorable(
560        old_content,
561        new_content,
562        context_lines,
563        inter_hunk_context,
564        indent_heuristic,
565        need_min,
566        ws,
567        None::<fn(&[&str], &[&str]) -> bool>,
568    )
569}
570
571#[allow(clippy::too_many_arguments)]
572fn git_xdiff_unified_body_raw_with_ignorable<F>(
573    old_content: &str,
574    new_content: &str,
575    context_lines: usize,
576    inter_hunk_context: usize,
577    indent_heuristic: bool,
578    need_min: bool,
579    ws: super::WhitespaceMode,
580    is_ignorable_change: Option<F>,
581) -> String
582where
583    F: Fn(&[&str], &[&str]) -> bool,
584{
585    let before = LineSide::from_text(old_content);
586    let after = LineSide::from_text(new_content);
587    if before.is_empty() && after.is_empty() {
588        return String::new();
589    }
590    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
591    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
592    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
593    let ops = git_xdiff_compacted_line_ops(
594        &old_compare,
595        &new_compare,
596        &before,
597        &after,
598        indent_heuristic,
599        need_min,
600    );
601    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
602        diff_ops_to_ignorable_edit_spans(
603            &ops,
604            &line_bodies(&before),
605            &line_bodies(&after),
606            is_ignorable,
607        )
608    } else {
609        diff_ops_to_edit_spans(&ops)
610    };
611    if edits.is_empty() {
612        if old_content != new_content && !ws.normalizes_line_compare() {
613            edits = line_ending_only_edits(&before, &after);
614        }
615        if edits.is_empty() {
616            return String::new();
617        }
618    }
619    emit_unified_body_from_edits(
620        &before,
621        &after,
622        &edits,
623        UnifiedEmitOptions {
624            context_lines,
625            inter_hunk_context,
626            function_context: false,
627        },
628        None,
629    )
630}
631
632#[allow(clippy::too_many_arguments)]
633fn git_patience_unified_body_raw_with_ignorable<F>(
634    old_content: &str,
635    new_content: &str,
636    context_lines: usize,
637    inter_hunk_context: usize,
638    indent_heuristic: bool,
639    ws: super::WhitespaceMode,
640    is_ignorable_change: Option<F>,
641) -> String
642where
643    F: Fn(&[&str], &[&str]) -> bool,
644{
645    let before = LineSide::from_text(old_content);
646    let after = LineSide::from_text(new_content);
647    if before.is_empty() && after.is_empty() {
648        return String::new();
649    }
650    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
651    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
652    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
653    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
654    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
655    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
656    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
657    let ops = diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
658        &raw,
659        diff_indent_heuristic::CompactSide {
660            lines: &old_refs,
661            keys: &old_compare,
662            compare_ids: None,
663        },
664        diff_indent_heuristic::CompactSide {
665            lines: &new_refs,
666            keys: &new_compare,
667            compare_ids: None,
668        },
669        indent_heuristic,
670        ws,
671        false,
672    );
673    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
674        diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
675    } else {
676        diff_ops_to_edit_spans(&ops)
677    };
678    if edits.is_empty() {
679        if old_content != new_content && !ws.normalizes_line_compare() {
680            edits = line_ending_only_edits(&before, &after);
681        }
682        if edits.is_empty() {
683            return String::new();
684        }
685    }
686    emit_unified_body_from_edits(
687        &before,
688        &after,
689        &edits,
690        UnifiedEmitOptions {
691            context_lines,
692            inter_hunk_context,
693            function_context: false,
694        },
695        None,
696    )
697}
698
699fn blank_line_ignorable(ws: super::WhitespaceMode) -> impl Fn(&[&str], &[&str]) -> bool + Copy {
700    move |removed: &[&str], added: &[&str]| {
701        ws.ignore_blank_lines
702            && removed.iter().all(|l| crate::ws::ws_blank_line(l))
703            && added.iter().all(|l| crate::ws::ws_blank_line(l))
704    }
705}
706
707/// Unified hunk bodies (`@@` …) for [`DiffOptions`], shared by structured and log-patch output.
708pub(crate) fn unified_hunk_body_for_diff_options(
709    old_content: &str,
710    new_content: &str,
711    options: &crate::diff::options::DiffOptions,
712) -> String {
713    use crate::diff::options::DiffAlgorithm;
714
715    let ws = options.whitespace;
716    if ws.is_active() {
717        let blank_only = blank_line_ignorable(ws);
718        if ws.normalizes_line_compare() {
719            let ignorable = ws.ignore_blank_lines.then_some(blank_only);
720            return match options.algorithm {
721                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
722                    old_content,
723                    new_content,
724                    options.context_lines,
725                    options.inter_hunk_context,
726                    options.indent_heuristic,
727                    false,
728                    ws,
729                    ignorable,
730                ),
731                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
732                    old_content,
733                    new_content,
734                    options.context_lines,
735                    options.inter_hunk_context,
736                    options.indent_heuristic,
737                    true,
738                    ws,
739                    ignorable,
740                ),
741                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
742                    old_content,
743                    new_content,
744                    options.context_lines,
745                    options.inter_hunk_context,
746                    options.indent_heuristic,
747                    ws,
748                    ignorable,
749                ),
750                DiffAlgorithm::Histogram => {
751                    if ws.ignore_blank_lines {
752                        histogram_unified_body_ignore_fc(
753                            old_content,
754                            new_content,
755                            options.context_lines,
756                            options.inter_hunk_context,
757                            false,
758                            None,
759                            options.indent_heuristic,
760                            ws,
761                            blank_only,
762                        )
763                    } else {
764                        histogram_unified_body_raw(
765                            old_content,
766                            new_content,
767                            options.context_lines,
768                            options.inter_hunk_context,
769                            options.indent_heuristic,
770                            ws,
771                        )
772                    }
773                }
774            };
775        }
776        if ws.ignore_blank_lines {
777            return match options.algorithm {
778                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
779                    old_content,
780                    new_content,
781                    options.context_lines,
782                    options.inter_hunk_context,
783                    options.indent_heuristic,
784                    false,
785                    ws,
786                    Some(blank_only),
787                ),
788                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
789                    old_content,
790                    new_content,
791                    options.context_lines,
792                    options.inter_hunk_context,
793                    options.indent_heuristic,
794                    true,
795                    ws,
796                    Some(blank_only),
797                ),
798                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
799                    old_content,
800                    new_content,
801                    options.context_lines,
802                    options.inter_hunk_context,
803                    options.indent_heuristic,
804                    ws,
805                    Some(blank_only),
806                ),
807                DiffAlgorithm::Histogram => histogram_unified_body_ignore_fc(
808                    old_content,
809                    new_content,
810                    options.context_lines,
811                    options.inter_hunk_context,
812                    false,
813                    None,
814                    options.indent_heuristic,
815                    ws,
816                    blank_only,
817                ),
818            };
819        }
820    }
821    match options.algorithm {
822        DiffAlgorithm::Myers => git_xdiff_unified_body_raw(
823            old_content,
824            new_content,
825            options.context_lines,
826            options.inter_hunk_context,
827            options.indent_heuristic,
828            false,
829            ws,
830        ),
831        DiffAlgorithm::Minimal => git_xdiff_unified_body_raw(
832            old_content,
833            new_content,
834            options.context_lines,
835            options.inter_hunk_context,
836            options.indent_heuristic,
837            true,
838            ws,
839        ),
840        DiffAlgorithm::Patience => git_patience_unified_body_raw(
841            old_content,
842            new_content,
843            options.context_lines,
844            options.inter_hunk_context,
845            options.indent_heuristic,
846        ),
847        DiffAlgorithm::Histogram => histogram_unified_body_raw(
848            old_content,
849            new_content,
850            options.context_lines,
851            options.inter_hunk_context,
852            options.indent_heuristic,
853            ws,
854        ),
855    }
856}
857
858/// Unified hunk body using Git's patience diff (no `---` / `+++`).
859fn git_patience_unified_body_raw(
860    old_content: &str,
861    new_content: &str,
862    context_lines: usize,
863    inter_hunk_context: usize,
864    indent_heuristic: bool,
865) -> String {
866    let before = LineSide::from_text(old_content);
867    let after = LineSide::from_text(new_content);
868    if before.is_empty() && after.is_empty() {
869        return String::new();
870    }
871    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
872    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
873    let ws = super::WhitespaceMode::default();
874    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
875    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
876    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
877    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
878    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
879    let ops = diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
880        &raw,
881        diff_indent_heuristic::CompactSide {
882            lines: &old_refs,
883            keys: &old_compare,
884            compare_ids: None,
885        },
886        diff_indent_heuristic::CompactSide {
887            lines: &new_refs,
888            keys: &new_compare,
889            compare_ids: None,
890        },
891        indent_heuristic,
892        ws,
893        false,
894    );
895    let mut edits = diff_ops_to_edit_spans(&ops);
896    if edits.is_empty() {
897        if old_content != new_content {
898            edits = line_ending_only_edits(&before, &after);
899        }
900        if edits.is_empty() {
901            return String::new();
902        }
903    }
904    emit_unified_body_from_edits(
905        &before,
906        &after,
907        &edits,
908        UnifiedEmitOptions {
909            context_lines,
910            inter_hunk_context,
911            function_context: false,
912        },
913        None,
914    )
915}
916
917/// Parse `@@ -old +new @@` into 1-based starts and line counts (Git display form).
918pub(crate) fn unified_hunk_header_counts(
919    header_line: &str,
920) -> Option<(usize, usize, usize, usize)> {
921    let rest = header_line.strip_prefix("@@ ")?;
922    let (old_part, rest2) = rest.split_once(" +")?;
923    let old_tok = old_part.strip_prefix('-')?.split(' ').next()?;
924    let new_tok = rest2.split(" @@").next()?.split(' ').next()?;
925
926    fn parse_side(spec: &str) -> (usize, usize) {
927        if let Some((start, count)) = spec.split_once(',') {
928            (start.parse().unwrap_or(0), count.parse().unwrap_or(0))
929        } else {
930            (spec.parse().unwrap_or(0), 1)
931        }
932    }
933
934    let (os, ol) = parse_side(old_tok);
935    let (ns, nl) = parse_side(new_tok);
936    Some((os, ol, ns, nl))
937}
938
939/// Write `---` / `+++` and unified hunks using [`unified_hunk_body_for_diff_options`].
940#[allow(clippy::too_many_arguments)]
941pub(crate) fn write_unified_diff_from_diff_options<W: std::io::Write>(
942    out: &mut W,
943    old_content: &str,
944    new_content: &str,
945    old_path: &str,
946    new_path: &str,
947    src_prefix: &str,
948    dst_prefix: &str,
949    funcname_matcher: Option<&FuncnameMatcher>,
950    options: &crate::diff::options::DiffOptions,
951    quote_path_fully: bool,
952) -> Result<()> {
953    use crate::quote_path::format_diff_path_with_prefix;
954
955    if old_path == "/dev/null" {
956        out.write_all(b"--- /dev/null\n")?;
957    } else if src_prefix.is_empty() {
958        writeln!(out, "--- {old_path}")?;
959    } else {
960        out.write_all(b"--- ")?;
961        out.write_all(
962            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
963        )?;
964        out.write_all(b"\n")?;
965    }
966    if new_path == "/dev/null" {
967        out.write_all(b"+++ /dev/null\n")?;
968    } else if dst_prefix.is_empty() {
969        writeln!(out, "+++ {new_path}")?;
970    } else {
971        out.write_all(b"+++ ")?;
972        out.write_all(
973            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
974        )?;
975        out.write_all(b"\n")?;
976    }
977
978    let body = unified_hunk_body_for_diff_options(old_content, new_content, options);
979    if body.is_empty() {
980        return Ok(());
981    }
982    let old_lines: Vec<&str> = old_content.lines().collect();
983    for hunk_str in imara_unified_hunk_slices(&body) {
984        if hunk_str.is_empty() {
985            continue;
986        }
987        if let Some(first_newline) = hunk_str.find('\n') {
988            let header_line = &hunk_str[..first_newline];
989            let rest = &hunk_str[first_newline..];
990            if let Some(func_ctx) =
991                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
992            {
993                out.write_all(header_line.as_bytes())?;
994                out.write_all(b" ")?;
995                out.write_all(func_ctx.as_bytes())?;
996                out.write_all(rest.as_bytes())?;
997            } else {
998                out.write_all(hunk_str.as_bytes())?;
999            }
1000        } else {
1001            out.write_all(hunk_str.as_bytes())?;
1002        }
1003    }
1004    Ok(())
1005}
1006
1007#[expect(clippy::too_many_arguments)]
1008fn unified_diff_git_myers_with_prefix_and_funcname(
1009    old_content: &str,
1010    new_content: &str,
1011    old_path: &str,
1012    new_path: &str,
1013    context_lines: usize,
1014    inter_hunk_context: usize,
1015    src_prefix: &str,
1016    dst_prefix: &str,
1017    funcname_matcher: Option<&FuncnameMatcher>,
1018    indent_heuristic: bool,
1019    quote_path_fully: bool,
1020) -> String {
1021    use crate::quote_path::format_diff_path_with_prefix;
1022
1023    let body = git_xdiff_unified_body_raw(
1024        old_content,
1025        new_content,
1026        context_lines,
1027        inter_hunk_context,
1028        indent_heuristic,
1029        false,
1030        super::WhitespaceMode::default(),
1031    );
1032
1033    let mut output = String::new();
1034    if old_path == "/dev/null" {
1035        output.push_str("--- /dev/null\n");
1036    } else if src_prefix.is_empty() {
1037        output.push_str(&format!("--- {old_path}\n"));
1038    } else {
1039        output.push_str("--- ");
1040        output.push_str(&format_diff_path_with_prefix(
1041            src_prefix,
1042            old_path,
1043            quote_path_fully,
1044        ));
1045        output.push('\n');
1046    }
1047    if new_path == "/dev/null" {
1048        output.push_str("+++ /dev/null\n");
1049    } else if dst_prefix.is_empty() {
1050        output.push_str(&format!("+++ {new_path}\n"));
1051    } else {
1052        output.push_str("+++ ");
1053        output.push_str(&format_diff_path_with_prefix(
1054            dst_prefix,
1055            new_path,
1056            quote_path_fully,
1057        ));
1058        output.push('\n');
1059    }
1060
1061    let old_lines: Vec<&str> = old_content.lines().collect();
1062    for hunk_str in imara_unified_hunk_slices(&body) {
1063        if hunk_str.is_empty() {
1064            continue;
1065        }
1066        if let Some(first_newline) = hunk_str.find('\n') {
1067            let header_line = &hunk_str[..first_newline];
1068            let rest = &hunk_str[first_newline..];
1069            if let Some(func_ctx) =
1070                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1071            {
1072                output.push_str(header_line);
1073                output.push(' ');
1074                output.push_str(&func_ctx);
1075                output.push_str(rest);
1076            } else {
1077                output.push_str(hunk_str);
1078            }
1079        } else {
1080            output.push_str(hunk_str);
1081        }
1082    }
1083
1084    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1085}
1086
1087#[expect(clippy::too_many_arguments)]
1088fn unified_diff_git_minimal_with_prefix_and_funcname(
1089    old_content: &str,
1090    new_content: &str,
1091    old_path: &str,
1092    new_path: &str,
1093    context_lines: usize,
1094    inter_hunk_context: usize,
1095    src_prefix: &str,
1096    dst_prefix: &str,
1097    funcname_matcher: Option<&FuncnameMatcher>,
1098    indent_heuristic: bool,
1099    quote_path_fully: bool,
1100) -> String {
1101    use crate::quote_path::format_diff_path_with_prefix;
1102
1103    let body = git_xdiff_unified_body_raw(
1104        old_content,
1105        new_content,
1106        context_lines,
1107        inter_hunk_context,
1108        indent_heuristic,
1109        true,
1110        super::WhitespaceMode::default(),
1111    );
1112
1113    let mut output = String::new();
1114    if old_path == "/dev/null" {
1115        output.push_str("--- /dev/null\n");
1116    } else if src_prefix.is_empty() {
1117        output.push_str(&format!("--- {old_path}\n"));
1118    } else {
1119        output.push_str("--- ");
1120        output.push_str(&format_diff_path_with_prefix(
1121            src_prefix,
1122            old_path,
1123            quote_path_fully,
1124        ));
1125        output.push('\n');
1126    }
1127    if new_path == "/dev/null" {
1128        output.push_str("+++ /dev/null\n");
1129    } else if dst_prefix.is_empty() {
1130        output.push_str(&format!("+++ {new_path}\n"));
1131    } else {
1132        output.push_str("+++ ");
1133        output.push_str(&format_diff_path_with_prefix(
1134            dst_prefix,
1135            new_path,
1136            quote_path_fully,
1137        ));
1138        output.push('\n');
1139    }
1140
1141    let old_lines: Vec<&str> = old_content.lines().collect();
1142    for hunk_str in imara_unified_hunk_slices(&body) {
1143        if hunk_str.is_empty() {
1144            continue;
1145        }
1146        if let Some(first_newline) = hunk_str.find('\n') {
1147            let header_line = &hunk_str[..first_newline];
1148            let rest = &hunk_str[first_newline..];
1149            if let Some(func_ctx) =
1150                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1151            {
1152                output.push_str(header_line);
1153                output.push(' ');
1154                output.push_str(&func_ctx);
1155                output.push_str(rest);
1156            } else {
1157                output.push_str(hunk_str);
1158            }
1159        } else {
1160            output.push_str(hunk_str);
1161        }
1162    }
1163
1164    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1165}
1166
1167#[expect(clippy::too_many_arguments)]
1168fn unified_diff_git_patience_with_prefix_and_funcname(
1169    old_content: &str,
1170    new_content: &str,
1171    old_path: &str,
1172    new_path: &str,
1173    context_lines: usize,
1174    inter_hunk_context: usize,
1175    src_prefix: &str,
1176    dst_prefix: &str,
1177    funcname_matcher: Option<&FuncnameMatcher>,
1178    indent_heuristic: bool,
1179    quote_path_fully: bool,
1180) -> String {
1181    use crate::quote_path::format_diff_path_with_prefix;
1182
1183    let body = git_patience_unified_body_raw(
1184        old_content,
1185        new_content,
1186        context_lines,
1187        inter_hunk_context,
1188        indent_heuristic,
1189    );
1190
1191    let mut output = String::new();
1192    if old_path == "/dev/null" {
1193        output.push_str("--- /dev/null\n");
1194    } else if src_prefix.is_empty() {
1195        output.push_str(&format!("--- {old_path}\n"));
1196    } else {
1197        output.push_str("--- ");
1198        output.push_str(&format_diff_path_with_prefix(
1199            src_prefix,
1200            old_path,
1201            quote_path_fully,
1202        ));
1203        output.push('\n');
1204    }
1205    if new_path == "/dev/null" {
1206        output.push_str("+++ /dev/null\n");
1207    } else if dst_prefix.is_empty() {
1208        output.push_str(&format!("+++ {new_path}\n"));
1209    } else {
1210        output.push_str("+++ ");
1211        output.push_str(&format_diff_path_with_prefix(
1212            dst_prefix,
1213            new_path,
1214            quote_path_fully,
1215        ));
1216        output.push('\n');
1217    }
1218
1219    let old_lines: Vec<&str> = old_content.lines().collect();
1220    for hunk_str in imara_unified_hunk_slices(&body) {
1221        if hunk_str.is_empty() {
1222            continue;
1223        }
1224        if let Some(first_newline) = hunk_str.find('\n') {
1225            let header_line = &hunk_str[..first_newline];
1226            let rest = &hunk_str[first_newline..];
1227            if let Some(func_ctx) =
1228                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1229            {
1230                output.push_str(header_line);
1231                output.push(' ');
1232                output.push_str(&func_ctx);
1233                output.push_str(rest);
1234            } else {
1235                output.push_str(hunk_str);
1236            }
1237        } else {
1238            output.push_str(hunk_str);
1239        }
1240    }
1241
1242    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1243}
1244
1245/// Unified diff hunks for Git's histogram algorithm (no `---` / `+++` lines).
1246///
1247/// Used by `--no-index` when whitespace normalization is off so the patch matches upstream Git.
1248#[must_use]
1249pub fn unified_diff_histogram_hunks_only(
1250    old_content: &str,
1251    new_content: &str,
1252    context_lines: usize,
1253    inter_hunk_context: usize,
1254    indent_heuristic: bool,
1255) -> String {
1256    histogram_unified_body_raw(
1257        old_content,
1258        new_content,
1259        context_lines,
1260        inter_hunk_context,
1261        indent_heuristic,
1262        super::WhitespaceMode::default(),
1263    )
1264}
1265
1266#[expect(clippy::too_many_arguments)]
1267fn unified_diff_histogram_with_prefix_and_funcname_indent(
1268    old_content: &str,
1269    new_content: &str,
1270    old_path: &str,
1271    new_path: &str,
1272    context_lines: usize,
1273    inter_hunk_context: usize,
1274    src_prefix: &str,
1275    dst_prefix: &str,
1276    funcname_matcher: Option<&FuncnameMatcher>,
1277    indent_heuristic: bool,
1278    quote_path_fully: bool,
1279) -> String {
1280    use crate::quote_path::format_diff_path_with_prefix;
1281
1282    let body = histogram_unified_body_raw(
1283        old_content,
1284        new_content,
1285        context_lines,
1286        inter_hunk_context,
1287        indent_heuristic,
1288        super::WhitespaceMode::default(),
1289    );
1290
1291    let mut output = String::new();
1292    if old_path == "/dev/null" {
1293        output.push_str("--- /dev/null\n");
1294    } else if src_prefix.is_empty() {
1295        output.push_str(&format!("--- {old_path}\n"));
1296    } else {
1297        output.push_str("--- ");
1298        output.push_str(&format_diff_path_with_prefix(
1299            src_prefix,
1300            old_path,
1301            quote_path_fully,
1302        ));
1303        output.push('\n');
1304    }
1305    if new_path == "/dev/null" {
1306        output.push_str("+++ /dev/null\n");
1307    } else if dst_prefix.is_empty() {
1308        output.push_str(&format!("+++ {new_path}\n"));
1309    } else {
1310        output.push_str("+++ ");
1311        output.push_str(&format_diff_path_with_prefix(
1312            dst_prefix,
1313            new_path,
1314            quote_path_fully,
1315        ));
1316        output.push('\n');
1317    }
1318
1319    let old_lines: Vec<&str> = old_content.lines().collect();
1320    for hunk_str in imara_unified_hunk_slices(&body) {
1321        if hunk_str.is_empty() {
1322            continue;
1323        }
1324        if let Some(first_newline) = hunk_str.find('\n') {
1325            let header_line = &hunk_str[..first_newline];
1326            let rest = &hunk_str[first_newline..];
1327            if let Some(func_ctx) =
1328                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1329            {
1330                output.push_str(header_line);
1331                output.push(' ');
1332                output.push_str(&func_ctx);
1333                output.push_str(rest);
1334            } else {
1335                output.push_str(hunk_str);
1336            }
1337        } else {
1338            output.push_str(hunk_str);
1339        }
1340    }
1341
1342    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1343}
1344
1345/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm,
1346/// applying `--ignore-blank-lines` / `-I` change-record suppression
1347/// (`histogram_unified_body_ignore_fc`) and then attaching function-name hunk
1348/// headers like `unified_diff_histogram_with_prefix_and_funcname_indent`.
1349///
1350/// `is_ignorable_change` is given the removed/added line slices (without
1351/// trailing newlines) of one change record and returns whether the whole change
1352/// is ignorable. Returns an empty string when no substantive change survives so
1353/// the caller can hide the file entry.
1354#[allow(clippy::too_many_arguments)]
1355pub fn unified_diff_histogram_ignore_with_prefix_and_funcname<F>(
1356    old_content: &str,
1357    new_content: &str,
1358    old_path: &str,
1359    new_path: &str,
1360    context_lines: usize,
1361    inter_hunk_context: usize,
1362    src_prefix: &str,
1363    dst_prefix: &str,
1364    funcname_matcher: Option<&FuncnameMatcher>,
1365    quote_path_fully: bool,
1366    function_context: bool,
1367    indent_heuristic: bool,
1368    is_ignorable_change: F,
1369) -> String
1370where
1371    F: Fn(&[&str], &[&str]) -> bool,
1372{
1373    use crate::quote_path::format_diff_path_with_prefix;
1374
1375    let body = histogram_unified_body_ignore_fc(
1376        old_content,
1377        new_content,
1378        context_lines,
1379        inter_hunk_context,
1380        function_context,
1381        funcname_matcher,
1382        indent_heuristic,
1383        super::WhitespaceMode::default(),
1384        is_ignorable_change,
1385    );
1386    if body.is_empty() {
1387        return String::new();
1388    }
1389
1390    let mut output = String::new();
1391    if old_path == "/dev/null" {
1392        output.push_str("--- /dev/null\n");
1393    } else if src_prefix.is_empty() {
1394        output.push_str(&format!("--- {old_path}\n"));
1395    } else {
1396        output.push_str("--- ");
1397        output.push_str(&format_diff_path_with_prefix(
1398            src_prefix,
1399            old_path,
1400            quote_path_fully,
1401        ));
1402        output.push('\n');
1403    }
1404    if new_path == "/dev/null" {
1405        output.push_str("+++ /dev/null\n");
1406    } else if dst_prefix.is_empty() {
1407        output.push_str(&format!("+++ {new_path}\n"));
1408    } else {
1409        output.push_str("+++ ");
1410        output.push_str(&format_diff_path_with_prefix(
1411            dst_prefix,
1412            new_path,
1413            quote_path_fully,
1414        ));
1415        output.push('\n');
1416    }
1417
1418    let old_lines: Vec<&str> = old_content.lines().collect();
1419    for hunk_str in imara_unified_hunk_slices(&body) {
1420        if hunk_str.is_empty() {
1421            continue;
1422        }
1423        if let Some(first_newline) = hunk_str.find('\n') {
1424            let header_line = &hunk_str[..first_newline];
1425            let rest = &hunk_str[first_newline..];
1426            if let Some(func_ctx) =
1427                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1428            {
1429                output.push_str(header_line);
1430                output.push(' ');
1431                output.push_str(&func_ctx);
1432                output.push_str(rest);
1433            } else {
1434                output.push_str(hunk_str);
1435            }
1436        } else {
1437            output.push_str(hunk_str);
1438        }
1439    }
1440
1441    output
1442}
1443
1444/// `diff.indentHeuristic` from config (Git defaults to true when unset).
1445#[must_use]
1446pub fn indent_heuristic_from_config(config: &ConfigSet) -> bool {
1447    match config.get_bool("diff.indentHeuristic") {
1448        Some(Ok(b)) => b,
1449        Some(Err(_)) | None => true,
1450    }
1451}
1452
1453/// Resolve indent heuristic: `--no-indent-heuristic` and `--indent-heuristic` override config.
1454#[must_use]
1455pub fn resolve_indent_heuristic(
1456    config: &ConfigSet,
1457    cli_indent_heuristic: bool,
1458    cli_no_indent_heuristic: bool,
1459) -> bool {
1460    if cli_no_indent_heuristic {
1461        false
1462    } else if cli_indent_heuristic {
1463        true
1464    } else {
1465        indent_heuristic_from_config(config)
1466    }
1467}
1468
1469// ---------------------------------------------------------------------------
1470// Git-compatible implementation of the xdiff Myers engine,
1471// restricted to what the word-diff path needs (no rename/ignore-whitespace
1472// flags). Git's word diff runs the *default* xdiff Myers over the per-word
1473// token streams; matching Git's exact change-record selection requires
1474// reproducing its preprocessing (record classification + `xdl_cleanup_records`
1475// + `xdl_trim_ends`) and its divide-and-conquer split, because the histogram
1476// engine (and `similar`) pick different — but equally minimal — alignments for inputs
1477// with many repeated tokens (e.g. the `{`/`}`/`,` in the `bibtex` driver).
1478mod git_xdiff {
1479    use std::cell::RefCell;
1480
1481    const XDL_MAX_COST_MIN: i64 = 256;
1482    const XDL_HEUR_MIN_COST: i64 = 256;
1483    const XDL_SNAKE_CNT: i64 = 20;
1484    const XDL_K_HEUR: i64 = 4;
1485    const XDL_LINE_MAX: i64 = i64::MAX;
1486    const XDL_KPDIS_RUN: i64 = 4;
1487    const XDL_MAX_EQLIMIT: i64 = 1024;
1488    const XDL_SIMSCAN_WINDOW: i64 = 100;
1489
1490    // record-classification actions (xprepare.c)
1491    const DISCARD: u8 = 0;
1492    const KEEP: u8 = 1;
1493    const INVESTIGATE: u8 = 2;
1494
1495    /// Classical integer square root approximation using shifts (`xdl_bogosqrt`).
1496    fn bogosqrt(mut n: i64) -> i64 {
1497        let mut i: i64 = 1;
1498        while n > 0 {
1499            i <<= 1;
1500            n >>= 2;
1501        }
1502        i
1503    }
1504
1505    struct XdAlgoEnv {
1506        mxcost: i64,
1507        snake_cnt: i64,
1508        heur_min: i64,
1509    }
1510
1511    struct XdpSplit {
1512        i1: i64,
1513        i2: i64,
1514        min_lo: bool,
1515        min_hi: bool,
1516    }
1517
1518    /// A single side of the diff: token ids plus the `changed` flags and the
1519    /// `reference_index` mapping from effective record -> original record.
1520    struct XdFile<'a> {
1521        #[allow(dead_code)]
1522        ids: &'a [u32],
1523        changed: Vec<u8>,
1524        reference_index: Vec<usize>,
1525        /// Interned token id per effective record (parallel to `reference_index`).
1526        effective_ids: Vec<u32>,
1527        dstart: i64,
1528        dend: i64,
1529        nreff: i64,
1530    }
1531
1532    #[inline]
1533    fn effective_id(xdf: &XdFile<'_>, idx: i64) -> u32 {
1534        xdf.effective_ids[idx as usize]
1535    }
1536
1537    struct XdiffScratch {
1538        count1: Vec<i64>,
1539        count2: Vec<i64>,
1540        action1: Vec<u8>,
1541        action2: Vec<u8>,
1542        kvd: Vec<i64>,
1543    }
1544
1545    impl XdiffScratch {
1546        fn prepare_counts(&mut self, nclasses: usize) {
1547            if self.count1.len() < nclasses {
1548                self.count1.resize(nclasses, 0);
1549                self.count2.resize(nclasses, 0);
1550            }
1551            self.count1[..nclasses].fill(0);
1552            self.count2[..nclasses].fill(0);
1553        }
1554
1555        fn prepare_actions(&mut self, nrec1: usize, nrec2: usize) {
1556            let a1 = nrec1 + 1;
1557            let a2 = nrec2 + 1;
1558            if self.action1.len() < a1 {
1559                self.action1.resize(a1, 0);
1560            } else {
1561                self.action1[..a1].fill(0);
1562            }
1563            if self.action2.len() < a2 {
1564                self.action2.resize(a2, 0);
1565            } else {
1566                self.action2[..a2].fill(0);
1567            }
1568        }
1569
1570        fn prepare_kvd(&mut self, kvd_len: usize) {
1571            if self.kvd.len() < kvd_len {
1572                self.kvd.resize(kvd_len, 0);
1573            }
1574        }
1575    }
1576
1577    thread_local! { // hygiene: per-thread xdiff Myers scratch (class counts, K-vectors) reused across files
1578        static XDIFF_SCRATCH: RefCell<XdiffScratch> = const {
1579            RefCell::new(XdiffScratch {
1580                count1: Vec::new(),
1581                count2: Vec::new(),
1582                action1: Vec::new(),
1583                action2: Vec::new(),
1584                kvd: Vec::new(),
1585            })
1586        };
1587    }
1588
1589    fn bool_changed(changed: Vec<u8>) -> Vec<bool> {
1590        changed.into_iter().map(|b| b != 0).collect()
1591    }
1592
1593    /// Faithful port of `xdl_split` (the middle-snake finder). `kvdf`/`kvdb` are
1594    /// the forward/backward k-vectors, offset so index 0 maps to diagonal 0.
1595    #[allow(clippy::too_many_arguments)]
1596    fn xdl_split(
1597        xdf1: &XdFile<'_>,
1598        off1: i64,
1599        lim1: i64,
1600        xdf2: &XdFile<'_>,
1601        off2: i64,
1602        lim2: i64,
1603        kvdf: &mut [i64],
1604        kvdb: &mut [i64],
1605        koff: i64,
1606        need_min: bool,
1607        spl: &mut XdpSplit,
1608        xenv: &XdAlgoEnv,
1609    ) -> i64 {
1610        let dmin = off1 - lim2;
1611        let dmax = lim1 - off2;
1612        let fmid = off1 - off2;
1613        let bmid = lim1 - lim2;
1614        let odd = ((fmid - bmid) & 1) != 0;
1615        let mut fmin = fmid;
1616        let mut fmax = fmid;
1617        let mut bmin = bmid;
1618        let mut bmax = bmid;
1619
1620        let kf = |k: i64| -> usize { (k + koff) as usize };
1621
1622        kvdf[kf(fmid)] = off1;
1623        kvdb[kf(bmid)] = lim1;
1624
1625        let mut ec: i64 = 1;
1626        loop {
1627            let mut got_snake = false;
1628
1629            if fmin > dmin {
1630                fmin -= 1;
1631                kvdf[kf(fmin - 1)] = -1;
1632            } else {
1633                fmin += 1;
1634            }
1635            if fmax < dmax {
1636                fmax += 1;
1637                kvdf[kf(fmax + 1)] = -1;
1638            } else {
1639                fmax -= 1;
1640            }
1641
1642            let mut d = fmax;
1643            while d >= fmin {
1644                let mut i1 = if kvdf[kf(d - 1)] >= kvdf[kf(d + 1)] {
1645                    kvdf[kf(d - 1)] + 1
1646                } else {
1647                    kvdf[kf(d + 1)]
1648                };
1649                let prev1 = i1;
1650                let mut i2 = i1 - d;
1651                while i1 < lim1 && i2 < lim2 && effective_id(xdf1, i1) == effective_id(xdf2, i2) {
1652                    i1 += 1;
1653                    i2 += 1;
1654                }
1655                if i1 - prev1 > xenv.snake_cnt {
1656                    got_snake = true;
1657                }
1658                kvdf[kf(d)] = i1;
1659                if odd && bmin <= d && d <= bmax && kvdb[kf(d)] <= i1 {
1660                    spl.i1 = i1;
1661                    spl.i2 = i2;
1662                    spl.min_lo = true;
1663                    spl.min_hi = true;
1664                    return ec;
1665                }
1666                d -= 2;
1667            }
1668
1669            if bmin > dmin {
1670                bmin -= 1;
1671                kvdb[kf(bmin - 1)] = XDL_LINE_MAX;
1672            } else {
1673                bmin += 1;
1674            }
1675            if bmax < dmax {
1676                bmax += 1;
1677                kvdb[kf(bmax + 1)] = XDL_LINE_MAX;
1678            } else {
1679                bmax -= 1;
1680            }
1681
1682            let mut d = bmax;
1683            while d >= bmin {
1684                let mut i1 = if kvdb[kf(d - 1)] < kvdb[kf(d + 1)] {
1685                    kvdb[kf(d - 1)]
1686                } else {
1687                    kvdb[kf(d + 1)] - 1
1688                };
1689                let prev1 = i1;
1690                let mut i2 = i1 - d;
1691                while i1 > off1
1692                    && i2 > off2
1693                    && effective_id(xdf1, i1 - 1) == effective_id(xdf2, i2 - 1)
1694                {
1695                    i1 -= 1;
1696                    i2 -= 1;
1697                }
1698                if prev1 - i1 > xenv.snake_cnt {
1699                    got_snake = true;
1700                }
1701                kvdb[kf(d)] = i1;
1702                if !odd && fmin <= d && d <= fmax && i1 <= kvdf[kf(d)] {
1703                    spl.i1 = i1;
1704                    spl.i2 = i2;
1705                    spl.min_lo = true;
1706                    spl.min_hi = true;
1707                    return ec;
1708                }
1709                d -= 2;
1710            }
1711
1712            if need_min {
1713                ec += 1;
1714                continue;
1715            }
1716
1717            if got_snake && ec > xenv.heur_min {
1718                let mut best = 0i64;
1719                let mut d = fmax;
1720                while d >= fmin {
1721                    let dd = if d > fmid { d - fmid } else { fmid - d };
1722                    let i1 = kvdf[kf(d)];
1723                    let i2 = i1 - d;
1724                    let v = (i1 - off1) + (i2 - off2) - dd;
1725                    if v > XDL_K_HEUR * ec
1726                        && v > best
1727                        && off1 + xenv.snake_cnt <= i1
1728                        && i1 < lim1
1729                        && off2 + xenv.snake_cnt <= i2
1730                        && i2 < lim2
1731                    {
1732                        let mut k = 1i64;
1733                        while effective_id(xdf1, i1 - k) == effective_id(xdf2, i2 - k) {
1734                            if k == xenv.snake_cnt {
1735                                best = v;
1736                                spl.i1 = i1;
1737                                spl.i2 = i2;
1738                                break;
1739                            }
1740                            k += 1;
1741                        }
1742                    }
1743                    d -= 2;
1744                }
1745                if best > 0 {
1746                    spl.min_lo = true;
1747                    spl.min_hi = false;
1748                    return ec;
1749                }
1750
1751                let mut best = 0i64;
1752                let mut d = bmax;
1753                while d >= bmin {
1754                    let dd = if d > bmid { d - bmid } else { bmid - d };
1755                    let i1 = kvdb[kf(d)];
1756                    let i2 = i1 - d;
1757                    let v = (lim1 - i1) + (lim2 - i2) - dd;
1758                    if v > XDL_K_HEUR * ec
1759                        && v > best
1760                        && off1 < i1
1761                        && i1 <= lim1 - xenv.snake_cnt
1762                        && off2 < i2
1763                        && i2 <= lim2 - xenv.snake_cnt
1764                    {
1765                        let mut k = 0i64;
1766                        while effective_id(xdf1, i1 + k) == effective_id(xdf2, i2 + k) {
1767                            if k == xenv.snake_cnt - 1 {
1768                                best = v;
1769                                spl.i1 = i1;
1770                                spl.i2 = i2;
1771                                break;
1772                            }
1773                            k += 1;
1774                        }
1775                    }
1776                    d -= 2;
1777                }
1778                if best > 0 {
1779                    spl.min_lo = false;
1780                    spl.min_hi = true;
1781                    return ec;
1782                }
1783            }
1784
1785            if ec >= xenv.mxcost {
1786                let mut fbest = -1i64;
1787                let mut fbest1 = -1i64;
1788                let mut d = fmax;
1789                while d >= fmin {
1790                    let mut i1 = kvdf[kf(d)].min(lim1);
1791                    let mut i2 = i1 - d;
1792                    if lim2 < i2 {
1793                        i1 = lim2 + d;
1794                        i2 = lim2;
1795                    }
1796                    if fbest < i1 + i2 {
1797                        fbest = i1 + i2;
1798                        fbest1 = i1;
1799                    }
1800                    d -= 2;
1801                }
1802
1803                let mut bbest = XDL_LINE_MAX;
1804                let mut bbest1 = XDL_LINE_MAX;
1805                let mut d = bmax;
1806                while d >= bmin {
1807                    let mut i1 = off1.max(kvdb[kf(d)]);
1808                    let mut i2 = i1 - d;
1809                    if i2 < off2 {
1810                        i1 = off2 + d;
1811                        i2 = off2;
1812                    }
1813                    if i1 + i2 < bbest {
1814                        bbest = i1 + i2;
1815                        bbest1 = i1;
1816                    }
1817                    d -= 2;
1818                }
1819
1820                if (lim1 + lim2) - bbest < fbest - (off1 + off2) {
1821                    spl.i1 = fbest1;
1822                    spl.i2 = fbest - fbest1;
1823                    spl.min_lo = true;
1824                    spl.min_hi = false;
1825                } else {
1826                    spl.i1 = bbest1;
1827                    spl.i2 = bbest - bbest1;
1828                    spl.min_lo = false;
1829                    spl.min_hi = true;
1830                }
1831                return ec;
1832            }
1833
1834            ec += 1;
1835        }
1836    }
1837
1838    /// Faithful port of `xdl_recs_cmp` (divide & conquer). Marks `changed` flags.
1839    #[allow(clippy::too_many_arguments)]
1840    fn xdl_recs_cmp(
1841        xdf1: &mut XdFile<'_>,
1842        mut off1: i64,
1843        mut lim1: i64,
1844        xdf2: &mut XdFile<'_>,
1845        mut off2: i64,
1846        mut lim2: i64,
1847        kvdf: &mut [i64],
1848        kvdb: &mut [i64],
1849        koff: i64,
1850        need_min: bool,
1851        xenv: &XdAlgoEnv,
1852    ) {
1853        while off1 < lim1 && off2 < lim2 && effective_id(xdf1, off1) == effective_id(xdf2, off2) {
1854            off1 += 1;
1855            off2 += 1;
1856        }
1857        while off1 < lim1
1858            && off2 < lim2
1859            && effective_id(xdf1, lim1 - 1) == effective_id(xdf2, lim2 - 1)
1860        {
1861            lim1 -= 1;
1862            lim2 -= 1;
1863        }
1864
1865        if off1 == lim1 {
1866            while off2 < lim2 {
1867                let r = xdf2.reference_index[off2 as usize];
1868                xdf2.changed[r] = 1;
1869                off2 += 1;
1870            }
1871        } else if off2 == lim2 {
1872            while off1 < lim1 {
1873                let r = xdf1.reference_index[off1 as usize];
1874                xdf1.changed[r] = 1;
1875                off1 += 1;
1876            }
1877        } else {
1878            let mut spl = XdpSplit {
1879                i1: 0,
1880                i2: 0,
1881                min_lo: false,
1882                min_hi: false,
1883            };
1884            xdl_split(
1885                xdf1, off1, lim1, xdf2, off2, lim2, kvdf, kvdb, koff, need_min, &mut spl, xenv,
1886            );
1887            xdl_recs_cmp(
1888                xdf1, off1, spl.i1, xdf2, off2, spl.i2, kvdf, kvdb, koff, spl.min_lo, xenv,
1889            );
1890            xdl_recs_cmp(
1891                xdf1, spl.i1, lim1, xdf2, spl.i2, lim2, kvdf, kvdb, koff, spl.min_hi, xenv,
1892            );
1893        }
1894    }
1895
1896    /// `xdl_clean_mmatch`: decide whether a multimatch record should be discarded.
1897    fn clean_mmatch(action: &[u8], i: i64, mut s: i64, mut e: i64) -> bool {
1898        if i - s > XDL_SIMSCAN_WINDOW {
1899            s = i - XDL_SIMSCAN_WINDOW;
1900        }
1901        if e - i > XDL_SIMSCAN_WINDOW {
1902            e = i + XDL_SIMSCAN_WINDOW;
1903        }
1904
1905        let mut rdis0 = 0i64;
1906        let mut rpdis0 = 1i64;
1907        let mut r = 1i64;
1908        while i - r >= s {
1909            match action[(i - r) as usize] {
1910                DISCARD => rdis0 += 1,
1911                INVESTIGATE => rpdis0 += 1,
1912                _ => break, // KEEP
1913            }
1914            r += 1;
1915        }
1916        if rdis0 == 0 {
1917            return false;
1918        }
1919        let mut rdis1 = 0i64;
1920        let mut rpdis1 = 1i64;
1921        let mut r = 1i64;
1922        while i + r <= e {
1923            match action[(i + r) as usize] {
1924                DISCARD => rdis1 += 1,
1925                INVESTIGATE => rpdis1 += 1,
1926                _ => break, // KEEP
1927            }
1928            r += 1;
1929        }
1930        if rdis1 == 0 {
1931            return false;
1932        }
1933        rdis1 += rdis0;
1934        rpdis1 += rpdis0;
1935        rpdis1 * XDL_KPDIS_RUN < (rpdis1 + rdis1)
1936    }
1937
1938    /// Build the `changed` flags for both token streams using Git's xdiff engine.
1939    /// When `need_min` is true, Git's minimal diff (`--minimal`) is used.
1940    /// `ids1`/`ids2` are dense interned token ids from one [`crate::line_interner::LineInterner`]
1941    /// (equal id <=> equal token); per-id tables are sized by the largest id.
1942    /// Returns `(changed1, changed2)`, one bool per original token.
1943    pub fn changed_flags(ids1: &[u32], ids2: &[u32], need_min: bool) -> (Vec<bool>, Vec<bool>) {
1944        changed_flags_with_shared(ids1, ids2, &[], 0, need_min)
1945    }
1946
1947    /// Like [`changed_flags`] for slices cut from two larger files whose other records are
1948    /// identical on both sides.
1949    ///
1950    /// Git classifies each record by how often it occurs in the whole other file, so the
1951    /// records left out still count: `shared_counts[id]` is how many of them have token `id`
1952    /// (ids past its end have none), and `shared_records` is how many there are on each side.
1953    pub fn changed_flags_with_shared(
1954        ids1: &[u32],
1955        ids2: &[u32],
1956        shared_counts: &[u32],
1957        shared_records: usize,
1958        need_min: bool,
1959    ) -> (Vec<bool>, Vec<bool>) {
1960        XDIFF_SCRATCH.with(|cell| {
1961            changed_flags_with_shared_inner(
1962                cell,
1963                ids1,
1964                ids2,
1965                shared_counts,
1966                shared_records,
1967                need_min,
1968            )
1969        })
1970    }
1971
1972    fn changed_flags_with_shared_inner(
1973        scratch: &RefCell<XdiffScratch>,
1974        ids1: &[u32],
1975        ids2: &[u32],
1976        shared_counts: &[u32],
1977        shared_records: usize,
1978        need_min: bool,
1979    ) -> (Vec<bool>, Vec<bool>) {
1980        let nrec1 = ids1.len();
1981        let nrec2 = ids2.len();
1982
1983        // Per-token occurrence counts on each side (class len1/len2), indexed by the dense ids.
1984        let nclasses = ids1
1985            .iter()
1986            .chain(ids2)
1987            .max()
1988            .map_or(0, |&max| max as usize + 1);
1989        let mut work = scratch.borrow_mut();
1990        work.prepare_counts(nclasses);
1991        for &id in ids1 {
1992            work.count1[id as usize] += 1;
1993        }
1994        for &id in ids2 {
1995            work.count2[id as usize] += 1;
1996        }
1997        for (id, &shared) in shared_counts.iter().enumerate().take(nclasses) {
1998            work.count1[id] += i64::from(shared);
1999            work.count2[id] += i64::from(shared);
2000        }
2001
2002        let mut xdf1 = XdFile {
2003            ids: ids1,
2004            changed: vec![0u8; nrec1],
2005            reference_index: Vec::new(),
2006            effective_ids: Vec::new(),
2007            dstart: 0,
2008            dend: nrec1 as i64 - 1,
2009            nreff: 0,
2010        };
2011        let mut xdf2 = XdFile {
2012            ids: ids2,
2013            changed: vec![0u8; nrec2],
2014            reference_index: Vec::new(),
2015            effective_ids: Vec::new(),
2016            dstart: 0,
2017            dend: nrec2 as i64 - 1,
2018            nreff: 0,
2019        };
2020
2021        // xdl_trim_ends: trim leading/trailing matching records.
2022        let lim = nrec1.min(nrec2) as i64;
2023        let mut i = 0i64;
2024        while i < lim && ids1[i as usize] == ids2[i as usize] {
2025            i += 1;
2026        }
2027        xdf1.dstart = i;
2028        xdf2.dstart = i;
2029        let mut j = 0i64;
2030        let rem = lim - i;
2031        while j < rem && ids1[nrec1 - 1 - j as usize] == ids2[nrec2 - 1 - j as usize] {
2032            j += 1;
2033        }
2034        xdf1.dend = nrec1 as i64 - j - 1;
2035        xdf2.dend = nrec2 as i64 - j - 1;
2036
2037        // xdl_cleanup_records: classify and reduce to effective records.
2038        work.prepare_actions(nrec1, nrec2);
2039
2040        let mlim1 = if need_min {
2041            i64::MAX
2042        } else {
2043            let mut mlim = bogosqrt((nrec1 + shared_records) as i64);
2044            if mlim > XDL_MAX_EQLIMIT {
2045                mlim = XDL_MAX_EQLIMIT;
2046            }
2047            mlim
2048        };
2049        let mut idx = xdf1.dstart;
2050        while idx <= xdf1.dend {
2051            let id = ids1[idx as usize];
2052            let nm = work.count2[id as usize];
2053            work.action1[idx as usize] = if nm == 0 {
2054                DISCARD
2055            } else if nm >= mlim1 {
2056                INVESTIGATE
2057            } else {
2058                KEEP
2059            };
2060            idx += 1;
2061        }
2062
2063        let mlim2 = if need_min {
2064            i64::MAX
2065        } else {
2066            let mut mlim = bogosqrt((nrec2 + shared_records) as i64);
2067            if mlim > XDL_MAX_EQLIMIT {
2068                mlim = XDL_MAX_EQLIMIT;
2069            }
2070            mlim
2071        };
2072        let mut idx = xdf2.dstart;
2073        while idx <= xdf2.dend {
2074            let id = ids2[idx as usize];
2075            let nm = work.count1[id as usize];
2076            work.action2[idx as usize] = if nm == 0 {
2077                DISCARD
2078            } else if nm >= mlim2 {
2079                INVESTIGATE
2080            } else {
2081                KEEP
2082            };
2083            idx += 1;
2084        }
2085
2086        let mut idx = xdf1.dstart;
2087        while idx <= xdf1.dend {
2088            let id = ids1[idx as usize];
2089            let a = work.action1[idx as usize];
2090            if a == KEEP
2091                || (a == INVESTIGATE
2092                    && !clean_mmatch(&work.action1[..nrec1 + 1], idx, xdf1.dstart, xdf1.dend))
2093            {
2094                xdf1.reference_index.push(idx as usize);
2095                xdf1.effective_ids.push(id);
2096            } else {
2097                xdf1.changed[idx as usize] = 1;
2098            }
2099            idx += 1;
2100        }
2101        xdf1.nreff = xdf1.reference_index.len() as i64;
2102
2103        let mut idx = xdf2.dstart;
2104        while idx <= xdf2.dend {
2105            let id = ids2[idx as usize];
2106            let a = work.action2[idx as usize];
2107            if a == KEEP
2108                || (a == INVESTIGATE
2109                    && !clean_mmatch(&work.action2[..nrec2 + 1], idx, xdf2.dstart, xdf2.dend))
2110            {
2111                xdf2.reference_index.push(idx as usize);
2112                xdf2.effective_ids.push(id);
2113            } else {
2114                xdf2.changed[idx as usize] = 1;
2115            }
2116            idx += 1;
2117        }
2118        xdf2.nreff = xdf2.reference_index.len() as i64;
2119
2120        // Allocate K vectors (xdl_do_diff). koff lets us use negative diagonals.
2121        let ndiags = xdf1.nreff + xdf2.nreff + 3;
2122        let kvd_len = (2 * ndiags + 2) as usize;
2123        work.prepare_kvd(kvd_len);
2124        let kvd = &mut work.kvd[..kvd_len];
2125        // kvdf base offset = nreff2 + 1; kvdb base offset = ndiags + nreff2 + 1.
2126        let koff = xdf2.nreff + 1;
2127        let (kvdf_slice, kvdb_slice) = kvd.split_at_mut(ndiags as usize);
2128        // Both slices are indexed as [k + koff]; their length covers the diagonal range.
2129
2130        let xenv = XdAlgoEnv {
2131            mxcost: bogosqrt(ndiags).max(XDL_MAX_COST_MIN),
2132            snake_cnt: XDL_SNAKE_CNT,
2133            heur_min: XDL_HEUR_MIN_COST,
2134        };
2135
2136        let nreff1 = xdf1.nreff;
2137        let nreff2 = xdf2.nreff;
2138        xdl_recs_cmp(
2139            &mut xdf1, 0, nreff1, &mut xdf2, 0, nreff2, kvdf_slice, kvdb_slice, koff, need_min,
2140            &xenv,
2141        );
2142
2143        (bool_changed(xdf1.changed), bool_changed(xdf2.changed))
2144    }
2145
2146    /// Patience diff: match unique lines, then recurse on gaps with xdiff Myers.
2147    pub fn patience_changed_flags(lines1: &[&str], lines2: &[&str]) -> (Vec<bool>, Vec<bool>) {
2148        let mut changed1 = vec![false; lines1.len()];
2149        let mut changed2 = vec![false; lines2.len()];
2150        patience_recurse(
2151            lines1,
2152            lines2,
2153            &mut changed1,
2154            &mut changed2,
2155            0,
2156            lines1.len(),
2157            0,
2158            lines2.len(),
2159        );
2160        (changed1, changed2)
2161    }
2162
2163    #[allow(clippy::too_many_arguments)]
2164    fn patience_recurse(
2165        lines1: &[&str],
2166        lines2: &[&str],
2167        changed1: &mut [bool],
2168        changed2: &mut [bool],
2169        o_start: usize,
2170        o_end: usize,
2171        n_start: usize,
2172        n_end: usize,
2173    ) {
2174        if o_start >= o_end && n_start >= n_end {
2175            return;
2176        }
2177        if o_start >= o_end {
2178            changed2[n_start..n_end].fill(true);
2179            return;
2180        }
2181        if n_start >= n_end {
2182            changed1[o_start..o_end].fill(true);
2183            return;
2184        }
2185
2186        let anchors = patience_anchors(lines1, lines2, o_start, o_end, n_start, n_end);
2187        if anchors.is_empty() {
2188            merge_myers_region(
2189                lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
2190            );
2191            return;
2192        }
2193
2194        let mut prev_o = o_start;
2195        let mut prev_n = n_start;
2196        for (ao, an) in anchors {
2197            patience_recurse(lines1, lines2, changed1, changed2, prev_o, ao, prev_n, an);
2198            prev_o = ao + 1;
2199            prev_n = an + 1;
2200        }
2201        patience_recurse(
2202            lines1, lines2, changed1, changed2, prev_o, o_end, prev_n, n_end,
2203        );
2204    }
2205
2206    #[allow(clippy::too_many_arguments)]
2207    fn patience_anchors(
2208        lines1: &[&str],
2209        lines2: &[&str],
2210        o_start: usize,
2211        o_end: usize,
2212        n_start: usize,
2213        n_end: usize,
2214    ) -> Vec<(usize, usize)> {
2215        use std::collections::HashMap;
2216
2217        let old_slice = &lines1[o_start..o_end];
2218        let new_slice = &lines2[n_start..n_end];
2219        let mut old_pos: HashMap<&str, Vec<usize>> = HashMap::new();
2220        let mut new_pos: HashMap<&str, Vec<usize>> = HashMap::new();
2221        for (i, line) in old_slice.iter().enumerate() {
2222            old_pos.entry(*line).or_default().push(o_start + i);
2223        }
2224        for (i, line) in new_slice.iter().enumerate() {
2225            new_pos.entry(*line).or_default().push(n_start + i);
2226        }
2227
2228        let mut pairs: Vec<(usize, usize)> = Vec::new();
2229        for (line, o_indices) in &old_pos {
2230            if o_indices.len() != 1 {
2231                continue;
2232            }
2233            if let Some(n_indices) = new_pos.get(line) {
2234                if n_indices.len() == 1 {
2235                    pairs.push((o_indices[0], n_indices[0]));
2236                }
2237            }
2238        }
2239        pairs.sort_by_key(|p| p.0);
2240        longest_increasing_by_second(&pairs)
2241    }
2242
2243    fn longest_increasing_by_second(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
2244        if pairs.is_empty() {
2245            return Vec::new();
2246        }
2247        let n = pairs.len();
2248        let mut tails = vec![0usize; n];
2249        let mut prev = vec![usize::MAX; n];
2250        let mut len = 0usize;
2251        for i in 0..n {
2252            let v = pairs[i].1;
2253            let mut lo = 0usize;
2254            let mut hi = len;
2255            while lo < hi {
2256                let mid = (lo + hi) / 2;
2257                if pairs[tails[mid]].1 < v {
2258                    lo = mid + 1;
2259                } else {
2260                    hi = mid;
2261                }
2262            }
2263            if lo > 0 {
2264                prev[i] = tails[lo - 1];
2265            }
2266            tails[lo] = i;
2267            if lo == len {
2268                len += 1;
2269            }
2270        }
2271        let mut out = Vec::with_capacity(len);
2272        let mut k = tails[len.saturating_sub(1)];
2273        while k != usize::MAX {
2274            out.push(pairs[k]);
2275            k = prev[k];
2276        }
2277        out.reverse();
2278        out
2279    }
2280
2281    pub(crate) fn histogram_changed_flags(
2282        lines1: &[&str],
2283        lines2: &[&str],
2284    ) -> (Vec<bool>, Vec<bool>) {
2285        crate::diff::git_histogram::histogram_changed_flags(
2286            lines1,
2287            lines2,
2288            |changed1, changed2, o_start, o_end, n_start, n_end| {
2289                merge_myers_region(
2290                    lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
2291                );
2292            },
2293        )
2294    }
2295
2296    #[allow(clippy::too_many_arguments)]
2297    fn merge_myers_region(
2298        lines1: &[&str],
2299        lines2: &[&str],
2300        changed1: &mut [bool],
2301        changed2: &mut [bool],
2302        o_start: usize,
2303        o_end: usize,
2304        n_start: usize,
2305        n_end: usize,
2306    ) {
2307        use crate::line_interner::LineInterner;
2308
2309        let old_slice = &lines1[o_start..o_end];
2310        let new_slice = &lines2[n_start..n_end];
2311        if old_slice.is_empty() && new_slice.is_empty() {
2312            return;
2313        }
2314        let mut interner = LineInterner::with_capacity(old_slice.len() + new_slice.len());
2315        let ids1 = interner.intern_all(old_slice);
2316        let ids2 = interner.intern_all(new_slice);
2317        let (sub1, sub2) = changed_flags(&ids1, &ids2, false);
2318        for (i, ch) in sub1.iter().enumerate() {
2319            if *ch {
2320                changed1[o_start + i] = true;
2321            }
2322        }
2323        for (j, ch) in sub2.iter().enumerate() {
2324            if *ch {
2325                changed2[n_start + j] = true;
2326            }
2327        }
2328    }
2329}
2330
2331/// Myers changed-line masks for a line slice, honoring [`super::WhitespaceMode`] like Git `xdl_fall_back_diff`.
2332pub(crate) fn myers_rediff_changed_masks_for_region<'a>(
2333    old_lines: &[&'a str],
2334    new_lines: &[&'a str],
2335    ws: super::WhitespaceMode,
2336) -> (Vec<bool>, Vec<bool>) {
2337    use crate::line_interner::LineInterner;
2338
2339    if old_lines.is_empty() && new_lines.is_empty() {
2340        return (Vec::new(), Vec::new());
2341    }
2342    let normalize = |lines: &[&'a str]| -> Vec<Cow<'a, str>> {
2343        lines
2344            .iter()
2345            .map(|line| ws.normalize_line_for_compare(line))
2346            .collect()
2347    };
2348    let (old_normalized, new_normalized) = (normalize(old_lines), normalize(new_lines));
2349    let old_keys: Vec<&str> = old_normalized.iter().map(Cow::as_ref).collect();
2350    let new_keys: Vec<&str> = new_normalized.iter().map(Cow::as_ref).collect();
2351    let mut interner = LineInterner::with_capacity(old_keys.len() + new_keys.len());
2352    let ids1 = interner.intern_all(&old_keys);
2353    let ids2 = interner.intern_all(&new_keys);
2354    git_xdiff::changed_flags(&ids1, &ids2, false)
2355}
2356
2357/// Convert per-token `changed` flags (Git xdiff style) into `similar::DiffOp`s.
2358fn changed_flags_to_ops(
2359    changed1: &[bool],
2360    changed2: &[bool],
2361    old_len: usize,
2362    new_len: usize,
2363) -> Vec<similar::DiffOp> {
2364    use similar::DiffOp;
2365    let mut ops: Vec<DiffOp> = Vec::new();
2366    let mut i = 0usize;
2367    let mut j = 0usize;
2368    while i < old_len || j < new_len {
2369        let del = i < old_len && changed1[i];
2370        let ins = j < new_len && changed2[j];
2371        if !del && !ins {
2372            if i < old_len && j < new_len {
2373                // Equal run on both sides.
2374                let start_i = i;
2375                let start_j = j;
2376                while i < old_len && j < new_len && !changed1[i] && !changed2[j] {
2377                    i += 1;
2378                    j += 1;
2379                }
2380                ops.push(DiffOp::Equal {
2381                    old_index: start_i,
2382                    new_index: start_j,
2383                    len: i - start_i,
2384                });
2385            } else if i < old_len {
2386                let start_i = i;
2387                while i < old_len {
2388                    i += 1;
2389                }
2390                ops.push(DiffOp::Delete {
2391                    old_index: start_i,
2392                    old_len: i - start_i,
2393                    new_index: j,
2394                });
2395            } else if j < new_len {
2396                let start_j = j;
2397                while j < new_len {
2398                    j += 1;
2399                }
2400                ops.push(DiffOp::Insert {
2401                    old_index: i,
2402                    new_index: start_j,
2403                    new_len: j - start_j,
2404                });
2405            }
2406        } else {
2407            // Changed run: consecutive deletions then/and insertions.
2408            let start_i = i;
2409            let start_j = j;
2410            while i < old_len && changed1[i] {
2411                i += 1;
2412            }
2413            while j < new_len && changed2[j] {
2414                j += 1;
2415            }
2416            let dlen = i - start_i;
2417            let ilen = j - start_j;
2418            if dlen > 0 && ilen > 0 {
2419                ops.push(DiffOp::Replace {
2420                    old_index: start_i,
2421                    old_len: dlen,
2422                    new_index: start_j,
2423                    new_len: ilen,
2424                });
2425            } else if dlen > 0 {
2426                ops.push(DiffOp::Delete {
2427                    old_index: start_i,
2428                    old_len: dlen,
2429                    new_index: start_j,
2430                });
2431            } else if ilen > 0 {
2432                ops.push(DiffOp::Insert {
2433                    old_index: start_i,
2434                    new_index: start_j,
2435                    new_len: ilen,
2436                });
2437            }
2438        }
2439    }
2440    ops
2441}
2442
2443/// Line-diff ops for string slices after Git `xdl_change_compact` (and optional indent heuristic).
2444#[must_use]
2445pub fn diff_slice_ops_compacted(
2446    old_lines: &[&str],
2447    new_lines: &[&str],
2448    algorithm: similar::Algorithm,
2449    indent_heuristic: bool,
2450) -> Vec<similar::DiffOp> {
2451    diff_indent_heuristic::diff_slice_ops_compacted(
2452        old_lines,
2453        new_lines,
2454        algorithm,
2455        indent_heuristic,
2456    )
2457}
2458
2459/// Compacted line-diff ops from Git's xdiff Myers engine over precomputed line ids for each side
2460/// (default blame path; faster than `similar` on large files).
2461#[must_use]
2462///
2463/// `shared_counts` and `shared_records` describe identical records of both files left out of
2464/// the slices, as for [`git_xdiff::changed_flags_with_shared`]; pass `&[]` and `0` for whole files.
2465#[allow(clippy::too_many_arguments)]
2466pub(crate) fn line_diff_ops_git_xdiff_compacted_ids(
2467    old_ids: &[u32],
2468    new_ids: &[u32],
2469    shared_counts: &[u32],
2470    shared_records: usize,
2471    old_lines: &[&str],
2472    new_lines: &[&str],
2473    indent_heuristic: bool,
2474    ws: super::WhitespaceMode,
2475) -> Vec<similar::DiffOp> {
2476    let (changed1, changed2) = git_xdiff::changed_flags_with_shared(
2477        old_ids,
2478        new_ids,
2479        shared_counts,
2480        shared_records,
2481        false,
2482    );
2483    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2484    diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
2485        &ops,
2486        old_lines,
2487        new_lines,
2488        indent_heuristic,
2489        ws,
2490    )
2491}
2492
2493/// Whether each new-side line is marked changed by Git's xdiff engine (after change compaction).
2494#[cfg(test)]
2495#[must_use]
2496pub(crate) fn xdiff_new_line_changed_flags(
2497    old_lines: &[&str],
2498    new_lines: &[&str],
2499    indent_heuristic: bool,
2500) -> Vec<bool> {
2501    let mut interner =
2502        crate::line_interner::LineInterner::with_capacity(old_lines.len() + new_lines.len());
2503    let ids1 = interner.intern_all(old_lines);
2504    let ids2 = interner.intern_all(new_lines);
2505    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
2506    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2507    let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
2508        &ops,
2509        old_lines,
2510        new_lines,
2511        indent_heuristic,
2512    );
2513    let mut flags = vec![false; new_lines.len()];
2514    for op in compacted {
2515        match op.tag() {
2516            similar::DiffTag::Insert | similar::DiffTag::Replace => {
2517                for idx in op.new_range() {
2518                    if idx < flags.len() {
2519                        flags[idx] = true;
2520                    }
2521                }
2522            }
2523            similar::DiffTag::Equal | similar::DiffTag::Delete => {}
2524        }
2525    }
2526    flags
2527}
2528
2529/// The kind of change between two sides of a diff.
2530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2531#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2532#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
2533pub enum DiffStatus {
2534    /// File was added.
2535    Added,
2536    /// File was deleted.
2537    Deleted,
2538    /// File was modified (content or mode change).
2539    Modified,
2540    /// File was renamed (with optional content change).
2541    Renamed,
2542    /// File was copied.
2543    Copied,
2544    /// File type changed (e.g. regular → symlink).
2545    TypeChanged,
2546    /// Unmerged (conflict).
2547    Unmerged,
2548}
2549
2550impl DiffStatus {
2551    /// Single-character status letter used in raw diff output.
2552    #[must_use]
2553    pub fn letter(&self) -> char {
2554        match self {
2555            Self::Added => 'A',
2556            Self::Deleted => 'D',
2557            Self::Modified => 'M',
2558            Self::Renamed => 'R',
2559            Self::Copied => 'C',
2560            Self::TypeChanged => 'T',
2561            Self::Unmerged => 'U',
2562        }
2563    }
2564
2565    /// Status for a path present on both sides with differing content or mode.
2566    ///
2567    /// Returns [`DiffStatus::TypeChanged`] when the object type bits of the two
2568    /// modes differ (regular file, symlink, gitlink) and [`DiffStatus::Modified`]
2569    /// otherwise, so a permission-only change stays `Modified`. Every diff mode
2570    /// (tree, index, work tree) shares this rule.
2571    #[must_use]
2572    pub(crate) fn for_content_change(old_mode: u32, new_mode: u32) -> Self {
2573        if old_mode & 0o170000 != new_mode & 0o170000 {
2574            Self::TypeChanged
2575        } else {
2576            Self::Modified
2577        }
2578    }
2579}
2580
2581/// A single diff entry representing one changed path.
2582#[derive(Debug, Clone, PartialEq, Eq)]
2583#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2584pub struct DiffEntry {
2585    /// The status of this change.
2586    pub status: DiffStatus,
2587    /// Path in the "old" side (None for Added).
2588    pub old_path: Option<String>,
2589    /// Path in the "new" side (None for Deleted).
2590    pub new_path: Option<String>,
2591    /// Old file mode ([`FileMode::ABSENT`] for Added).
2592    pub old_mode: FileMode,
2593    /// New file mode ([`FileMode::ABSENT`] for Deleted).
2594    pub new_mode: FileMode,
2595    /// Old object ID (zero OID for Added).
2596    pub old_oid: ObjectId,
2597    /// New object ID (zero OID for Deleted, or for Modified index↔worktree / tree↔worktree
2598    /// entries whose content lives only on disk — raw output uses the null OID on that side).
2599    pub new_oid: ObjectId,
2600    /// Similarity score (0–100) for renames/copies.
2601    pub score: Option<u32>,
2602}
2603
2604impl DiffEntry {
2605    /// The primary path for display (new_path for adds, old_path for deletes).
2606    #[must_use]
2607    pub fn path(&self) -> &str {
2608        self.new_path
2609            .as_deref()
2610            .or(self.old_path.as_deref())
2611            .unwrap_or("")
2612    }
2613
2614    /// Return a human-oriented path display for this entry.
2615    ///
2616    /// For renames and copies this returns `old -> new`; for all other entry
2617    /// kinds this returns the primary path.
2618    #[must_use]
2619    pub fn display_path(&self) -> String {
2620        match self.status {
2621            DiffStatus::Renamed | DiffStatus::Copied => {
2622                let old = self.old_path.as_deref().unwrap_or("");
2623                let new = self.new_path.as_deref().unwrap_or("");
2624                if old.is_empty() || new.is_empty() {
2625                    self.path().to_owned()
2626                } else {
2627                    format!("{old} -> {new}")
2628                }
2629            }
2630            _ => self.path().to_owned(),
2631        }
2632    }
2633}
2634
2635/// Return the zero ObjectId.
2636#[must_use]
2637pub fn zero_oid() -> ObjectId {
2638    ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
2639        // This should never fail since we pass exactly 20 bytes
2640        panic!("internal error: failed to create zero OID");
2641    })
2642}
2643
2644/// Return the ObjectId for the empty blob object.
2645#[must_use]
2646pub fn empty_blob_oid() -> ObjectId {
2647    ObjectId::from_hex("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap_or_else(|_| {
2648        // This should never fail since the object ID literal is valid.
2649        panic!("internal error: failed to create empty blob OID");
2650    })
2651}
2652
2653// ── Tree-to-tree diff ───────────────────────────────────────────────
2654
2655/// Compare two trees and return the list of changed entries.
2656///
2657/// # Parameters
2658///
2659/// - `odb` — object database to read tree objects from.
2660/// - `old_tree_oid` — OID of the old tree (or `None` for comparison against empty).
2661/// - `new_tree_oid` — OID of the new tree (or `None` for comparison against empty).
2662/// - `prefix` — path prefix for nested tree recursion (empty string for root).
2663///
2664/// # Errors
2665///
2666/// Returns errors from object database reads.
2667pub fn diff_trees(
2668    odb: &Odb,
2669    old_tree_oid: Option<&ObjectId>,
2670    new_tree_oid: Option<&ObjectId>,
2671    prefix: &str,
2672) -> Result<Vec<DiffEntry>> {
2673    crate::trace::trace_span!("grit.diff.tree");
2674    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, false, None)
2675}
2676
2677/// Like [`diff_trees`], but when `index` matches `old_tree_oid` deleted subtrees are expanded
2678/// from sorted index entries instead of reading every tree object from the ODB.
2679pub(crate) fn diff_trees_with_index(
2680    odb: &Odb,
2681    index: &Index,
2682    old_tree_oid: Option<&ObjectId>,
2683    new_tree_oid: Option<&ObjectId>,
2684    prefix: &str,
2685    trust_index_for_old_tree: bool,
2686) -> Result<Vec<DiffEntry>> {
2687    let hint = (trust_index_for_old_tree
2688        && old_tree_oid.is_some()
2689        && index.entries_sorted
2690        && !index.entries.iter().any(|e| e.stage() != 0))
2691    .then_some(DiffTreesWalkHint {
2692        index: Some(index),
2693        old_tree_matches: true,
2694        ..DiffTreesWalkHint::default()
2695    });
2696    diff_trees_opts(
2697        odb,
2698        old_tree_oid,
2699        new_tree_oid,
2700        prefix,
2701        false,
2702        hint.as_ref(),
2703    )
2704}
2705
2706/// Like [`diff_trees`], but subtrees at directories `index` holds as sparse-directory rows are
2707/// not descended into, so no entry lies inside one. Paths of those directories themselves (a
2708/// blob replaced by a tree, say) are still reported.
2709pub(crate) fn diff_trees_outside_sparse_directories(
2710    odb: &Odb,
2711    index: &Index,
2712    old_tree_oid: Option<&ObjectId>,
2713    new_tree_oid: Option<&ObjectId>,
2714) -> Result<Vec<DiffEntry>> {
2715    crate::trace::trace_span!("grit.diff.tree");
2716    let hint = index
2717        .has_sparse_directory_placeholders()
2718        .then_some(DiffTreesWalkHint {
2719            index: Some(index),
2720            skip_sparse_directories: true,
2721            ..DiffTreesWalkHint::default()
2722        });
2723    diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, hint.as_ref())
2724}
2725
2726/// Like [`diff_trees`], but only paths `pathspecs` select (Git's pathspec-limited tree diff).
2727///
2728/// Directories no pathspec can reach are not read. An empty list selects every path.
2729pub(crate) fn diff_trees_for_pathspecs(
2730    odb: &Odb,
2731    old_tree_oid: Option<&ObjectId>,
2732    new_tree_oid: Option<&ObjectId>,
2733    pathspecs: &[String],
2734) -> Result<Vec<DiffEntry>> {
2735    if pathspecs.is_empty() {
2736        return diff_trees(odb, old_tree_oid, new_tree_oid, "");
2737    }
2738    crate::trace::trace_span!("grit.diff.tree");
2739    let hint = DiffTreesWalkHint {
2740        pathspecs,
2741        ..DiffTreesWalkHint::default()
2742    };
2743    let mut entries = diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, Some(&hint))?;
2744    entries.retain(|entry| {
2745        let path = entry.path();
2746        !path.is_empty() && crate::pathspec::matches_pathspec_list(path, pathspecs)
2747    });
2748    Ok(entries)
2749}
2750
2751/// Directories a tree diff may skip, and an index that can stand in for the old tree.
2752#[derive(Clone, Copy, Default)]
2753struct DiffTreesWalkHint<'a> {
2754    index: Option<&'a Index>,
2755    /// `index` holds exactly the old tree's entries, so deleted subtrees may be listed from it.
2756    old_tree_matches: bool,
2757    /// Do not descend into directories `index` holds as sparse-directory rows.
2758    skip_sparse_directories: bool,
2759    /// When non-empty, do not descend into directories these pathspecs cannot match inside.
2760    pathspecs: &'a [String],
2761}
2762
2763impl DiffTreesWalkHint<'_> {
2764    /// Whether the walk must not enter directory `dir` (stored form, no trailing slash).
2765    fn skips(&self, dir: &str) -> bool {
2766        if dir.is_empty() {
2767            return false;
2768        }
2769        if !self.pathspecs.is_empty()
2770            && !crate::pathspec::pathspec_entry_is_relevant(dir, true, self.pathspecs)
2771        {
2772            return true;
2773        }
2774        let Some(index) = self.index.filter(|_| self.skip_sparse_directories) else {
2775            return false;
2776        };
2777        let mut row = crate::repo_relpath::storage_bytes(dir).into_owned();
2778        row.push(b'/');
2779        index
2780            .find_entry_pos(&row, 0)
2781            .is_some_and(|pos| index.entries[pos].is_sparse_directory_placeholder())
2782    }
2783}
2784
2785/// Count path-level differences between two trees without building a [`DiffEntry`] list.
2786///
2787/// Same semantics as [`diff_trees`], but avoids allocating diff records (issue #1064 commit hot path).
2788///
2789/// # Errors
2790///
2791/// Returns errors from object database reads.
2792pub fn diff_tree_path_change_count(
2793    odb: &Odb,
2794    old_tree_oid: Option<&ObjectId>,
2795    new_tree_oid: Option<&ObjectId>,
2796    prefix: &str,
2797) -> Result<usize> {
2798    if old_tree_oid == new_tree_oid {
2799        return Ok(0);
2800    }
2801    let old_entries = match old_tree_oid {
2802        Some(oid) => read_tree(odb, oid)?,
2803        None => Vec::new(),
2804    };
2805    let new_entries = match new_tree_oid {
2806        Some(oid) => read_tree(odb, oid)?,
2807        None => Vec::new(),
2808    };
2809    let mut count = 0usize;
2810    diff_tree_entries_count(odb, &old_entries, &new_entries, prefix, false, &mut count)?;
2811    Ok(count)
2812}
2813
2814/// Diff two trees and pair deleted/added paths as renames when content is similar enough.
2815///
2816/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
2817/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
2818///
2819/// # Errors
2820///
2821/// Propagates errors from [`diff_trees`].
2822pub fn diff_trees_with_renames(
2823    odb: &Odb,
2824    old_tree_oid: Option<&ObjectId>,
2825    new_tree_oid: Option<&ObjectId>,
2826    prefix: &str,
2827    rename_threshold: u32,
2828) -> Result<Vec<DiffEntry>> {
2829    let config = ConfigSet::new();
2830    let policy = ThreadPolicy::default();
2831    diff_trees_with_renames_and_policy(
2832        odb,
2833        old_tree_oid,
2834        new_tree_oid,
2835        prefix,
2836        rename_threshold,
2837        &config,
2838        &policy,
2839    )
2840}
2841
2842/// Like [`diff_trees_with_renames`], but honors `index.threads` / embedder thread policy.
2843///
2844/// # Errors
2845///
2846/// Propagates errors from [`diff_trees`].
2847pub fn diff_trees_with_renames_and_policy(
2848    odb: &Odb,
2849    old_tree_oid: Option<&ObjectId>,
2850    new_tree_oid: Option<&ObjectId>,
2851    prefix: &str,
2852    rename_threshold: u32,
2853    config: &ConfigSet,
2854    thread_policy: &ThreadPolicy,
2855) -> Result<Vec<DiffEntry>> {
2856    let mut entries = diff_trees(odb, old_tree_oid, new_tree_oid, prefix)?;
2857    if rename_threshold <= 100 {
2858        entries = detect_renames_with_policy(
2859            odb,
2860            None,
2861            entries,
2862            rename_threshold,
2863            config,
2864            thread_policy,
2865            None,
2866        )?;
2867    }
2868    Ok(entries)
2869}
2870
2871/// Return the tree OID stored in a commit object.
2872///
2873/// # Errors
2874///
2875/// Returns [`Error::InvalidRef`] when `commit_oid` is not a commit, and object read/parse
2876/// errors when it is missing or malformed.
2877pub fn tree_oid_of_commit(odb: &Odb, commit_oid: &ObjectId) -> Result<ObjectId> {
2878    odb.commit_tree(commit_oid).map_err(|err| match err {
2879        Error::UnexpectedObjectKind { .. } => {
2880            Error::InvalidRef(format!("invalid ref: {commit_oid} is not a commit"))
2881        }
2882        other => other,
2883    })
2884}
2885
2886/// Tree OID to use as one side of a tree diff when the id names a commit or a tree.
2887///
2888/// # Errors
2889///
2890/// Returns [`Error::InvalidRef`] when `oid` is not a commit or tree, and object read/parse
2891/// errors when it is missing or malformed.
2892pub(crate) fn tree_oid_for_diff_side(odb: &Odb, oid: &ObjectId) -> Result<ObjectId> {
2893    use crate::objects::{CommitRef, ObjectKind};
2894    let object = odb.read(oid)?;
2895    match object.kind {
2896        ObjectKind::Commit => Ok(CommitRef::parse(&object.data)?.tree()),
2897        ObjectKind::Tree => Ok(*oid),
2898        actual => Err(Error::InvalidRef(format!(
2899            "invalid ref: {oid} is not a commit or tree ({actual:?})"
2900        ))),
2901    }
2902}
2903
2904/// Like [`tree_oid_of_commit`], but uses the repository commit-graph cache when available.
2905///
2906/// # Errors
2907///
2908/// Returns object read/parse errors when `commit_oid` is missing or not a commit.
2909pub fn tree_oid_of_commit_in_repo(
2910    repo: &crate::repo::Repository,
2911    commit_oid: &ObjectId,
2912) -> Result<ObjectId> {
2913    if let Some(chain) = repo.odb().commit_graph_chain() {
2914        if let Some(tree) = chain.graph_commit_tree(commit_oid) {
2915            return Ok(tree);
2916        }
2917    }
2918    tree_oid_of_commit(repo.odb(), commit_oid)
2919}
2920
2921/// Diff the trees recorded at two commits (`base` is the old side, `tip` the new side).
2922///
2923/// # Errors
2924///
2925/// Propagates object read errors and tree diff failures.
2926pub fn diff_commits(odb: &Odb, base: &ObjectId, tip: &ObjectId) -> Result<Vec<DiffEntry>> {
2927    let base_tree = tree_oid_of_commit(odb, base)?;
2928    let tip_tree = tree_oid_of_commit(odb, tip)?;
2929    diff_trees(odb, Some(&base_tree), Some(&tip_tree), "")
2930}
2931
2932/// Diff the trees at two commits and pair deleted/added paths as renames when similar enough.
2933///
2934/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
2935/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
2936///
2937/// # Errors
2938///
2939/// Propagates object read errors and tree diff failures.
2940pub fn diff_commits_with_renames(
2941    odb: &Odb,
2942    base: &ObjectId,
2943    tip: &ObjectId,
2944    rename_threshold: u32,
2945) -> Result<Vec<DiffEntry>> {
2946    let config = ConfigSet::new();
2947    let policy = ThreadPolicy::default();
2948    diff_commits_with_renames_and_policy(odb, base, tip, rename_threshold, &config, &policy)
2949}
2950
2951/// Like [`diff_commits_with_renames`], but honors `index.threads` / embedder thread policy.
2952///
2953/// # Errors
2954///
2955/// Propagates object read errors and tree diff failures.
2956pub fn diff_commits_with_renames_and_policy(
2957    odb: &Odb,
2958    base: &ObjectId,
2959    tip: &ObjectId,
2960    rename_threshold: u32,
2961    config: &ConfigSet,
2962    thread_policy: &ThreadPolicy,
2963) -> Result<Vec<DiffEntry>> {
2964    let base_tree = tree_oid_of_commit(odb, base)?;
2965    let tip_tree = tree_oid_of_commit(odb, tip)?;
2966    diff_trees_with_renames_and_policy(
2967        odb,
2968        Some(&base_tree),
2969        Some(&tip_tree),
2970        "",
2971        rename_threshold,
2972        config,
2973        thread_policy,
2974    )
2975}
2976
2977/// Like [`diff_trees`], but emit entries for tree objects themselves in addition
2978/// to their recursive contents (Git's `diff-tree -t` behavior).
2979///
2980/// # Errors
2981///
2982/// Returns errors from object database reads.
2983pub fn diff_trees_show_tree_entries(
2984    odb: &Odb,
2985    old_tree_oid: Option<&ObjectId>,
2986    new_tree_oid: Option<&ObjectId>,
2987    prefix: &str,
2988) -> Result<Vec<DiffEntry>> {
2989    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, true, None)
2990}
2991
2992fn diff_subtrees_in_place(
2993    odb: &Odb,
2994    old_tree_oid: Option<&ObjectId>,
2995    new_tree_oid: Option<&ObjectId>,
2996    prefix: &str,
2997    show_trees: bool,
2998    index_hint: Option<&DiffTreesWalkHint<'_>>,
2999    result: &mut Vec<DiffEntry>,
3000) -> Result<()> {
3001    if index_hint.is_some_and(|hint| hint.skips(prefix)) {
3002        return Ok(());
3003    }
3004    if !show_trees {
3005        if let (Some(a), Some(b)) = (old_tree_oid, new_tree_oid) {
3006            if a == b {
3007                return Ok(());
3008            }
3009        }
3010    }
3011    // The ODB already budgets cached trees. Keep only active recursion frames
3012    // here so completing siblings can release trees evicted from that cache.
3013    let old_tree = old_tree_oid
3014        .map(|oid| odb.cached_tree_for_walk(oid))
3015        .transpose()?;
3016    let new_tree = new_tree_oid
3017        .map(|oid| odb.cached_tree_for_walk(oid))
3018        .transpose()?;
3019    let oid_len = odb.hash_algo().len();
3020    diff_tree_entries_opts(
3021        odb,
3022        WalkEntries::new(old_tree.as_ref(), oid_len),
3023        WalkEntries::new(new_tree.as_ref(), oid_len),
3024        prefix,
3025        show_trees,
3026        index_hint,
3027        result,
3028    )
3029}
3030
3031/// Entries of a tree from [`Odb::cached_tree_for_walk`] in stored order, borrowing names
3032/// from it.
3033enum WalkEntries<'a> {
3034    Parsed(std::slice::Iter<'a, TreeEntry>),
3035    Raw(TreeRefIter<'a>),
3036}
3037
3038impl<'a> WalkEntries<'a> {
3039    /// Iterate `tree`, or nothing when the side is absent.
3040    fn new(tree: Option<&'a SessionTree>, oid_len: usize) -> Self {
3041        match tree {
3042            Some(SessionTree::Parsed(entries)) => Self::Parsed(entries.iter()),
3043            Some(SessionTree::Raw(data)) => Self::Raw(TreeRefIter::with_oid_len(data, oid_len)),
3044            None => Self::Parsed([].iter()),
3045        }
3046    }
3047
3048    fn next_entry(&mut self) -> Result<Option<TreeEntryRef<'a>>> {
3049        match self {
3050            Self::Parsed(entries) => Ok(entries.next().map(|entry| TreeEntryRef {
3051                mode: entry.mode,
3052                name: &entry.name,
3053                oid: entry.oid,
3054            })),
3055            Self::Raw(entries) => entries.next().transpose(),
3056        }
3057    }
3058}
3059
3060fn diff_trees_opts(
3061    odb: &Odb,
3062    old_tree_oid: Option<&ObjectId>,
3063    new_tree_oid: Option<&ObjectId>,
3064    prefix: &str,
3065    show_trees: bool,
3066    index_hint: Option<&DiffTreesWalkHint<'_>>,
3067) -> Result<Vec<DiffEntry>> {
3068    let mut result = Vec::new();
3069    diff_subtrees_in_place(
3070        odb,
3071        old_tree_oid,
3072        new_tree_oid,
3073        prefix,
3074        show_trees,
3075        index_hint,
3076        &mut result,
3077    )?;
3078    Ok(result)
3079}
3080
3081#[cfg(test)]
3082use std::cell::RefCell;
3083#[cfg(test)]
3084use std::rc::Rc;
3085#[cfg(test)]
3086use std::sync::atomic::{AtomicUsize, Ordering};
3087
3088#[cfg(test)]
3089thread_local! {
3090    static DIFF_TREE_READ_HOOK: RefCell<Option<Rc<AtomicUsize>>> = const { RefCell::new(None) };
3091}
3092
3093/// Install a per-thread tree read counter for unit tests (cleared when `hook` is `None`).
3094#[cfg(test)]
3095pub(crate) fn set_diff_tree_read_hook_for_tests(hook: Option<Rc<AtomicUsize>>) {
3096    DIFF_TREE_READ_HOOK.with(|cell| *cell.borrow_mut() = hook);
3097}
3098
3099/// Read and parse a tree object from the ODB (no session cache).
3100fn read_tree_from_odb(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
3101    #[cfg(test)]
3102    if let Some(counter) = DIFF_TREE_READ_HOOK.with(|cell| cell.borrow().clone()) {
3103        counter.fetch_add(1, Ordering::Relaxed);
3104    }
3105    Ok((*odb.cached_tree_entries(oid)?).clone())
3106}
3107
3108/// Read and parse a tree object from the ODB.
3109fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
3110    read_tree_from_odb(odb, oid)
3111}
3112
3113fn diff_tree_entries_count(
3114    odb: &Odb,
3115    old: &[TreeEntry],
3116    new: &[TreeEntry],
3117    prefix: &str,
3118    show_trees: bool,
3119    count: &mut usize,
3120) -> Result<()> {
3121    let mut oi = 0;
3122    let mut ni = 0;
3123
3124    while oi < old.len() || ni < new.len() {
3125        match (old.get(oi), new.get(ni)) {
3126            (Some(o), Some(n)) => {
3127                let cmp = crate::objects::tree_entry_cmp(
3128                    &o.name,
3129                    is_tree_mode(o.mode),
3130                    &n.name,
3131                    is_tree_mode(n.mode),
3132                );
3133                match cmp {
3134                    std::cmp::Ordering::Less => {
3135                        emit_deleted_count(odb, o, prefix, show_trees, count)?;
3136                        oi += 1;
3137                    }
3138                    std::cmp::Ordering::Greater => {
3139                        emit_added_count(odb, n, prefix, show_trees, count)?;
3140                        ni += 1;
3141                    }
3142                    std::cmp::Ordering::Equal => {
3143                        if o.oid != n.oid
3144                            || crate::index::canon_mode(o.mode) != crate::index::canon_mode(n.mode)
3145                        {
3146                            let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
3147                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3148                                if show_trees {
3149                                    *count += 1;
3150                                }
3151                                *count += diff_tree_path_change_count(
3152                                    odb,
3153                                    Some(&o.oid),
3154                                    Some(&n.oid),
3155                                    &path,
3156                                )?;
3157                            } else if is_tree_mode(o.mode) != is_tree_mode(n.mode) {
3158                                emit_deleted_count(odb, o, prefix, show_trees, count)?;
3159                                emit_added_count(odb, n, prefix, show_trees, count)?;
3160                            } else {
3161                                *count += 1;
3162                            }
3163                        }
3164                        oi += 1;
3165                        ni += 1;
3166                    }
3167                }
3168            }
3169            (Some(o), None) => {
3170                emit_deleted_count(odb, o, prefix, show_trees, count)?;
3171                oi += 1;
3172            }
3173            (None, Some(n)) => {
3174                emit_added_count(odb, n, prefix, show_trees, count)?;
3175                ni += 1;
3176            }
3177            (None, None) => break,
3178        }
3179    }
3180
3181    Ok(())
3182}
3183
3184fn emit_deleted_count(
3185    odb: &Odb,
3186    entry: &TreeEntry,
3187    prefix: &str,
3188    show_trees: bool,
3189    count: &mut usize,
3190) -> Result<()> {
3191    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3192    if is_tree_mode(entry.mode) {
3193        if show_trees {
3194            *count += 1;
3195        }
3196        *count += diff_tree_path_change_count(odb, Some(&entry.oid), None, &path)?;
3197    } else {
3198        *count += 1;
3199    }
3200    Ok(())
3201}
3202
3203fn emit_added_count(
3204    odb: &Odb,
3205    entry: &TreeEntry,
3206    prefix: &str,
3207    show_trees: bool,
3208    count: &mut usize,
3209) -> Result<()> {
3210    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3211    if is_tree_mode(entry.mode) {
3212        if show_trees {
3213            *count += 1;
3214        }
3215        *count += diff_tree_path_change_count(odb, None, Some(&entry.oid), &path)?;
3216    } else {
3217        *count += 1;
3218    }
3219    Ok(())
3220}
3221
3222/// Compare two sorted streams of tree entries, recursing into subtrees.
3223fn diff_tree_entries_opts(
3224    odb: &Odb,
3225    mut old: WalkEntries<'_>,
3226    mut new: WalkEntries<'_>,
3227    prefix: &str,
3228    show_trees: bool,
3229    index_hint: Option<&DiffTreesWalkHint<'_>>,
3230    result: &mut Vec<DiffEntry>,
3231) -> Result<()> {
3232    let mut old_entry = old.next_entry()?;
3233    let mut new_entry = new.next_entry()?;
3234
3235    loop {
3236        match (&old_entry, &new_entry) {
3237            (Some(o), Some(n)) => {
3238                let cmp = crate::objects::tree_entry_cmp(
3239                    o.name,
3240                    is_tree_mode(o.mode),
3241                    n.name,
3242                    is_tree_mode(n.mode),
3243                );
3244                match cmp {
3245                    std::cmp::Ordering::Less => {
3246                        // Old entry not in new → deleted
3247                        emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3248                        old_entry = old.next_entry()?;
3249                    }
3250                    std::cmp::Ordering::Greater => {
3251                        // New entry not in old → added
3252                        emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3253                        new_entry = new.next_entry()?;
3254                    }
3255                    std::cmp::Ordering::Equal => {
3256                        // Both present — check for changes
3257                        if o.oid != n.oid
3258                            || crate::index::canon_mode(o.mode) != crate::index::canon_mode(n.mode)
3259                        {
3260                            let path = crate::repo_relpath::format_path_stored(prefix, o.name);
3261                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3262                                // Both are trees
3263                                if show_trees {
3264                                    result.push(DiffEntry {
3265                                        status: DiffStatus::Modified,
3266                                        old_path: Some(path.clone()),
3267                                        new_path: Some(path.clone()),
3268                                        old_mode: FileMode::new(crate::index::canon_mode(o.mode)),
3269                                        new_mode: FileMode::new(crate::index::canon_mode(n.mode)),
3270                                        old_oid: o.oid,
3271                                        new_oid: n.oid,
3272                                        score: None,
3273                                    });
3274                                }
3275                                diff_subtrees_in_place(
3276                                    odb,
3277                                    Some(&o.oid),
3278                                    Some(&n.oid),
3279                                    &path,
3280                                    show_trees,
3281                                    index_hint,
3282                                    result,
3283                                )?;
3284                            } else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
3285                                // Tree → blob: delete tree contents, add blob
3286                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3287                                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3288                            } else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3289                                // Blob → tree: delete blob, add tree contents
3290                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3291                                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3292                            } else {
3293                                result.push(DiffEntry {
3294                                    status: DiffStatus::for_content_change(o.mode, n.mode),
3295                                    old_path: Some(path.clone()),
3296                                    new_path: Some(path),
3297                                    old_mode: FileMode::new(crate::index::canon_mode(o.mode)),
3298                                    new_mode: FileMode::new(crate::index::canon_mode(n.mode)),
3299                                    old_oid: o.oid,
3300                                    new_oid: n.oid,
3301                                    score: None,
3302                                });
3303                            }
3304                        }
3305                        old_entry = old.next_entry()?;
3306                        new_entry = new.next_entry()?;
3307                    }
3308                }
3309            }
3310            (Some(o), None) => {
3311                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3312                old_entry = old.next_entry()?;
3313            }
3314            (None, Some(n)) => {
3315                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3316                new_entry = new.next_entry()?;
3317            }
3318            (None, None) => break,
3319        }
3320    }
3321
3322    Ok(())
3323}
3324
3325fn emit_deleted_tree_from_index(index: &Index, dir_prefix: &str, result: &mut Vec<DiffEntry>) {
3326    let scan_prefix: Vec<u8> = if dir_prefix.is_empty() {
3327        Vec::new()
3328    } else {
3329        format!("{dir_prefix}/").into_bytes()
3330    };
3331    if index.entries_sorted {
3332        let start = if scan_prefix.is_empty() {
3333            0
3334        } else {
3335            index
3336                .entries
3337                .partition_point(|e| e.path.as_slice() < scan_prefix.as_slice())
3338        };
3339        for ie in index.entries[start..].iter() {
3340            if ie.stage() != 0 {
3341                continue;
3342            }
3343            if !scan_prefix.is_empty() && !ie.path.starts_with(&scan_prefix) {
3344                break;
3345            }
3346            if scan_prefix.is_empty() && ie.path.contains(&b'/') {
3347                continue;
3348            }
3349            let path = String::from_utf8_lossy(&ie.path).into_owned();
3350            result.push(DiffEntry {
3351                status: DiffStatus::Deleted,
3352                old_path: Some(path),
3353                new_path: None,
3354                old_mode: FileMode::new(ie.mode),
3355                new_mode: FileMode::ABSENT,
3356                old_oid: ie.oid,
3357                new_oid: zero_oid(),
3358                score: None,
3359            });
3360        }
3361        return;
3362    }
3363    for ie in &index.entries {
3364        if ie.stage() != 0 {
3365            continue;
3366        }
3367        let Ok(path) = std::str::from_utf8(&ie.path) else {
3368            continue;
3369        };
3370        let under = if dir_prefix.is_empty() {
3371            !path.contains('/')
3372        } else if path == dir_prefix {
3373            false
3374        } else {
3375            path.strip_prefix(dir_prefix)
3376                .is_some_and(|rest| rest.starts_with('/'))
3377        };
3378        if !under {
3379            continue;
3380        }
3381        result.push(DiffEntry {
3382            status: DiffStatus::Deleted,
3383            old_path: Some(path.to_owned()),
3384            new_path: None,
3385            old_mode: FileMode::new(ie.mode),
3386            new_mode: FileMode::ABSENT,
3387            old_oid: ie.oid,
3388            new_oid: zero_oid(),
3389            score: None,
3390        });
3391    }
3392}
3393
3394fn emit_deleted_opts(
3395    odb: &Odb,
3396    entry: &TreeEntryRef<'_>,
3397    prefix: &str,
3398    show_trees: bool,
3399    index_hint: Option<&DiffTreesWalkHint<'_>>,
3400    result: &mut Vec<DiffEntry>,
3401) -> Result<()> {
3402    let path = crate::repo_relpath::format_path_stored(prefix, entry.name);
3403    if is_tree_mode(entry.mode) {
3404        if show_trees {
3405            result.push(DiffEntry {
3406                status: DiffStatus::Deleted,
3407                old_path: Some(path.clone()),
3408                new_path: None,
3409                old_mode: FileMode::new(entry.mode),
3410                new_mode: FileMode::ABSENT,
3411                old_oid: entry.oid,
3412                new_oid: zero_oid(),
3413                score: None,
3414            });
3415        }
3416        if let Some(index) = index_hint
3417            .filter(|hint| hint.old_tree_matches)
3418            .and_then(|hint| hint.index)
3419            .filter(|index| !index.has_sparse_directory_placeholders())
3420        {
3421            emit_deleted_tree_from_index(index, &path, result);
3422            return Ok(());
3423        }
3424        diff_subtrees_in_place(
3425            odb,
3426            Some(&entry.oid),
3427            None,
3428            &path,
3429            show_trees,
3430            index_hint,
3431            result,
3432        )?;
3433    } else {
3434        result.push(DiffEntry {
3435            status: DiffStatus::Deleted,
3436            old_path: Some(path.clone()),
3437            new_path: None,
3438            old_mode: FileMode::new(crate::index::canon_mode(entry.mode)),
3439            new_mode: FileMode::ABSENT,
3440            old_oid: entry.oid,
3441            new_oid: zero_oid(),
3442            score: None,
3443        });
3444    }
3445    Ok(())
3446}
3447
3448fn emit_added_opts(
3449    odb: &Odb,
3450    entry: &TreeEntryRef<'_>,
3451    prefix: &str,
3452    show_trees: bool,
3453    index_hint: Option<&DiffTreesWalkHint<'_>>,
3454    result: &mut Vec<DiffEntry>,
3455) -> Result<()> {
3456    let path = crate::repo_relpath::format_path_stored(prefix, entry.name);
3457    if is_tree_mode(entry.mode) {
3458        if show_trees {
3459            result.push(DiffEntry {
3460                status: DiffStatus::Added,
3461                old_path: None,
3462                new_path: Some(path.clone()),
3463                old_mode: FileMode::ABSENT,
3464                new_mode: FileMode::new(entry.mode),
3465                old_oid: zero_oid(),
3466                new_oid: entry.oid,
3467                score: None,
3468            });
3469        }
3470        diff_subtrees_in_place(
3471            odb,
3472            None,
3473            Some(&entry.oid),
3474            &path,
3475            show_trees,
3476            index_hint,
3477            result,
3478        )?;
3479    } else {
3480        result.push(DiffEntry {
3481            status: DiffStatus::Added,
3482            old_path: None,
3483            new_path: Some(path),
3484            old_mode: FileMode::ABSENT,
3485            new_mode: FileMode::new(crate::index::canon_mode(entry.mode)),
3486            old_oid: zero_oid(),
3487            new_oid: entry.oid,
3488            score: None,
3489        });
3490    }
3491    Ok(())
3492}
3493
3494// ── Index-to-tree diff (staged changes) ─────────────────────────────
3495
3496/// Compare the index against a tree (usually HEAD's tree).
3497///
3498/// This shows "staged" changes — what would be committed.
3499///
3500/// # Parameters
3501///
3502/// - `odb` — object database.
3503/// - `index` — the current index.
3504/// - `tree_oid` — the tree to compare against (e.g. HEAD's tree), or `None`
3505///   for comparison against an empty tree (initial commit).
3506///
3507/// # Errors
3508///
3509/// Returns errors from ODB reads.
3510///
3511/// When `ignore_submodules` is true, gitlink (`160000`) paths are omitted from the diff, matching
3512/// Git's `require_clean_work_tree(..., ignore_submodules=1)` used by `git rebase` / `git pull`.
3513pub fn diff_index_to_tree(
3514    odb: &Odb,
3515    index: &Index,
3516    tree_oid: Option<&ObjectId>,
3517    ignore_submodules: bool,
3518) -> Result<Vec<DiffEntry>> {
3519    match index_cache_tree_walk(index, tree_oid) {
3520        CacheTreeWalk::Fast {
3521            head_tree,
3522            cache_root,
3523        } => {
3524            diff_index_to_tree_with_cache_tree(odb, index, head_tree, cache_root, ignore_submodules)
3525        }
3526        CacheTreeWalk::Flatten => {
3527            diff_index_to_tree_flatten(odb, index, tree_oid, ignore_submodules)
3528        }
3529    }
3530}
3531
3532/// Diff the index against a tree and pair deleted/added paths as renames when similar enough.
3533///
3534/// `rename_threshold` is Git's `-M` percentage (50 is the usual default); pass `100` to skip
3535/// rename detection.
3536///
3537/// # Errors
3538///
3539/// Propagates errors from [`diff_index_to_tree`].
3540pub fn diff_index_to_tree_with_renames(
3541    odb: &Odb,
3542    index: &Index,
3543    tree_oid: Option<&ObjectId>,
3544    ignore_submodules: bool,
3545    rename_threshold: u32,
3546    config: &ConfigSet,
3547    thread_policy: &ThreadPolicy,
3548) -> Result<Vec<DiffEntry>> {
3549    let entries = diff_index_to_tree(odb, index, tree_oid, ignore_submodules)?;
3550    apply_rename_detection_to_diff_entries(
3551        odb,
3552        entries,
3553        None,
3554        rename_threshold,
3555        config,
3556        thread_policy,
3557    )
3558}
3559
3560/// Apply Git `-M` rename detection to a flat diff (index↔tree, tree↔tree, or index↔worktree).
3561///
3562/// Pass `work_root` when added sides may be read from the working tree (index↔worktree diffs).
3563/// Use `rename_threshold` `100` to leave entries unchanged.
3564///
3565/// # Errors
3566///
3567/// Propagates object read failures while scoring renames.
3568pub fn apply_rename_detection_to_diff_entries(
3569    odb: &Odb,
3570    entries: Vec<DiffEntry>,
3571    work_root: Option<&Path>,
3572    rename_threshold: u32,
3573    config: &ConfigSet,
3574    thread_policy: &ThreadPolicy,
3575) -> Result<Vec<DiffEntry>> {
3576    if entries.is_empty() || rename_threshold >= 100 {
3577        return Ok(entries);
3578    }
3579    if work_root.is_some() {
3580        let opts = DiffPostprocessOptions {
3581            rename_threshold,
3582            ..DiffPostprocessOptions::default()
3583        };
3584        postprocess_diff_entries(
3585            odb,
3586            entries,
3587            None,
3588            work_root,
3589            None,
3590            config,
3591            thread_policy,
3592            &opts,
3593        )
3594    } else {
3595        detect_renames_with_policy(
3596            odb,
3597            None,
3598            entries,
3599            rename_threshold,
3600            config,
3601            thread_policy,
3602            None,
3603        )
3604    }
3605}
3606
3607/// Diff the index against the working tree and pair deleted/added paths as renames when similar.
3608///
3609/// Runs [`diff_index_to_worktree_with_options`], then [`apply_rename_detection_to_diff_entries`]
3610/// with `work_root` set to `work_tree`. Untracked worktree paths are not diff entries (see
3611/// [`crate::porcelain::status`]); they are never fed into rename detection here.
3612///
3613/// # Errors
3614///
3615/// Propagates errors from the index↔worktree diff or rename post-processing.
3616pub fn diff_index_to_worktree_with_renames(
3617    repo: &Repository,
3618    index: &mut Index,
3619    work_tree: &Path,
3620    options: DiffIndexToWorktreeOptions,
3621    rename_threshold: u32,
3622) -> Result<(Vec<DiffEntry>, bool)> {
3623    let config = repo.config()?.as_ref().clone();
3624    let thread_policy = repo.thread_policy();
3625    let odb = repo.odb();
3626    let (entries, changed) = diff_index_to_worktree_with_options(odb, index, work_tree, options)?;
3627    let entries = apply_rename_detection_to_diff_entries(
3628        odb,
3629        entries,
3630        Some(work_tree),
3631        rename_threshold,
3632        &config,
3633        &thread_policy,
3634    )?;
3635    Ok((entries, changed))
3636}
3637
3638enum CacheTreeWalk<'a> {
3639    Fast {
3640        head_tree: &'a ObjectId,
3641        cache_root: &'a CacheTreeNode,
3642    },
3643    Flatten,
3644}
3645
3646/// Whether the index may use the cache-tree walker (possibly with an invalid root and valid
3647/// descendant nodes after partial invalidation).
3648fn index_cache_tree_walk<'a>(
3649    index: &'a Index,
3650    tree_oid: Option<&'a ObjectId>,
3651) -> CacheTreeWalk<'a> {
3652    let Some(head_tree) = tree_oid else {
3653        return CacheTreeWalk::Flatten;
3654    };
3655    let Some(cache_root) = index.cache_tree.as_ref() else {
3656        return CacheTreeWalk::Flatten;
3657    };
3658    if index.entries.iter().any(|e| e.stage() != 0) {
3659        return CacheTreeWalk::Flatten;
3660    }
3661    CacheTreeWalk::Fast {
3662        head_tree,
3663        cache_root,
3664    }
3665}
3666
3667/// Flatten-tree index-vs-tree diff (used when the index has no cache-tree extension).
3668fn diff_index_to_tree_flatten(
3669    odb: &Odb,
3670    index: &Index,
3671    tree_oid: Option<&ObjectId>,
3672    ignore_submodules: bool,
3673) -> Result<Vec<DiffEntry>> {
3674    let sparse_index = index.has_sparse_directory_placeholders();
3675    // Flatten the tree into a sorted list of (path, mode, oid)
3676    let tree_entries = match tree_oid {
3677        Some(oid) if sparse_index => flatten_tree_with_index(odb, oid, "", Some(index))?,
3678        Some(oid) => flatten_tree(odb, oid, "")?,
3679        None => Vec::new(),
3680    };
3681
3682    // Build maps keyed by path
3683    let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
3684        std::collections::BTreeMap::new();
3685    for entry in &tree_entries {
3686        tree_map.insert(&entry.path, entry);
3687    }
3688
3689    let mut result = Vec::new();
3690    let mut stage0_paths = std::collections::BTreeSet::new();
3691    let mut unmerged_modes: std::collections::BTreeMap<String, (u8, u32)> =
3692        std::collections::BTreeMap::new();
3693
3694    // Check index entries against tree
3695    for ie in &index.entries {
3696        let path = String::from_utf8_lossy(&ie.path).to_string();
3697        if ie.stage() == 0 && ie.intent_to_add() {
3698            // Intent-to-add entries are not "staged" for diff-index / status
3699            // (matches Git: `git diff --cached` is empty for `-N` paths).
3700            continue;
3701        }
3702        if ie.stage() == 0 && ie.is_sparse_directory_placeholder() {
3703            let dir = path.strip_suffix('/').unwrap_or(path.as_str());
3704            let index_flat = flatten_tree(odb, &ie.oid, dir)?;
3705            let head_flat = match tree_oid {
3706                None => Vec::new(),
3707                Some(head_root) => match subtree_oid_at_path(odb, head_root, dir)? {
3708                    None => Vec::new(),
3709                    Some(head_sub) if head_sub == ie.oid => {
3710                        continue;
3711                    }
3712                    Some(head_sub) => flatten_tree(odb, &head_sub, dir)?,
3713                },
3714            };
3715            diff_flattened_subtrees(
3716                &head_flat,
3717                &index_flat,
3718                ignore_submodules,
3719                &mut result,
3720                &mut stage0_paths,
3721                &mut tree_map,
3722            );
3723            continue;
3724        }
3725        if ie.stage() != 0 {
3726            let rank = match ie.stage() {
3727                2 => 0u8,
3728                3 => 1u8,
3729                1 => 2u8,
3730                _ => 3u8,
3731            };
3732            match unmerged_modes.get(&path) {
3733                Some((existing_rank, _)) if *existing_rank <= rank => {}
3734                _ => {
3735                    unmerged_modes.insert(path, (rank, ie.mode));
3736                }
3737            }
3738            continue;
3739        }
3740        if ignore_submodules && ie.mode == 0o160000 {
3741            let _ = tree_map.remove(path.as_str());
3742            stage0_paths.insert(path.clone());
3743            continue;
3744        }
3745        stage0_paths.insert(path.clone());
3746        match tree_map.remove(path.as_str()) {
3747            Some(te) => {
3748                // Present in both — check for differences
3749                if te.oid != ie.oid
3750                    || crate::index::canon_mode(te.mode) != crate::index::canon_mode(ie.mode)
3751                {
3752                    let tree_mode = crate::index::canon_mode(te.mode);
3753                    let index_mode = crate::index::canon_mode(ie.mode);
3754                    result.push(DiffEntry {
3755                        status: DiffStatus::for_content_change(tree_mode, index_mode),
3756                        old_path: Some(path.clone()),
3757                        new_path: Some(path),
3758                        old_mode: FileMode::new(tree_mode),
3759                        new_mode: FileMode::new(index_mode),
3760                        old_oid: te.oid,
3761                        new_oid: ie.oid,
3762                        score: None,
3763                    });
3764                }
3765            }
3766            None => {
3767                // In index but not tree → added
3768                result.push(DiffEntry {
3769                    status: DiffStatus::Added,
3770                    old_path: None,
3771                    new_path: Some(path),
3772                    old_mode: FileMode::ABSENT,
3773                    new_mode: FileMode::new(ie.mode),
3774                    old_oid: zero_oid(),
3775                    new_oid: ie.oid,
3776                    score: None,
3777                });
3778            }
3779        }
3780    }
3781
3782    diff_index_to_tree_finish_unmerged_and_deleted(
3783        ignore_submodules,
3784        &mut tree_map,
3785        &unmerged_modes,
3786        &stage0_paths,
3787        &mut result,
3788    );
3789
3790    sort_entries_by_path(&mut result);
3791    Ok(result)
3792}
3793
3794fn diff_index_to_tree_finish_unmerged_and_deleted(
3795    ignore_submodules: bool,
3796    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
3797    unmerged_modes: &std::collections::BTreeMap<String, (u8, u32)>,
3798    stage0_paths: &std::collections::BTreeSet<String>,
3799    result: &mut Vec<DiffEntry>,
3800) {
3801    for (path, (_, mode)) in unmerged_modes {
3802        if stage0_paths.contains(path) {
3803            continue;
3804        }
3805        tree_map.remove(path.as_str());
3806        result.push(DiffEntry {
3807            status: DiffStatus::Unmerged,
3808            old_path: Some(path.clone()),
3809            new_path: Some(path.clone()),
3810            old_mode: FileMode::ABSENT,
3811            new_mode: FileMode::new(*mode),
3812            old_oid: zero_oid(),
3813            new_oid: zero_oid(),
3814            score: None,
3815        });
3816    }
3817
3818    // Remaining tree entries not in index → deleted
3819    for (path, te) in std::mem::take(tree_map) {
3820        if ignore_submodules && te.mode == 0o160000 {
3821            continue;
3822        }
3823        result.push(DiffEntry {
3824            status: DiffStatus::Deleted,
3825            old_path: Some(path.to_owned()),
3826            new_path: None,
3827            old_mode: FileMode::new(te.mode),
3828            new_mode: FileMode::ABSENT,
3829            old_oid: te.oid,
3830            new_oid: zero_oid(),
3831            score: None,
3832        });
3833    }
3834}
3835
3836/// Cache-tree-aware index-vs-tree diff: walk HEAD tree and index together and skip
3837/// subtrees whose valid cache-tree node OID matches the tree entry (Git unpack-trees).
3838fn diff_index_to_tree_with_cache_tree(
3839    odb: &Odb,
3840    index: &Index,
3841    head_tree: &ObjectId,
3842    cache_root: &CacheTreeNode,
3843    ignore_submodules: bool,
3844) -> Result<Vec<DiffEntry>> {
3845    let mut result = Vec::new();
3846    let mut idx_cursor = 0usize;
3847
3848    let mut walk = DiffIndexTreeWalk {
3849        odb,
3850        index,
3851        idx_cursor: &mut idx_cursor,
3852        ignore_submodules,
3853        result: &mut result,
3854        abort_to_flatten: false,
3855    };
3856    diff_index_tree_at(&mut walk, head_tree, Some(cache_root), b"")?;
3857    if walk.abort_to_flatten {
3858        return diff_index_to_tree_flatten(odb, index, Some(head_tree), ignore_submodules);
3859    }
3860
3861    // Index entries not consumed by the tree walk (added paths).
3862    while idx_cursor < index.entries.len() {
3863        let ie = &index.entries[idx_cursor];
3864        idx_cursor += 1;
3865        let path = String::from_utf8_lossy(&ie.path).to_string();
3866        if ie.stage() == 0 && ie.intent_to_add() {
3867            continue;
3868        }
3869        if ie.stage() != 0 {
3870            continue;
3871        }
3872        if ignore_submodules && ie.mode == 0o160000 {
3873            continue;
3874        }
3875        result.push(DiffEntry {
3876            status: DiffStatus::Added,
3877            old_path: None,
3878            new_path: Some(path),
3879            old_mode: FileMode::ABSENT,
3880            new_mode: FileMode::new(ie.mode),
3881            old_oid: zero_oid(),
3882            new_oid: ie.oid,
3883            score: None,
3884        });
3885    }
3886
3887    sort_entries_by_path(&mut result);
3888    Ok(result)
3889}
3890
3891enum IndexLevelKind<'a> {
3892    Blob {
3893        entry: &'a IndexEntry,
3894        name: &'a [u8],
3895    },
3896    Tree {
3897        name: &'a [u8],
3898    },
3899}
3900
3901/// `path` relative to the directory `dir_prefix` (raw bytes, no trailing slash), or `None` when
3902/// `path` lies outside it. The directory's own path maps to an empty slice.
3903fn index_relative_in_dir<'a>(path: &'a [u8], dir_prefix: &[u8]) -> Option<&'a [u8]> {
3904    if dir_prefix.is_empty() {
3905        return Some(path);
3906    }
3907    let rest = path.strip_prefix(dir_prefix)?;
3908    match rest.split_first() {
3909        None => Some(rest),
3910        Some((b'/', tail)) => Some(tail),
3911        Some(_) => None,
3912    }
3913}
3914
3915fn join_index_path(dir_prefix: &[u8], name: &[u8]) -> Vec<u8> {
3916    if dir_prefix.is_empty() {
3917        return name.to_vec();
3918    }
3919    let mut path = Vec::with_capacity(dir_prefix.len() + 1 + name.len());
3920    path.extend_from_slice(dir_prefix);
3921    path.push(b'/');
3922    path.extend_from_slice(name);
3923    path
3924}
3925
3926fn peek_index_level_entry<'a>(
3927    index: &'a Index,
3928    cursor: &mut usize,
3929    dir_prefix: &[u8],
3930    ignore_submodules: bool,
3931) -> Option<IndexLevelKind<'a>> {
3932    while let Some(ie) = index.entries.get(*cursor) {
3933        let rel = index_relative_in_dir(&ie.path, dir_prefix)?;
3934        if ie.stage() != 0 || ie.intent_to_add() || rel.is_empty() {
3935            *cursor += 1;
3936            continue;
3937        }
3938        if let Some(slash) = rel.iter().position(|&b| b == b'/') {
3939            return Some(IndexLevelKind::Tree {
3940                name: &rel[..slash],
3941            });
3942        }
3943        if ignore_submodules && ie.mode == 0o160000 {
3944            *cursor += 1;
3945            continue;
3946        }
3947        return Some(IndexLevelKind::Blob {
3948            entry: ie,
3949            name: rel,
3950        });
3951    }
3952    None
3953}
3954
3955fn skip_index_subtree(index: &Index, cursor: &mut usize, dir_prefix: &[u8]) {
3956    while index
3957        .entries
3958        .get(*cursor)
3959        .is_some_and(|ie| index_relative_in_dir(&ie.path, dir_prefix).is_some())
3960    {
3961        *cursor += 1;
3962    }
3963}
3964
3965/// Children of a cache-tree node up to which a linear name scan beats sorting.
3966const CACHE_CHILD_LINEAR_SCAN_MAX: usize = 16;
3967
3968/// Name lookup over one cache-tree node's subtrees.
3969///
3970/// The index extension does not promise any child order, and a wide directory is looked up
3971/// once per tree entry, so nodes with many children are sorted once per visit instead of
3972/// being scanned for every entry. The first child with a matching name wins either way.
3973struct CacheChildren<'a> {
3974    children: &'a [CacheTreeNode],
3975    sorted: Vec<&'a CacheTreeNode>,
3976}
3977
3978impl<'a> CacheChildren<'a> {
3979    fn new(node: Option<&'a CacheTreeNode>) -> Self {
3980        let children = node.map_or(&[][..], |n| n.children.as_slice());
3981        let sorted = if children.len() > CACHE_CHILD_LINEAR_SCAN_MAX {
3982            let mut sorted: Vec<&CacheTreeNode> = children.iter().collect();
3983            sorted.sort_by(|a, b| a.name.cmp(&b.name));
3984            sorted
3985        } else {
3986            Vec::new()
3987        };
3988        Self { children, sorted }
3989    }
3990
3991    fn get(&self, name: &[u8]) -> Option<&'a CacheTreeNode> {
3992        if self.sorted.is_empty() {
3993            return self.children.iter().find(|c| c.name == name);
3994        }
3995        let pos = self.sorted.partition_point(|c| c.name.as_slice() < name);
3996        self.sorted.get(pos).copied().filter(|c| c.name == name)
3997    }
3998}
3999
4000struct DiffIndexTreeWalk<'a, 'b> {
4001    odb: &'a Odb,
4002    index: &'a Index,
4003    idx_cursor: &'b mut usize,
4004    ignore_submodules: bool,
4005    result: &'b mut Vec<DiffEntry>,
4006    /// Set when a tree object has duplicate entry names; the caller falls back to flatten.
4007    abort_to_flatten: bool,
4008}
4009
4010fn tree_entries_have_duplicate_names(entries: &[TreeEntry]) -> bool {
4011    let mut seen = std::collections::BTreeSet::<&[u8]>::new();
4012    for entry in entries {
4013        if !seen.insert(entry.name.as_slice()) {
4014            return true;
4015        }
4016    }
4017    false
4018}
4019
4020/// Walk the tree `tree_oid` against the index entries under `dir_prefix` (raw path bytes).
4021fn diff_index_tree_at(
4022    walk: &mut DiffIndexTreeWalk<'_, '_>,
4023    tree_oid: &ObjectId,
4024    cache_node: Option<&CacheTreeNode>,
4025    dir_prefix: &[u8],
4026) -> Result<()> {
4027    if !dir_prefix.is_empty() {
4028        let mut path = dir_prefix.to_vec();
4029        path.push(b'/');
4030        if let Some(entry) = walk
4031            .index
4032            .entries
4033            .get(*walk.idx_cursor)
4034            .filter(|entry| entry.path == path && entry.is_sparse_directory_placeholder())
4035        {
4036            if entry.oid == *tree_oid {
4037                *walk.idx_cursor += 1;
4038                return Ok(());
4039            }
4040            let dir = String::from_utf8_lossy(dir_prefix);
4041            let head_flat = flatten_tree(walk.odb, tree_oid, dir.as_ref())?;
4042            let index_flat = flatten_tree(walk.odb, &entry.oid, dir.as_ref())?;
4043            let mut stage0_paths = std::collections::BTreeSet::new();
4044            let mut tree_map = std::collections::BTreeMap::new();
4045            diff_flattened_subtrees(
4046                &head_flat,
4047                &index_flat,
4048                walk.ignore_submodules,
4049                walk.result,
4050                &mut stage0_paths,
4051                &mut tree_map,
4052            );
4053            *walk.idx_cursor += 1;
4054            return Ok(());
4055        }
4056    }
4057    let tree_entries = read_tree(walk.odb, tree_oid)?;
4058    if tree_entries_have_duplicate_names(&tree_entries) {
4059        walk.abort_to_flatten = true;
4060        return Ok(());
4061    }
4062    let cache_children = CacheChildren::new(cache_node);
4063    let mut ti = 0usize;
4064
4065    loop {
4066        let te = tree_entries.get(ti);
4067        let ie_kind = peek_index_level_entry(
4068            walk.index,
4069            walk.idx_cursor,
4070            dir_prefix,
4071            walk.ignore_submodules,
4072        );
4073
4074        match (te, ie_kind) {
4075            (Some(t_entry), Some(ie)) => {
4076                let t_name = &t_entry.name;
4077                let (i_name, i_is_tree) = match &ie {
4078                    IndexLevelKind::Blob { name, .. } => (*name, false),
4079                    IndexLevelKind::Tree { name } => (*name, true),
4080                };
4081                let t_is_tree = is_tree_mode(t_entry.mode);
4082                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4083                match cmp {
4084                    std::cmp::Ordering::Less => {
4085                        emit_index_tree_deleted(
4086                            walk.odb,
4087                            t_entry,
4088                            dir_prefix,
4089                            walk.ignore_submodules,
4090                            walk.result,
4091                        )?;
4092                        ti += 1;
4093                    }
4094                    std::cmp::Ordering::Greater => {
4095                        emit_index_tree_added(walk, dir_prefix)?;
4096                    }
4097                    std::cmp::Ordering::Equal => {
4098                        if t_is_tree && i_is_tree {
4099                            let full_path = join_index_path(dir_prefix, t_name);
4100                            if diff_index_tree_skip_sparse_placeholder(walk, &full_path, t_entry)? {
4101                                ti += 1;
4102                                continue;
4103                            }
4104                            let child_cache = cache_children.get(t_name);
4105                            if child_cache
4106                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
4107                            {
4108                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4109                                ti += 1;
4110                                continue;
4111                            }
4112                            diff_index_tree_at(walk, &t_entry.oid, child_cache, &full_path)?;
4113                        } else if t_is_tree != i_is_tree {
4114                            emit_index_tree_deleted(
4115                                walk.odb,
4116                                t_entry,
4117                                dir_prefix,
4118                                walk.ignore_submodules,
4119                                walk.result,
4120                            )?;
4121                            emit_index_tree_added(walk, dir_prefix)?;
4122                        } else {
4123                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
4124                                ti += 1;
4125                                continue;
4126                            };
4127                            *walk.idx_cursor += 1;
4128                            if t_entry.oid != ie.oid
4129                                || crate::index::canon_mode(t_entry.mode)
4130                                    != crate::index::canon_mode(ie.mode)
4131                            {
4132                                let path = String::from_utf8_lossy(&ie.path).into_owned();
4133                                let tree_mode = crate::index::canon_mode(t_entry.mode);
4134                                let index_mode = crate::index::canon_mode(ie.mode);
4135                                walk.result.push(DiffEntry {
4136                                    status: DiffStatus::for_content_change(tree_mode, index_mode),
4137                                    old_path: Some(path.clone()),
4138                                    new_path: Some(path),
4139                                    old_mode: FileMode::new(tree_mode),
4140                                    new_mode: FileMode::new(index_mode),
4141                                    old_oid: t_entry.oid,
4142                                    new_oid: ie.oid,
4143                                    score: None,
4144                                });
4145                            }
4146                        }
4147                        ti += 1;
4148                    }
4149                }
4150            }
4151            (Some(t_entry), None) => {
4152                emit_index_tree_deleted(
4153                    walk.odb,
4154                    t_entry,
4155                    dir_prefix,
4156                    walk.ignore_submodules,
4157                    walk.result,
4158                )?;
4159                ti += 1;
4160            }
4161            (None, Some(_)) => {
4162                emit_index_tree_added(walk, dir_prefix)?;
4163            }
4164            (None, None) => break,
4165        }
4166    }
4167
4168    Ok(())
4169}
4170
4171fn diff_index_tree_skip_sparse_placeholder(
4172    walk: &mut DiffIndexTreeWalk<'_, '_>,
4173    full_path: &[u8],
4174    t_entry: &TreeEntry,
4175) -> Result<bool> {
4176    let Some(entry) = walk.index.entries.get(*walk.idx_cursor) else {
4177        return Ok(false);
4178    };
4179    if !entry.is_sparse_directory_placeholder() {
4180        return Ok(false);
4181    }
4182    let mut dir = full_path.to_vec();
4183    if !dir.ends_with(b"/") {
4184        dir.push(b'/');
4185    }
4186    if entry.path != dir || entry.oid != t_entry.oid {
4187        return Ok(false);
4188    }
4189    *walk.idx_cursor += 1;
4190    Ok(true)
4191}
4192
4193fn emit_index_tree_deleted(
4194    odb: &Odb,
4195    entry: &TreeEntry,
4196    dir_prefix: &[u8],
4197    ignore_submodules: bool,
4198    result: &mut Vec<DiffEntry>,
4199) -> Result<()> {
4200    if ignore_submodules && entry.mode == 0o160000 {
4201        return Ok(());
4202    }
4203    let path = String::from_utf8_lossy(&join_index_path(dir_prefix, &entry.name)).into_owned();
4204    if is_tree_mode(entry.mode) {
4205        for fe in flatten_tree(odb, &entry.oid, &path)? {
4206            if ignore_submodules && fe.mode == 0o160000 {
4207                continue;
4208            }
4209            result.push(DiffEntry {
4210                status: DiffStatus::Deleted,
4211                old_path: Some(fe.path.clone()),
4212                new_path: None,
4213                old_mode: FileMode::new(fe.mode),
4214                new_mode: FileMode::ABSENT,
4215                old_oid: fe.oid,
4216                new_oid: zero_oid(),
4217                score: None,
4218            });
4219        }
4220    } else {
4221        result.push(DiffEntry {
4222            status: DiffStatus::Deleted,
4223            old_path: Some(path),
4224            new_path: None,
4225            old_mode: FileMode::new(entry.mode),
4226            new_mode: FileMode::ABSENT,
4227            old_oid: entry.oid,
4228            new_oid: zero_oid(),
4229            score: None,
4230        });
4231    }
4232    Ok(())
4233}
4234
4235fn emit_index_tree_added(walk: &mut DiffIndexTreeWalk<'_, '_>, dir_prefix: &[u8]) -> Result<()> {
4236    let ie_kind = peek_index_level_entry(
4237        walk.index,
4238        walk.idx_cursor,
4239        dir_prefix,
4240        walk.ignore_submodules,
4241    )
4242    .ok_or_else(|| Error::IndexError("missing index entry during tree walk".to_owned()))?;
4243    match ie_kind {
4244        IndexLevelKind::Blob { entry: ie, .. } => {
4245            let path = String::from_utf8_lossy(&ie.path).into_owned();
4246            *walk.idx_cursor += 1;
4247            walk.result.push(DiffEntry {
4248                status: DiffStatus::Added,
4249                old_path: None,
4250                new_path: Some(path),
4251                old_mode: FileMode::ABSENT,
4252                new_mode: FileMode::new(ie.mode),
4253                old_oid: zero_oid(),
4254                new_oid: ie.oid,
4255                score: None,
4256            });
4257        }
4258        IndexLevelKind::Tree { name } => {
4259            let full_path = join_index_path(dir_prefix, name);
4260            let mut path_slash = full_path.clone();
4261            path_slash.push(b'/');
4262            if let Some(entry) =
4263                walk.index.entries.get(*walk.idx_cursor).filter(|entry| {
4264                    entry.path == path_slash && entry.is_sparse_directory_placeholder()
4265                })
4266            {
4267                let dir = String::from_utf8_lossy(if dir_prefix.is_empty() {
4268                    name
4269                } else {
4270                    &full_path
4271                });
4272                emit_flattened_subtree_as_added(
4273                    walk.odb,
4274                    &entry.oid,
4275                    dir.as_ref(),
4276                    walk.ignore_submodules,
4277                    walk.result,
4278                )?;
4279                *walk.idx_cursor += 1;
4280                return Ok(());
4281            }
4282            let start = *walk.idx_cursor;
4283            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4284            for ie in &walk.index.entries[start..*walk.idx_cursor] {
4285                if ie.stage() != 0 || ie.intent_to_add() {
4286                    continue;
4287                }
4288                if walk.ignore_submodules && ie.mode == 0o160000 {
4289                    continue;
4290                }
4291                let path = String::from_utf8_lossy(&ie.path).into_owned();
4292                walk.result.push(DiffEntry {
4293                    status: DiffStatus::Added,
4294                    old_path: None,
4295                    new_path: Some(path),
4296                    old_mode: FileMode::ABSENT,
4297                    new_mode: FileMode::new(ie.mode),
4298                    old_oid: zero_oid(),
4299                    new_oid: ie.oid,
4300                    score: None,
4301                });
4302            }
4303        }
4304    }
4305    Ok(())
4306}
4307
4308/// Paths and rows to reconcile an index with a target tree during hard reset.
4309#[derive(Default)]
4310pub(crate) struct HardResetIndexPlan {
4311    pub remove_paths: Vec<Vec<u8>>,
4312    pub updates: Vec<(Vec<u8>, u32, ObjectId)>,
4313    pub insert_entries: Vec<IndexEntry>,
4314}
4315
4316/// Plan index changes to match `target_tree` using a cache-tree-aware lockstep walk (raw paths).
4317///
4318/// Never builds a flattened tree map; when the cache-tree extension is absent, walks the full tree
4319/// against the index instead of calling [`diff_index_to_tree_flatten`].
4320pub(crate) fn plan_hard_reset_index_align(
4321    odb: &Odb,
4322    index: &Index,
4323    target_tree: &ObjectId,
4324) -> Result<HardResetIndexPlan> {
4325    let mut plan = HardResetIndexPlan::default();
4326    let mut idx_cursor = 0usize;
4327    let cache_root = index.cache_tree.as_ref();
4328    let use_cache = cache_root.is_some()
4329        && index
4330            .entries
4331            .iter()
4332            .all(|entry| entry.stage() == 0 && !entry.intent_to_add());
4333    let cache_node = if use_cache { cache_root } else { None };
4334    let retry = {
4335        let mut walk = HardResetAlignWalk {
4336            odb,
4337            index,
4338            idx_cursor: &mut idx_cursor,
4339            ignore_submodules: false,
4340            plan: &mut plan,
4341            retry_without_cache: false,
4342        };
4343        hard_reset_align_index_tree_at(&mut walk, target_tree, cache_node, b"")?;
4344        walk.retry_without_cache
4345    };
4346    if retry {
4347        idx_cursor = 0;
4348        plan = HardResetIndexPlan::default();
4349        let mut walk = HardResetAlignWalk {
4350            odb,
4351            index,
4352            idx_cursor: &mut idx_cursor,
4353            ignore_submodules: false,
4354            plan: &mut plan,
4355            retry_without_cache: false,
4356        };
4357        hard_reset_align_index_tree_at(&mut walk, target_tree, None, b"")?;
4358    }
4359    while idx_cursor < index.entries.len() {
4360        let ie = &index.entries[idx_cursor];
4361        idx_cursor += 1;
4362        if ie.stage() != 0 || ie.intent_to_add() {
4363            continue;
4364        }
4365        plan.remove_paths.push(ie.path.clone());
4366    }
4367    Ok(plan)
4368}
4369
4370/// Apply a [`HardResetIndexPlan`] and invalidate cache-tree nodes for touched paths.
4371pub(crate) fn apply_hard_reset_index_plan(index: &mut Index, plan: HardResetIndexPlan) {
4372    let change_count = plan
4373        .remove_paths
4374        .len()
4375        .saturating_add(plan.updates.len())
4376        .saturating_add(plan.insert_entries.len());
4377    const BULK_HARD_RESET_INDEX_MIN: usize = 64;
4378    let bulk = change_count >= BULK_HARD_RESET_INDEX_MIN;
4379    if bulk {
4380        index.drop_derived_index_extensions();
4381    }
4382    for (path, mode, oid) in plan.updates {
4383        if let Some(pos) = index.find_entry_pos(&path, 0) {
4384            let entry = &mut index.entries[pos];
4385            let content_unchanged = entry.oid == oid && entry.mode == mode;
4386            entry.mode = mode;
4387            entry.oid = oid;
4388            if !content_unchanged {
4389                entry.size = 0;
4390            }
4391            if !bulk {
4392                index.invalidate_cache_tree_for_path(&path);
4393            }
4394        }
4395    }
4396    index.defer_untracked_cache_invalidation = bulk;
4397    index.remove_prefixes_paths_and_insert(
4398        &std::collections::BTreeSet::new(),
4399        plan.remove_paths.iter().map(|p| p.as_slice()),
4400        plan.insert_entries,
4401    );
4402    index.defer_untracked_cache_invalidation = false;
4403    if !bulk {
4404        for path in &plan.remove_paths {
4405            index.invalidate_cache_tree_for_path(path);
4406        }
4407    }
4408    index.sort();
4409}
4410
4411/// Rebuild the cache-tree extension after hard-reset index alignment, reusing valid seed
4412/// subtrees from the lockstep tree/index walk.
4413pub(crate) fn rebuild_hard_reset_cache_tree(
4414    odb: &Odb,
4415    index: &mut Index,
4416    target_tree: &ObjectId,
4417) -> Result<()> {
4418    let stage0_count = index
4419        .entries
4420        .iter()
4421        .filter(|entry| entry.stage() == 0 && !entry.intent_to_add())
4422        .count();
4423    let seed_root = index.cache_tree.as_ref();
4424    let mut idx_cursor = 0usize;
4425    let root = rebuild_hard_reset_cache_tree_at(
4426        odb,
4427        index,
4428        &mut idx_cursor,
4429        target_tree,
4430        seed_root,
4431        b"",
4432        Vec::new(),
4433    )?;
4434    if usize::try_from(root.entry_count).ok() == Some(stage0_count) {
4435        index.set_cache_tree(root);
4436        Ok(())
4437    } else {
4438        Err(Error::IndexError(
4439            "hard reset cache-tree entry count does not match index".into(),
4440        ))
4441    }
4442}
4443
4444fn rebuild_hard_reset_cache_tree_at(
4445    odb: &Odb,
4446    index: &Index,
4447    idx_cursor: &mut usize,
4448    tree_oid: &ObjectId,
4449    seed_node: Option<&CacheTreeNode>,
4450    dir_prefix: &[u8],
4451    name: Vec<u8>,
4452) -> Result<CacheTreeNode> {
4453    if !dir_prefix.is_empty() {
4454        let mut path = dir_prefix.to_vec();
4455        path.push(b'/');
4456        if let Some(entry) = index.entries.get(*idx_cursor).filter(|entry| {
4457            entry.path == path && entry.is_sparse_directory_placeholder() && entry.oid == *tree_oid
4458        }) {
4459            let _ = entry;
4460            *idx_cursor += 1;
4461            return Ok(CacheTreeNode::valid(name, 1, *tree_oid, Vec::new()));
4462        }
4463    }
4464    let tree_entries = read_tree(odb, tree_oid)?;
4465    let cache_children = CacheChildren::new(seed_node);
4466    let mut entry_count: i32 = 0;
4467    let mut children: Vec<CacheTreeNode> = Vec::new();
4468    let mut ti = 0usize;
4469
4470    loop {
4471        let te = tree_entries.get(ti);
4472        let ie_kind = peek_index_level_entry(index, idx_cursor, dir_prefix, false);
4473        match (te, ie_kind) {
4474            (Some(t_entry), Some(ie)) => {
4475                let t_name = &t_entry.name;
4476                let (i_name, i_is_tree) = match &ie {
4477                    IndexLevelKind::Blob { name, .. } => (*name, false),
4478                    IndexLevelKind::Tree { name } => (*name, true),
4479                };
4480                let t_is_tree = is_tree_mode(t_entry.mode);
4481                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4482                if cmp != std::cmp::Ordering::Equal {
4483                    return Err(Error::IndexError(
4484                        "hard reset cache-tree rebuild: index out of sync with target tree".into(),
4485                    ));
4486                }
4487                if t_is_tree && i_is_tree {
4488                    let full_path = join_index_path(dir_prefix, t_name);
4489                    if hard_reset_skip_sparse_cache_rebuild(index, idx_cursor, &full_path, t_entry)?
4490                    {
4491                        entry_count = entry_count.saturating_add(1);
4492                        ti += 1;
4493                        continue;
4494                    }
4495                    let child = if let Some(child_cache) = cache_children
4496                        .get(t_name)
4497                        .filter(|node| node.is_valid() && node.oid == Some(t_entry.oid))
4498                    {
4499                        skip_index_subtree(index, idx_cursor, &full_path);
4500                        let mut reused = (*child_cache).clone();
4501                        reused.name = t_name.clone();
4502                        reused
4503                    } else {
4504                        rebuild_hard_reset_cache_tree_at(
4505                            odb,
4506                            index,
4507                            idx_cursor,
4508                            &t_entry.oid,
4509                            cache_children.get(t_name),
4510                            &full_path,
4511                            t_name.clone(),
4512                        )?
4513                    };
4514                    entry_count = entry_count.saturating_add(child.entry_count.max(0));
4515                    children.push(child);
4516                } else if t_is_tree != i_is_tree {
4517                    return Err(Error::IndexError(
4518                        "hard reset cache-tree rebuild: tree/index type mismatch".into(),
4519                    ));
4520                } else {
4521                    *idx_cursor += 1;
4522                    entry_count = entry_count.saturating_add(1);
4523                }
4524                ti += 1;
4525            }
4526            (None, None) => break,
4527            _ => {
4528                return Err(Error::IndexError(
4529                    "hard reset cache-tree rebuild: index out of sync with target tree".into(),
4530                ));
4531            }
4532        }
4533    }
4534    children.sort_by(|a, b| a.name.cmp(&b.name));
4535    Ok(CacheTreeNode::valid(name, entry_count, *tree_oid, children))
4536}
4537
4538fn hard_reset_skip_sparse_cache_rebuild(
4539    index: &Index,
4540    idx_cursor: &mut usize,
4541    full_path: &[u8],
4542    t_entry: &TreeEntry,
4543) -> Result<bool> {
4544    let Some(entry) = index.entries.get(*idx_cursor) else {
4545        return Ok(false);
4546    };
4547    if !entry.is_sparse_directory_placeholder() {
4548        return Ok(false);
4549    }
4550    let mut dir = full_path.to_vec();
4551    if !dir.ends_with(b"/") {
4552        dir.push(b'/');
4553    }
4554    if entry.path != dir || entry.oid != t_entry.oid {
4555        return Ok(false);
4556    }
4557    *idx_cursor += 1;
4558    Ok(true)
4559}
4560
4561struct HardResetAlignWalk<'a, 'b> {
4562    odb: &'a Odb,
4563    index: &'a Index,
4564    idx_cursor: &'b mut usize,
4565    ignore_submodules: bool,
4566    plan: &'b mut HardResetIndexPlan,
4567    retry_without_cache: bool,
4568}
4569
4570fn hard_reset_align_index_tree_at(
4571    walk: &mut HardResetAlignWalk<'_, '_>,
4572    tree_oid: &ObjectId,
4573    cache_node: Option<&CacheTreeNode>,
4574    dir_prefix: &[u8],
4575) -> Result<()> {
4576    let tree_entries = read_tree(walk.odb, tree_oid)?;
4577    if tree_entries_have_duplicate_names(&tree_entries) {
4578        walk.retry_without_cache = true;
4579        return Ok(());
4580    }
4581    let cache_children = CacheChildren::new(cache_node);
4582    let mut ti = 0usize;
4583
4584    loop {
4585        let te = tree_entries.get(ti);
4586        let ie_kind = peek_index_level_entry(
4587            walk.index,
4588            walk.idx_cursor,
4589            dir_prefix,
4590            walk.ignore_submodules,
4591        );
4592
4593        match (te, ie_kind) {
4594            (Some(t_entry), Some(ie)) => {
4595                let t_name = &t_entry.name;
4596                let (i_name, i_is_tree) = match &ie {
4597                    IndexLevelKind::Blob { name, .. } => (*name, false),
4598                    IndexLevelKind::Tree { name } => (*name, true),
4599                };
4600                let t_is_tree = is_tree_mode(t_entry.mode);
4601                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4602                match cmp {
4603                    std::cmp::Ordering::Less => {
4604                        hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
4605                        ti += 1;
4606                    }
4607                    std::cmp::Ordering::Greater => {
4608                        hard_reset_plan_index_only(walk, dir_prefix)?;
4609                    }
4610                    std::cmp::Ordering::Equal => {
4611                        if t_is_tree && i_is_tree {
4612                            let full_path = join_index_path(dir_prefix, t_name);
4613                            if hard_reset_skip_sparse_placeholder(walk, &full_path, t_entry)? {
4614                                ti += 1;
4615                                continue;
4616                            }
4617                            let child_cache = cache_children.get(t_name);
4618                            if child_cache
4619                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
4620                            {
4621                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4622                                ti += 1;
4623                                continue;
4624                            }
4625                            hard_reset_align_index_tree_at(
4626                                walk,
4627                                &t_entry.oid,
4628                                child_cache,
4629                                &full_path,
4630                            )?;
4631                        } else if t_is_tree != i_is_tree {
4632                            hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
4633                            hard_reset_plan_index_only(walk, dir_prefix)?;
4634                        } else {
4635                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
4636                                ti += 1;
4637                                continue;
4638                            };
4639                            *walk.idx_cursor += 1;
4640                            if t_entry.oid != ie.oid
4641                                || crate::index::canon_mode(t_entry.mode)
4642                                    != crate::index::canon_mode(ie.mode)
4643                            {
4644                                walk.plan.updates.push((
4645                                    ie.path.clone(),
4646                                    crate::index::canon_mode(t_entry.mode),
4647                                    t_entry.oid,
4648                                ));
4649                            }
4650                        }
4651                        ti += 1;
4652                    }
4653                }
4654            }
4655            (Some(t_entry), None) => {
4656                hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
4657                ti += 1;
4658            }
4659            (None, Some(_)) => {
4660                hard_reset_plan_index_only(walk, dir_prefix)?;
4661            }
4662            (None, None) => break,
4663        }
4664    }
4665    Ok(())
4666}
4667
4668fn hard_reset_skip_sparse_placeholder(
4669    walk: &mut HardResetAlignWalk<'_, '_>,
4670    full_path: &[u8],
4671    t_entry: &TreeEntry,
4672) -> Result<bool> {
4673    let Some(ie) = walk.index.entries.get(*walk.idx_cursor) else {
4674        return Ok(false);
4675    };
4676    if !ie.is_sparse_directory_placeholder() {
4677        return Ok(false);
4678    }
4679    let mut dir = full_path.to_vec();
4680    if !dir.ends_with(b"/") {
4681        dir.push(b'/');
4682    }
4683    if ie.path != dir || ie.oid != t_entry.oid {
4684        return Ok(false);
4685    }
4686    *walk.idx_cursor += 1;
4687    Ok(true)
4688}
4689
4690fn hard_reset_plan_index_only(
4691    walk: &mut HardResetAlignWalk<'_, '_>,
4692    dir_prefix: &[u8],
4693) -> Result<()> {
4694    let ie_kind = peek_index_level_entry(
4695        walk.index,
4696        walk.idx_cursor,
4697        dir_prefix,
4698        walk.ignore_submodules,
4699    )
4700    .ok_or_else(|| Error::IndexError("missing index entry during hard reset align".into()))?;
4701    match ie_kind {
4702        IndexLevelKind::Blob { entry: ie, .. } => {
4703            walk.plan.remove_paths.push(ie.path.clone());
4704            *walk.idx_cursor += 1;
4705        }
4706        IndexLevelKind::Tree { name } => {
4707            let full_path = join_index_path(dir_prefix, name);
4708            let start = *walk.idx_cursor;
4709            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4710            for ie in &walk.index.entries[start..*walk.idx_cursor] {
4711                if ie.stage() != 0 || ie.intent_to_add() {
4712                    continue;
4713                }
4714                walk.plan.remove_paths.push(ie.path.clone());
4715            }
4716        }
4717    }
4718    Ok(())
4719}
4720
4721fn hard_reset_plan_tree_only(
4722    walk: &mut HardResetAlignWalk<'_, '_>,
4723    entry: &TreeEntry,
4724    dir_prefix: &[u8],
4725) -> Result<()> {
4726    if walk.ignore_submodules && entry.mode == 0o160000 {
4727        return Ok(());
4728    }
4729    let path = join_index_path(dir_prefix, &entry.name);
4730    if is_tree_mode(entry.mode) {
4731        hard_reset_append_entries_from_tree(
4732            walk.odb,
4733            &entry.oid,
4734            &path,
4735            &mut walk.plan.insert_entries,
4736        )?;
4737    } else {
4738        walk.plan
4739            .insert_entries
4740            .push(hard_reset_index_tree_row(path, entry.mode, entry.oid));
4741    }
4742    Ok(())
4743}
4744
4745fn hard_reset_index_tree_row(path: Vec<u8>, mode: u32, oid: ObjectId) -> IndexEntry {
4746    IndexEntry {
4747        ctime_sec: 0,
4748        ctime_nsec: 0,
4749        mtime_sec: 0,
4750        mtime_nsec: 0,
4751        dev: 0,
4752        ino: 0,
4753        mode: crate::index::canon_mode(mode),
4754        uid: 0,
4755        gid: 0,
4756        size: 0,
4757        oid,
4758        flags: path.len().min(0xFFF) as u16,
4759        flags_extended: None,
4760        path,
4761        base_index_pos: 0,
4762    }
4763}
4764
4765fn hard_reset_append_entries_from_tree(
4766    odb: &Odb,
4767    tree_oid: &ObjectId,
4768    prefix: &[u8],
4769    out: &mut Vec<IndexEntry>,
4770) -> Result<()> {
4771    for te in read_tree(odb, tree_oid)? {
4772        let mut path = prefix.to_vec();
4773        if !path.is_empty() {
4774            path.push(b'/');
4775        }
4776        path.extend_from_slice(&te.name);
4777        if is_tree_mode(te.mode) {
4778            hard_reset_append_entries_from_tree(odb, &te.oid, &path, out)?;
4779        } else {
4780            out.push(hard_reset_index_tree_row(path, te.mode, te.oid));
4781        }
4782    }
4783    Ok(())
4784}
4785
4786// ── Index-to-worktree diff (unstaged changes) ───────────────────────
4787
4788/// Compare the index against the working tree.
4789///
4790/// This shows "unstaged" changes — modifications not yet staged.
4791///
4792/// Entries with [`IndexEntry::assume_unchanged`] or [`IndexEntry::skip_worktree`] are treated as
4793/// matching the work tree without examining the filesystem (Git `CE_VALID` / skip-worktree).
4794///
4795/// # Parameters
4796///
4797/// - `odb` — object database (for hashing worktree files).
4798/// - `index` — the current index.
4799/// - `work_tree` — path to the working tree root.
4800/// - `ignore_submodule_untracked` — when true, gitlink entries are not dirty solely from untracked
4801///   files inside the submodule (matches `git status -uno`).
4802/// - `simplify_gitlinks` — when true, nested gitlink entries only compare the submodule checkout
4803///   HEAD to the recorded OID (ignore dirty work trees inside nested submodules). Used when
4804///   computing `submodule_porcelain_flags` so untracked files under a nested submodule do not set
4805///   the parent submodule's `modified` bit (Git `DIRTY_SUBMODULE_MODIFIED`; t7506).
4806///
4807/// # Errors
4808///
4809/// Returns errors from I/O or hashing.
4810pub fn diff_index_to_worktree(
4811    odb: &Odb,
4812    index: &mut Index,
4813    work_tree: &Path,
4814    ignore_submodule_untracked: bool,
4815    simplify_gitlinks: bool,
4816) -> Result<Vec<DiffEntry>> {
4817    diff_index_to_worktree_with_options(
4818        odb,
4819        index,
4820        work_tree,
4821        DiffIndexToWorktreeOptions {
4822            ignore_submodule_untracked,
4823            simplify_gitlinks,
4824            ..DiffIndexToWorktreeOptions::default()
4825        },
4826    )
4827    .map(|(entries, _)| entries)
4828}
4829
4830/// Additional inputs for [`diff_index_to_worktree_with_options`].
4831#[derive(Clone)]
4832pub struct DiffIndexToWorktreeOptions {
4833    /// Optional index mtime pair `(sec, nsec)` sampled when the index was read.
4834    ///
4835    /// When provided, entries with matching stat data are still considered dirty candidates if
4836    /// their recorded mtime is "racy" (at or after this timestamp), matching Git's
4837    /// `is_racy_timestamp` behavior.
4838    pub index_mtime: Option<(u32, u32)>,
4839    /// When true, gitlink entries are not dirty solely from untracked files inside the submodule.
4840    pub ignore_submodule_untracked: bool,
4841    /// When true, nested gitlink entries only compare the submodule checkout HEAD to the recorded OID.
4842    pub simplify_gitlinks: bool,
4843    /// When true, a populated gitlink checkout whose `.git` indirection cannot resolve to a HEAD
4844    /// is returned as an error instead of a normal modified gitlink.
4845    pub error_on_broken_gitlinks: bool,
4846    /// Repository git directory used to load config (separate git dir / linked worktrees).
4847    ///
4848    /// When unset, defaults to `work_tree/.git`, which is wrong for gitfile layouts.
4849    pub repository_git_dir: Option<std::path::PathBuf>,
4850    /// When true, adopt refreshed stat data for unchanged blobs during the same directory scan
4851    /// as the worktree diff (one lstat per tracked path).
4852    pub refresh_index_stat_in_pass: bool,
4853    /// When set, used instead of loading config from [`Self::repository_git_dir`].
4854    pub config: Option<std::sync::Arc<crate::config::ConfigSet>>,
4855    /// Cap parallel stat workers (`Some(1)` forces serial), also applied to hashing the
4856    /// racily clean entries the stat refresh verifies. When `None`, honors
4857    /// `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
4858    pub stat_parallel_threads: Option<usize>,
4859    /// CLI / embedder thread override applied to stat preload and hashing in this diff.
4860    pub thread_policy: Option<crate::parallel::ThreadPolicy>,
4861    /// Shared per-operation attribute/conversion context (see [`crate::worktree_rules::WorktreeRules`]).
4862    pub worktree_rules:
4863        Option<std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
4864    /// Status-only shortcuts (default `false`).
4865    ///
4866    /// When true, a tracked file whose size differs from its index entry is reported as
4867    /// modified without reading it, with a null worktree `new_oid`, and intent-to-add
4868    /// entries are not hashed. Content diffs must leave this off so a content change always
4869    /// carries a null worktree `new_oid` (`old_oid != new_oid`).
4870    pub for_status: bool,
4871    /// When true (default), query `core.fsmonitor` before scanning the worktree.
4872    pub refresh_fsmonitor: bool,
4873    /// Injectable subprocess runner for fsmonitor hooks; defaults to the system runner.
4874    pub command_runner: Option<std::sync::Arc<dyn crate::command_runner::CommandRunner>>,
4875    /// When set, used as the hook protocol v1 query boundary; otherwise [`crate::environment::wall_clock_unix_timestamp_nanos`] is sampled once per diff.
4876    pub fsmonitor_query_boundary: Option<crate::fsmonitor::FsmonitorQueryBoundary>,
4877    /// When set, only index paths in this set are compared against the work tree.
4878    ///
4879    /// Used by branch switch to verify dirty paths only where the tree-to-tree diff would
4880    /// update the worktree, avoiding a full tracked scan on large indexes. Callers must
4881    /// refuse the operation when the index has unmerged entries; this filter does not
4882    /// substitute for a full unmerged scan.
4883    pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
4884    /// When true with staging materialization, compute blob OIDs without writing loose objects.
4885    pub hash_blobs_without_write: bool,
4886    /// When set, long loops abort with [`crate::error::Error::Cancelled`] once the flag is raised
4887    /// (see [`crate::progress::cancel_watch_arc`] and [`crate::progress::poll_cancel_watch`]).
4888    pub cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
4889    /// When set, work-tree access failures during status emit [`crate::diagnostics::Warning`]
4890    /// values here instead of aborting the diff.
4891    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
4892}
4893
4894impl Default for DiffIndexToWorktreeOptions {
4895    fn default() -> Self {
4896        Self {
4897            index_mtime: None,
4898            ignore_submodule_untracked: false,
4899            simplify_gitlinks: false,
4900            error_on_broken_gitlinks: false,
4901            repository_git_dir: None,
4902            refresh_index_stat_in_pass: false,
4903            config: None,
4904            stat_parallel_threads: None,
4905            thread_policy: None,
4906            worktree_rules: None,
4907            for_status: false,
4908            refresh_fsmonitor: true,
4909            command_runner: None,
4910            fsmonitor_query_boundary: None,
4911            restrict_to_paths: None,
4912            hash_blobs_without_write: false,
4913            cancel_watch: None,
4914            diagnostics: None,
4915        }
4916    }
4917}
4918
4919#[inline]
4920fn poll_diff_cancel_watch(
4921    watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
4922) -> crate::error::Result<()> {
4923    if let Some(flag) = watch {
4924        crate::progress::poll_watch(flag)?;
4925    }
4926    Ok(())
4927}
4928
4929#[inline]
4930fn poll_diff_cancel(
4931    options: &DiffIndexToWorktreeOptions,
4932    owner_poll: Option<&std::sync::Arc<crate::progress::StackCancelPoll>>,
4933) -> crate::error::Result<()> {
4934    if let Some(poll) = owner_poll {
4935        poll.poll_owner()?;
4936    } else {
4937        poll_diff_cancel_watch(options.cancel_watch.as_ref())?;
4938    }
4939    Ok(())
4940}
4941
4942/// One tracked file the parallel pass must hash, queued once per stat-dirty index entry.
4943///
4944/// A stale index can queue every entry, so the job names its entry by position instead of
4945/// owning copies of it, and the workers rebuild paths from the shared index.
4946struct StagingHashJob {
4947    entry_index: usize,
4948    /// Worktree mode after [`crate::index::WorktreeModePolicy`] against the entry's mode.
4949    worktree_mode: u32,
4950    /// `None` when the path has default attributes, which is the common case.
4951    file_attrs: Option<Box<crate::crlf::FileAttrs>>,
4952}
4953
4954impl StagingHashJob {
4955    fn new(entry_index: usize, worktree_mode: u32, file_attrs: crate::crlf::FileAttrs) -> Self {
4956        let file_attrs =
4957            (file_attrs != crate::crlf::FileAttrs::default()).then(|| Box::new(file_attrs));
4958        Self {
4959            entry_index,
4960            worktree_mode,
4961            file_attrs,
4962        }
4963    }
4964}
4965
4966/// Where the parallel pass finds the index entries and files its jobs name.
4967struct StagingHashSource<'a> {
4968    index: &'a Index,
4969    work_tree: &'a Path,
4970    precompose_unicode: bool,
4971    ignorecase: bool,
4972}
4973
4974#[allow(clippy::too_many_arguments)]
4975fn parallel_process_blob_worktree_jobs(
4976    odb: &Odb,
4977    source: &StagingHashSource<'_>,
4978    jobs: &[StagingHashJob],
4979    thread_policy: &crate::parallel::ThreadPolicy,
4980    config: &crate::config::ConfigSet,
4981    materialize_dirty_blobs: bool,
4982    hash_blobs_without_write: bool,
4983    for_status: bool,
4984    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
4985    conv: &crate::crlf::ConversionConfig,
4986    filter_process: Option<&crate::filter_process::FilterProcessState>,
4987    _icase_collisions: Option<&crate::path_icase::IcaseCheckoutCollisionMap>,
4988    result: &mut Vec<DiffEntry>,
4989) -> Result<()> {
4990    use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
4991
4992    if jobs.is_empty() {
4993        return Ok(());
4994    }
4995    let mut par_opts = thread_policy.resolve(ParallelOp::IndexRefresh, config);
4996    par_opts.min_items = 0;
4997    par_opts.min_total_bytes = None;
4998    let est_bytes = jobs.len().saturating_mul(8192);
4999    let store_dirty_blobs = (materialize_dirty_blobs && !hash_blobs_without_write)
5000        .then(|| odb.bulk_stage_write_options());
5001    let stream_from = if store_dirty_blobs.is_some() {
5002        odb.big_file_threshold()?
5003    } else {
5004        hash_stream_threshold(odb)?
5005    };
5006    let default_attrs = crate::crlf::FileAttrs::default();
5007
5008    // Each job yields only the worktree side's oid when the entry changed; most jobs on a
5009    // stale index find clean files, so diff entries are built afterwards for the few changes.
5010    let new_oids = try_par_map::<StagingHashJob, Option<ObjectId>, Error, _>(
5011        jobs,
5012        par_opts,
5013        est_bytes,
5014        |job| {
5015            let entry = &source.index.entries[job.entry_index];
5016            let rel_path = crate::repo_relpath::storage_string(&entry.path);
5017            let modified = |new_oid| Ok(Some(new_oid));
5018            let file_path = index_entry_worktree_abs_bytes(
5019                source.work_tree,
5020                &entry.path,
5021                source.precompose_unicode,
5022                source.ignorecase,
5023            );
5024            let content = match read_regular_worktree_blob(
5025                odb,
5026                &file_path,
5027                conv,
5028                job.file_attrs.as_deref().unwrap_or(&default_attrs),
5029                &rel_path,
5030                Some(entry),
5031                filter_process,
5032                stream_from,
5033            ) {
5034                Ok(content) => content,
5035                Err(err)
5036                    if status_skips_inaccessible_worktree_error(
5037                        &err,
5038                        for_status,
5039                        materialize_dirty_blobs,
5040                    ) =>
5041                {
5042                    warn_and_skip_status_inaccessible(diagnostics, rel_path.as_str(), &err);
5043                    return Ok(None);
5044                }
5045                Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
5046                    return modified(zero_oid());
5047                }
5048                Err(err) => return Err(err),
5049            };
5050            let eff_oid = content.object_id(odb, store_dirty_blobs)?;
5051            if eff_oid != entry.oid || job.worktree_mode != entry.mode {
5052                let new_oid = if eff_oid != entry.oid && !materialize_dirty_blobs {
5053                    zero_oid()
5054                } else {
5055                    eff_oid
5056                };
5057                modified(new_oid)
5058            } else {
5059                Ok(None)
5060            }
5061        },
5062    )
5063    .map_err(|e| match e {
5064        ParallelHashError::Task(err) => err,
5065    })?;
5066
5067    let mut changed: Vec<(usize, u32, ObjectId)> = jobs
5068        .iter()
5069        .zip(new_oids)
5070        .filter_map(|(job, new_oid)| new_oid.map(|oid| (job.entry_index, job.worktree_mode, oid)))
5071        .collect();
5072    changed.sort_by_key(|(idx, _, _)| *idx);
5073    result.extend(
5074        changed
5075            .into_iter()
5076            .map(|(entry_index, worktree_mode, new_oid)| {
5077                let entry = &source.index.entries[entry_index];
5078                let rel_path = crate::repo_relpath::storage_string(&entry.path);
5079                worktree_content_change_entry(entry, &rel_path, worktree_mode, new_oid)
5080            }),
5081    );
5082    Ok(())
5083}
5084
5085/// Index-vs-worktree entry for a tracked path whose content or mode differs from `ie`.
5086fn worktree_content_change_entry(
5087    ie: &IndexEntry,
5088    path: &str,
5089    worktree_mode: u32,
5090    new_oid: ObjectId,
5091) -> DiffEntry {
5092    DiffEntry {
5093        status: DiffStatus::for_content_change(ie.mode, worktree_mode),
5094        old_path: Some(path.to_owned()),
5095        new_path: Some(path.to_owned()),
5096        old_mode: FileMode::new(ie.mode),
5097        new_mode: FileMode::new(worktree_mode),
5098        old_oid: ie.oid,
5099        new_oid,
5100        score: None,
5101    }
5102}
5103
5104/// Whether a failed read of a tracked file's content reports the path as modified (with an
5105/// unknown worktree oid) instead of failing the whole scan.
5106///
5107/// A file that cannot be opened (no permission, held exclusively by another process) cannot
5108/// be proven to match the index, so it counts as changed, as in Git. Callers that need the
5109/// bytes themselves (`materialize_dirty_blobs`, i.e. staging) still get the error.
5110fn unreadable_worktree_blob_is_modified(err: &Error, materialize_dirty_blobs: bool) -> bool {
5111    !materialize_dirty_blobs
5112        && matches!(
5113            err,
5114            Error::WorktreePathIo {
5115                operation: crate::worktree::WorktreePathOperation::Read,
5116                ..
5117            }
5118        )
5119}
5120
5121fn warn_worktree_access_failed(
5122    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5123    path: &str,
5124    err: &std::io::Error,
5125) {
5126    if let Some(handle) = diagnostics {
5127        crate::diagnostics::warn(
5128            handle,
5129            crate::diagnostics::Warning::WorktreeAccessFailed {
5130                path: path.to_owned(),
5131                detail: err.to_string(),
5132            },
5133        );
5134    }
5135}
5136
5137fn blob_disk_lookup_io_result(
5138    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5139    path: &str,
5140    err: std::io::Error,
5141    for_status: bool,
5142    materialize_dirty_blobs: bool,
5143) -> Result<()> {
5144    if for_status && !materialize_dirty_blobs {
5145        warn_worktree_access_failed(diagnostics, path, &err);
5146        return Ok(());
5147    }
5148    Err(Error::Io(err))
5149}
5150
5151fn io_err_is_permission_denied(err: &std::io::Error) -> bool {
5152    err.kind() == std::io::ErrorKind::PermissionDenied
5153}
5154
5155fn status_skips_inaccessible_worktree_error(
5156    err: &Error,
5157    for_status: bool,
5158    materialize_dirty_blobs: bool,
5159) -> bool {
5160    if !for_status || materialize_dirty_blobs {
5161        return false;
5162    }
5163    match err {
5164        Error::Io(e) => io_err_is_permission_denied(e),
5165        Error::WorktreePathIo {
5166            operation: crate::worktree::WorktreePathOperation::Metadata,
5167            source,
5168            ..
5169        } => io_err_is_permission_denied(source),
5170        _ => false,
5171    }
5172}
5173
5174fn warn_and_skip_status_inaccessible(
5175    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5176    path: &str,
5177    err: &Error,
5178) -> bool {
5179    let detail = match err {
5180        Error::Io(e) => e.to_string(),
5181        Error::WorktreePathIo { source, .. } => source.to_string(),
5182        _ => return false,
5183    };
5184    if let Some(handle) = diagnostics {
5185        crate::diagnostics::warn(
5186            handle,
5187            crate::diagnostics::Warning::WorktreeAccessFailed {
5188                path: path.to_owned(),
5189                detail,
5190            },
5191        );
5192    }
5193    true
5194}
5195
5196fn symlink_metadata_worktree_diff(
5197    file_path: &Path,
5198    path_str: &str,
5199    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5200    for_status: bool,
5201    materialize_dirty_blobs: bool,
5202) -> Result<Option<fs::Metadata>> {
5203    match fs::symlink_metadata(file_path) {
5204        Ok(meta) => Ok(Some(meta)),
5205        Err(e)
5206            if e.kind() == std::io::ErrorKind::NotFound
5207                || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5208        {
5209            Ok(None)
5210        }
5211        Err(e) => {
5212            if for_status && !materialize_dirty_blobs && io_err_is_permission_denied(&e) {
5213                warn_worktree_access_failed(diagnostics, path_str, &e);
5214                Ok(None)
5215            } else {
5216                Err(Error::Io(e))
5217            }
5218        }
5219    }
5220}
5221
5222/// Whether status may report `ie` modified because the worktree size (already munged to the
5223/// index's 32-bit form) differs from the cached one, without reading the file (Git
5224/// `compare_stat_data`).
5225///
5226/// Only sound when clean conversion is the identity: a line-ending rule, filter, encoding or
5227/// `ident` can turn a file of another size into exactly the indexed blob. The
5228/// `conversion_is_identity` check runs only once the sizes differ, so clean entries never
5229/// resolve attributes here.
5230fn status_size_change_proves_modified(
5231    ie: &IndexEntry,
5232    worktree_size: u32,
5233    for_status: bool,
5234    conversion_is_identity: impl FnOnce() -> bool,
5235) -> bool {
5236    for_status && !ie.stat_cache_unset() && worktree_size != ie.size && conversion_is_identity()
5237}
5238
5239/// Report `ie` modified when [`status_size_change_proves_modified`] holds; returns whether it
5240/// did. The worktree side uses the null OID because the file was not hashed.
5241#[allow(clippy::too_many_arguments)]
5242fn try_push_status_modified_on_size_mismatch(
5243    odb: &Odb,
5244    ie: &IndexEntry,
5245    path_str_ref: &str,
5246    worktree_size: u32,
5247    worktree_mode: u32,
5248    raw_worktree_len: u64,
5249    for_status: bool,
5250    conversion_is_identity: impl FnOnce() -> bool,
5251    result: &mut Vec<DiffEntry>,
5252) -> Result<bool> {
5253    let identity = conversion_is_identity();
5254    let size_differs = worktree_size != ie.size;
5255    if !size_differs || !identity {
5256        return Ok(false);
5257    }
5258    let threshold = odb.big_file_threshold()?;
5259    let big_file = raw_worktree_len > threshold;
5260    let proves_modified = if big_file {
5261        true
5262    } else {
5263        status_size_change_proves_modified(ie, worktree_size, for_status, || identity)
5264    };
5265    if !proves_modified {
5266        return Ok(false);
5267    }
5268    result.push(worktree_content_change_entry(
5269        ie,
5270        path_str_ref,
5271        worktree_mode,
5272        zero_oid(),
5273    ));
5274    Ok(true)
5275}
5276
5277#[allow(clippy::too_many_arguments)]
5278fn push_index_blob_worktree_diff(
5279    odb: &Odb,
5280    ie: &IndexEntry,
5281    path_str_ref: &str,
5282    file_path: &Path,
5283    meta: fs::Metadata,
5284    index_mtime: Option<(u32, u32)>,
5285    mode_policy: crate::index::WorktreeModePolicy,
5286    stat_policy: crate::index::StatMatchPolicy,
5287    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
5288    conv: &crate::crlf::ConversionConfig,
5289    materialize_dirty_blobs: bool,
5290    hash_blobs_without_write: bool,
5291    for_status: bool,
5292    diagnostics: Option<&crate::diagnostics::DiagnosticsHandle>,
5293    filter_process: Option<&crate::filter_process::FilterProcessState>,
5294    icase_collisions: Option<&crate::path_icase::IcaseCheckoutCollisionMap>,
5295    result: &mut Vec<DiffEntry>,
5296) -> Result<()> {
5297    if meta.is_dir() {
5298        if file_path.join(".git").exists() {
5299            let head = read_submodule_head_oid(file_path).unwrap_or_else(zero_oid);
5300            let path_owned = path_str_ref.to_owned();
5301            result.push(DiffEntry {
5302                status: DiffStatus::TypeChanged,
5303                old_path: Some(path_owned.clone()),
5304                new_path: Some(path_owned),
5305                old_mode: FileMode::new(ie.mode),
5306                new_mode: FileMode::new(0o160000),
5307                old_oid: ie.oid,
5308                new_oid: head,
5309                score: None,
5310            });
5311            return Ok(());
5312        }
5313        result.push(DiffEntry {
5314            status: DiffStatus::Deleted,
5315            old_path: Some(path_str_ref.to_owned()),
5316            new_path: None,
5317            old_mode: FileMode::new(ie.mode),
5318            new_mode: FileMode::ABSENT,
5319            old_oid: ie.oid,
5320            new_oid: zero_oid(),
5321            score: None,
5322        });
5323        return Ok(());
5324    }
5325
5326    let worktree_mode =
5327        mode_policy.index_mode_for_worktree(mode_from_metadata(&meta), Some(ie.mode));
5328    let stat_same = stat_matches_with_policy(ie, &meta, stat_policy);
5329    if stat_same && worktree_mode != ie.mode {
5330        result.push(worktree_content_change_entry(
5331            ie,
5332            path_str_ref,
5333            worktree_mode,
5334            ie.oid,
5335        ));
5336        return Ok(());
5337    }
5338
5339    if stat_same
5340        && index_entry_skips_worktree_content_hash_after_stat_match(ie, index_mtime, stat_policy)
5341    {
5342        return Ok(());
5343    }
5344
5345    let file_attrs = file_attrs_for(path_str_ref);
5346    if !icase_collisions.is_some_and(|map| map.diff_requires_worktree_hash(ie.path.as_slice()))
5347        && try_push_status_modified_on_size_mismatch(
5348            odb,
5349            ie,
5350            path_str_ref,
5351            crate::index::munge_index_size(meta.len()),
5352            worktree_mode,
5353            meta.len(),
5354            for_status,
5355            || crate::crlf::git_conversion_is_identity(conv, &file_attrs),
5356            result,
5357        )?
5358    {
5359        return Ok(());
5360    }
5361
5362    let store = (materialize_dirty_blobs && !hash_blobs_without_write)
5363        .then(|| odb.bulk_stage_write_options());
5364    let stream_from = if store.is_some() {
5365        odb.big_file_threshold()?
5366    } else {
5367        hash_stream_threshold(odb)?
5368    };
5369    let content = match worktree_blob_content_from(
5370        odb,
5371        file_path,
5372        &meta,
5373        conv,
5374        &file_attrs,
5375        path_str_ref,
5376        Some(ie),
5377        filter_process,
5378        stream_from,
5379    ) {
5380        Ok(content) => content,
5381        Err(err)
5382            if status_skips_inaccessible_worktree_error(
5383                &err,
5384                for_status,
5385                materialize_dirty_blobs,
5386            ) =>
5387        {
5388            warn_and_skip_status_inaccessible(diagnostics, path_str_ref, &err);
5389            return Ok(());
5390        }
5391        Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
5392            result.push(worktree_content_change_entry(
5393                ie,
5394                path_str_ref,
5395                worktree_mode,
5396                zero_oid(),
5397            ));
5398            return Ok(());
5399        }
5400        Err(err) => return Err(err),
5401    };
5402    let eff_oid = content.object_id(odb, store)?;
5403
5404    if eff_oid != ie.oid || worktree_mode != ie.mode {
5405        // `git diff-files --raw` uses the null OID for the worktree side when content differs.
5406        // Staging (`diff_index_to_worktree_for_staging`) materializes blobs and needs the real OID.
5407        let new_oid = if eff_oid != ie.oid && !materialize_dirty_blobs {
5408            zero_oid()
5409        } else {
5410            eff_oid
5411        };
5412        result.push(worktree_content_change_entry(
5413            ie,
5414            path_str_ref,
5415            worktree_mode,
5416            new_oid,
5417        ));
5418    }
5419    Ok(())
5420}
5421
5422/// Compare tracked index entries with the worktree, using link-target bytes for symlinks.
5423///
5424/// Symlink targets bypass regular-file filters and line-ending conversion. Parallel and
5425/// serial scans use the same file-type classification, including directory replacements
5426/// and regular-file placeholders checked out with `core.symlinks=false`.
5427///
5428/// # Errors
5429///
5430/// Returns errors from I/O or hashing.
5431pub fn diff_index_to_worktree_with_options(
5432    odb: &Odb,
5433    index: &mut Index,
5434    work_tree: &Path,
5435    options: DiffIndexToWorktreeOptions,
5436) -> Result<(Vec<DiffEntry>, bool)> {
5437    let default_git_dir = work_tree.join(".git");
5438    let git_dir = options
5439        .repository_git_dir
5440        .clone()
5441        .unwrap_or(default_git_dir);
5442    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false, None)
5443        .map(|(entries, changed, _)| (entries, changed))
5444}
5445
5446/// Like [`diff_index_to_worktree_with_options`], with an owner-thread progress poll hook for
5447/// long stat scans (used by [`crate::porcelain::status::status`]).
5448pub(crate) fn diff_index_to_worktree_with_owner_poll(
5449    odb: &Odb,
5450    index: &mut Index,
5451    work_tree: &Path,
5452    options: DiffIndexToWorktreeOptions,
5453    owner_poll: Option<std::sync::Arc<crate::progress::StackCancelPoll>>,
5454) -> Result<(Vec<DiffEntry>, bool)> {
5455    let default_git_dir = work_tree.join(".git");
5456    let git_dir = options
5457        .repository_git_dir
5458        .clone()
5459        .unwrap_or(default_git_dir);
5460    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false, owner_poll)
5461        .map(|(entries, changed, _)| (entries, changed))
5462}
5463
5464/// Like [`diff_index_to_worktree_with_options`], but loads config from `repository_git_dir`
5465/// (the main repository for linked worktrees) and persists each hashed worktree blob into `odb`
5466/// so staging can update the index without re-reading file contents.
5467/// Symlinks store their target bytes. With [`DiffIndexToWorktreeOptions::hash_blobs_without_write`],
5468/// returns the same blob IDs without writing objects, for staging previews.
5469pub fn diff_index_to_worktree_for_staging(
5470    odb: &Odb,
5471    repository_git_dir: &Path,
5472    index: &mut Index,
5473    work_tree: &Path,
5474    options: DiffIndexToWorktreeOptions,
5475) -> Result<(
5476    Vec<DiffEntry>,
5477    bool,
5478    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
5479)> {
5480    diff_index_to_worktree_inner(
5481        odb,
5482        index,
5483        work_tree,
5484        repository_git_dir,
5485        options,
5486        true,
5487        None,
5488    )
5489}
5490
5491/// First pass of [`diff_index_to_worktree_inner`]: gitlinks and intent-to-add paths only.
5492#[allow(clippy::too_many_arguments)]
5493fn worktree_diff_pass1_index_entry(
5494    odb: &Odb,
5495    work_tree: &Path,
5496    ie: &IndexEntry,
5497    path_str_ref: &str,
5498    simplify_gitlinks: bool,
5499    ignore_submodule_untracked: bool,
5500    error_on_broken_gitlinks: bool,
5501    materialize_dirty_blobs: bool,
5502    filter_fp: Option<&crate::filter_process::FilterProcessState>,
5503    conv: &crate::crlf::ConversionConfig,
5504    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
5505    precompose_unicode: bool,
5506    ignorecase: bool,
5507    result: &mut Vec<DiffEntry>,
5508) -> Result<()> {
5509    if ie.skip_worktree() || ie.assume_unchanged() {
5510        return Ok(());
5511    }
5512    let is_intent_to_add = ie.intent_to_add();
5513
5514    if ie.mode == 0o160000 {
5515        let sub_dir =
5516            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
5517        match fs::symlink_metadata(&sub_dir) {
5518            Err(e)
5519                if e.kind() == std::io::ErrorKind::NotFound
5520                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5521            {
5522                if !simplify_gitlinks {
5523                    let path_owned = path_str_ref.to_owned();
5524                    result.push(DiffEntry {
5525                        status: DiffStatus::Deleted,
5526                        old_path: Some(path_owned.clone()),
5527                        new_path: Some(path_owned),
5528                        old_mode: FileMode::new(ie.mode),
5529                        new_mode: FileMode::ABSENT,
5530                        old_oid: ie.oid,
5531                        new_oid: zero_oid(),
5532                        score: None,
5533                    });
5534                }
5535                return Ok(());
5536            }
5537            Err(e) => return Err(Error::Io(e)),
5538            Ok(meta) if !meta.is_dir() => {
5539                let worktree_mode = mode_from_metadata(&meta);
5540                let file_attrs = file_attrs_for(path_str_ref);
5541                let worktree_oid = worktree_file_oid(
5542                    odb,
5543                    &sub_dir,
5544                    &meta,
5545                    conv,
5546                    &file_attrs,
5547                    path_str_ref,
5548                    None,
5549                    materialize_dirty_blobs,
5550                    filter_fp,
5551                )?;
5552                let path_owned = path_str_ref.to_owned();
5553                result.push(DiffEntry {
5554                    status: DiffStatus::TypeChanged,
5555                    old_path: Some(path_owned.clone()),
5556                    new_path: Some(path_owned),
5557                    old_mode: FileMode::new(ie.mode),
5558                    new_mode: FileMode::new(worktree_mode),
5559                    old_oid: ie.oid,
5560                    new_oid: worktree_oid,
5561                    score: None,
5562                });
5563                return Ok(());
5564            }
5565            Ok(_) => {}
5566        }
5567        let sub_head_oid = read_submodule_head_oid(&sub_dir);
5568        let ref_matches = if let Some(oid) = sub_head_oid {
5569            oid == ie.oid
5570        } else {
5571            if error_on_broken_gitlinks && submodule_embedded_git_dir(&sub_dir).is_some() {
5572                return Err(Error::Config(
5573                    format!("could not read submodule HEAD for '{path_str_ref}'").into(),
5574                ));
5575            }
5576            uninitialized_gitlink_worktree_matches_index(&sub_dir)
5577        };
5578        if simplify_gitlinks {
5579            if !ref_matches {
5580                let path_owned = path_str_ref.to_owned();
5581                let new_oid = sub_head_oid.unwrap_or_else(zero_oid);
5582                result.push(DiffEntry {
5583                    status: DiffStatus::Modified,
5584                    old_path: Some(path_owned.clone()),
5585                    new_path: Some(path_owned),
5586                    old_mode: FileMode::new(ie.mode),
5587                    new_mode: FileMode::new(ie.mode),
5588                    old_oid: ie.oid,
5589                    new_oid,
5590                    score: None,
5591                });
5592            }
5593            return Ok(());
5594        }
5595        if sub_head_oid.is_some() && submodule_head_object_broken(&sub_dir) {
5596            return Err(Error::Config(
5597                format!("'git status --porcelain=2' failed in submodule {path_str_ref}").into(),
5598            ));
5599        }
5600        let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
5601        if ignore_submodule_untracked {
5602            flags.untracked = false;
5603        }
5604        let inner_dirty = flags.modified || flags.untracked;
5605        if !ref_matches || inner_dirty {
5606            let path_owned = path_str_ref.to_owned();
5607            let new_oid = if !ref_matches {
5608                sub_head_oid.unwrap_or_else(zero_oid)
5609            } else {
5610                zero_oid()
5611            };
5612            result.push(DiffEntry {
5613                status: DiffStatus::Modified,
5614                old_path: Some(path_owned.clone()),
5615                new_path: Some(path_owned),
5616                old_mode: FileMode::new(ie.mode),
5617                new_mode: FileMode::new(ie.mode),
5618                old_oid: ie.oid,
5619                new_oid,
5620                score: None,
5621            });
5622        }
5623        return Ok(());
5624    }
5625
5626    let file_path =
5627        index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
5628    let path_match = crate::repo_relpath::as_utf8_for_matching(path_str_ref);
5629
5630    if is_intent_to_add {
5631        match fs::symlink_metadata(&file_path) {
5632            Ok(meta) => {
5633                let file_attrs = file_attrs_for(path_match.as_ref());
5634                let worktree_oid = worktree_file_oid(
5635                    odb,
5636                    &file_path,
5637                    &meta,
5638                    conv,
5639                    &file_attrs,
5640                    path_match.as_ref(),
5641                    None,
5642                    materialize_dirty_blobs,
5643                    filter_fp,
5644                )?;
5645                let worktree_mode = mode_from_metadata(&meta);
5646                result.push(DiffEntry {
5647                    status: DiffStatus::Added,
5648                    old_path: None,
5649                    new_path: Some(path_str_ref.to_owned()),
5650                    old_mode: FileMode::ABSENT,
5651                    new_mode: FileMode::new(worktree_mode),
5652                    old_oid: zero_oid(),
5653                    new_oid: worktree_oid,
5654                    score: None,
5655                });
5656            }
5657            Err(e)
5658                if e.kind() == std::io::ErrorKind::NotFound
5659                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5660            {
5661                result.push(DiffEntry {
5662                    status: DiffStatus::Deleted,
5663                    old_path: Some(path_str_ref.to_owned()),
5664                    new_path: None,
5665                    old_mode: FileMode::new(ie.mode),
5666                    new_mode: FileMode::ABSENT,
5667                    old_oid: ie.oid,
5668                    new_oid: zero_oid(),
5669                    score: None,
5670                });
5671            }
5672            Err(e) => return Err(Error::Io(e)),
5673        }
5674    }
5675    Ok(())
5676}
5677
5678#[inline]
5679fn index_entry_needs_worktree_diff_pass1(ie: &IndexEntry) -> bool {
5680    ie.mode == crate::index::MODE_GITLINK || ie.intent_to_add()
5681}
5682
5683fn diff_index_to_worktree_inner(
5684    odb: &Odb,
5685    index: &mut Index,
5686    work_tree: &Path,
5687    repository_git_dir: &Path,
5688    options: DiffIndexToWorktreeOptions,
5689    materialize_dirty_blobs: bool,
5690    owner_poll: Option<std::sync::Arc<crate::progress::StackCancelPoll>>,
5691) -> Result<(
5692    Vec<DiffEntry>,
5693    bool,
5694    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
5695)> {
5696    use crate::config::ConfigSet;
5697    use crate::crlf;
5698    use std::collections::BTreeMap;
5699
5700    let mut staging_stat_cache = BTreeMap::new();
5701    poll_diff_cancel(&options, owner_poll.as_ref())?;
5702    let index_mtime = index_mtime_for_diff(index, options.index_mtime);
5703
5704    let ignore_submodule_untracked = options.ignore_submodule_untracked;
5705    let simplify_gitlinks = options.simplify_gitlinks;
5706
5707    let rules_lock = options
5708        .worktree_rules
5709        .as_ref()
5710        .map(|shared| {
5711            shared
5712                .lock()
5713                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
5714        })
5715        .transpose()?;
5716    let config = if let Some(rules) = rules_lock.as_ref() {
5717        rules.config().clone()
5718    } else if let Some(c) = options.config.as_ref() {
5719        c.as_ref().clone()
5720    } else {
5721        ConfigSet::load(
5722            &crate::environment::Environment::empty(),
5723            Some(repository_git_dir),
5724            true,
5725        )
5726        .unwrap_or_else(|_| ConfigSet::new())
5727    };
5728    let conv = rules_lock
5729        .as_ref()
5730        .map(|r| r.conversion().clone())
5731        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
5732    let legacy_attrs = if rules_lock.is_none() {
5733        Some(crlf::load_gitattributes_parsed(work_tree))
5734    } else {
5735        None
5736    };
5737    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
5738        if let Some(rules) = rules_lock.as_ref() {
5739            rules.file_attrs(path, false)
5740        } else if let Some(parsed) = legacy_attrs.as_ref() {
5741            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
5742        } else {
5743            crlf::FileAttrs::default()
5744        }
5745    };
5746    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
5747    let precompose_unicode = config
5748        .get_bool("core.precomposeunicode")
5749        .and_then(|r| r.ok())
5750        .unwrap_or(false);
5751    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
5752    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
5753    let ignorecase = config
5754        .get_bool("core.ignorecase")
5755        .and_then(|r| r.ok())
5756        .unwrap_or(false);
5757    let icase_collisions = ignorecase
5758        .then(|| crate::path_icase::IcaseCheckoutCollisionMap::from_index(work_tree, index, true));
5759    let hash_blobs_without_write = options.hash_blobs_without_write;
5760
5761    let mut result = Vec::new();
5762    let refresh_index_stat_in_pass = options.refresh_index_stat_in_pass;
5763    let mut index_stat_refresh_changed = false;
5764    let mut fsmonitor_valid_updated = false;
5765    let mut honor_fsmonitor_valid = false;
5766    if options.refresh_fsmonitor {
5767        let default_runner = crate::command_runner::system_command_runner();
5768        let runner = options
5769            .command_runner
5770            .as_deref()
5771            .unwrap_or(default_runner.as_ref());
5772        let boundary = options.fsmonitor_query_boundary.unwrap_or_else(|| {
5773            crate::fsmonitor::FsmonitorQueryBoundary(
5774                crate::environment::wall_clock_unix_timestamp_nanos(),
5775            )
5776        });
5777        let refresh_opts = crate::fsmonitor::FsmonitorRefreshOptions::with_query_boundary(boundary);
5778        let refresh = crate::fsmonitor::refresh_index(
5779            index,
5780            repository_git_dir,
5781            work_tree,
5782            &config,
5783            Some(runner),
5784            &refresh_opts,
5785        )?;
5786        if refresh.index_changed {
5787            index_stat_refresh_changed = true;
5788        }
5789        honor_fsmonitor_valid = refresh.honor_valid_bits;
5790    }
5791    let use_fsmonitor_stat_skip = honor_fsmonitor_valid;
5792    let mut unmerged_base: std::collections::BTreeMap<String, (u8, IndexEntry)> =
5793        std::collections::BTreeMap::new();
5794
5795    let error_on_broken_gitlinks = options.error_on_broken_gitlinks;
5796    if options.restrict_to_paths.is_none() {
5797        for ie in &index.entries {
5798            poll_diff_cancel(&options, owner_poll.as_ref())?;
5799            if ie.stage() != 0 {
5800                let path = String::from_utf8_lossy(&ie.path).to_string();
5801                let rank = match ie.stage() {
5802                    2 => 0u8,
5803                    3 => 1u8,
5804                    1 => 2u8,
5805                    _ => 3u8,
5806                };
5807                match unmerged_base.get(&path) {
5808                    Some((existing_rank, _)) if *existing_rank <= rank => {}
5809                    _ => {
5810                        unmerged_base.insert(path, (rank, ie.clone()));
5811                    }
5812                }
5813            }
5814        }
5815    }
5816
5817    if let Some(limit) = options.restrict_to_paths.as_ref() {
5818        for path in limit.iter() {
5819            let Some(ie) = index.get(path.as_bytes(), 0) else {
5820                continue;
5821            };
5822            if !index_entry_needs_worktree_diff_pass1(ie) {
5823                continue;
5824            }
5825            worktree_diff_pass1_index_entry(
5826                odb,
5827                work_tree,
5828                ie,
5829                path.as_str(),
5830                simplify_gitlinks,
5831                ignore_submodule_untracked,
5832                error_on_broken_gitlinks,
5833                materialize_dirty_blobs,
5834                filter_fp,
5835                &conv,
5836                &file_attrs_for,
5837                precompose_unicode,
5838                ignorecase,
5839                &mut result,
5840            )?;
5841        }
5842    } else {
5843        for ie in &index.entries {
5844            poll_diff_cancel(&options, owner_poll.as_ref())?;
5845            if ie.stage() != 0 {
5846                continue;
5847            }
5848            if !index_entry_needs_worktree_diff_pass1(ie) {
5849                continue;
5850            }
5851            let path_stored = crate::repo_relpath::storage_string(&ie.path);
5852            worktree_diff_pass1_index_entry(
5853                odb,
5854                work_tree,
5855                ie,
5856                &path_stored,
5857                simplify_gitlinks,
5858                ignore_submodule_untracked,
5859                error_on_broken_gitlinks,
5860                materialize_dirty_blobs,
5861                filter_fp,
5862                &conv,
5863                &file_attrs_for,
5864                precompose_unicode,
5865                ignorecase,
5866                &mut result,
5867            )?;
5868        }
5869    }
5870
5871    use crate::worktree_scan::{
5872        collect_blob_lookup_batches_with_literal_paths, group_blob_entries_by_dir,
5873        stat_scan_thread_count, visit_blob_lookup_batches, BlobDiskLookup, BlobScanSparseMode,
5874        WorktreeBlobScanOptions, PARALLEL_STAT_MIN_ENTRIES,
5875    };
5876    let preload_index = config
5877        .get_bool("core.preloadIndex")
5878        .and_then(|r| r.ok())
5879        .unwrap_or(true);
5880    let blob_scan = WorktreeBlobScanOptions {
5881        preload_index,
5882        stat_parallel_threads: options.stat_parallel_threads,
5883        ..WorktreeBlobScanOptions::default()
5884    };
5885    let blob_dirs = if let Some(limit) = options.restrict_to_paths.as_ref() {
5886        crate::worktree_scan::group_blob_entries_by_dir_for_paths(index, limit)
5887    } else {
5888        group_blob_entries_by_dir(index, honor_fsmonitor_valid)
5889    };
5890    let work_tree_owned = work_tree.to_path_buf();
5891    let dir_abs = move |dir: &[u8]| {
5892        if dir.is_empty() {
5893            work_tree_owned.clone()
5894        } else {
5895            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
5896        }
5897    };
5898    let work_tree_for_files = work_tree.to_path_buf();
5899    let file_abs = move |rel: &[u8]| {
5900        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
5901    };
5902    let blob_count = blob_dirs.len();
5903    let thread_policy = options.thread_policy.unwrap_or_default();
5904    let hash_threads = stat_scan_thread_count(&thread_policy, &config, blob_scan, blob_count);
5905    let parallel_blob_work = blob_count >= PARALLEL_STAT_MIN_ENTRIES && hash_threads > 1;
5906    let mut staging_hash_jobs: Vec<StagingHashJob> = Vec::new();
5907    // Entries the stat refresh just hashed and found matching their recorded blob.
5908    let mut refresh_content_verified = vec![false; index.entries.len()];
5909    // Parallel preload must lstat ancestor directories without following symlinks. When a
5910    // tracked directory is replaced by a symlink, indexed paths under it are deletions even if
5911    // the link target still holds a file at the same relative path with an unchanged stat.
5912    // This is independent of `core.symlinks` (checkout support); detection must stay enabled.
5913    let sparse = if parallel_blob_work {
5914        BlobScanSparseMode::StatusDiff {
5915            stat_policy,
5916            index_mtime,
5917            mode_policy,
5918            for_status: options.for_status,
5919            refresh_index_stat_in_pass,
5920            parallel_blob_work,
5921            honor_fsmonitor_stat_skip: use_fsmonitor_stat_skip,
5922        }
5923    } else {
5924        BlobScanSparseMode::Dense
5925    };
5926    let blob_stat_batches = collect_blob_lookup_batches_with_literal_paths(
5927        index,
5928        &blob_dirs,
5929        blob_scan,
5930        &thread_policy,
5931        &config,
5932        dir_abs,
5933        file_abs,
5934        !precompose_unicode && !ignorecase,
5935        || {
5936            let mut dir_symlinks = SymlinkDirCache::default();
5937            move |rel_path: &[u8]| {
5938                dir_symlinks.has_symlink_in_path_bytes(
5939                    work_tree,
5940                    rel_path,
5941                    precompose_unicode,
5942                    ignorecase,
5943                )
5944            }
5945        },
5946        sparse,
5947        options.cancel_watch.clone(),
5948        owner_poll.clone(),
5949    )?;
5950    // Size-changed entries ask whether their clean conversion is the identity in both the stat
5951    // refresh and the visit below; resolve each one's attributes once.
5952    let identity_by_entry = std::cell::RefCell::new(std::collections::HashMap::new());
5953    let conversion_is_identity = |entry_index: usize, path: &str| -> bool {
5954        *identity_by_entry
5955            .borrow_mut()
5956            .entry(entry_index)
5957            .or_insert_with(|| crlf::git_conversion_is_identity(&conv, &file_attrs_for(path)))
5958    };
5959    let refresh_checks = if refresh_index_stat_in_pass {
5960        let par_opts =
5961            refresh_hash_parallel_options(&thread_policy, &config, options.stat_parallel_threads);
5962        refresh_content_checks(
5963            odb,
5964            index,
5965            &blob_stat_batches,
5966            index_mtime,
5967            stat_policy,
5968            &|entry_index, ie, stat| {
5969                !(use_fsmonitor_stat_skip && crate::fsmonitor::entry_skips_worktree_stat(ie, index))
5970                    && worktree_diff_refreshes_entry(
5971                        ie,
5972                        stat,
5973                        index_mtime,
5974                        stat_policy,
5975                        options.for_status,
5976                        || {
5977                            conversion_is_identity(
5978                                entry_index,
5979                                &crate::repo_relpath::storage_string(&ie.path),
5980                            )
5981                        },
5982                    )
5983            },
5984            &WorktreeHashContext {
5985                work_tree,
5986                precompose_unicode,
5987                ignorecase,
5988                conv: &conv,
5989                file_attrs_for: &file_attrs_for,
5990                filter_process: filter_fp,
5991            },
5992            par_opts,
5993            options.cancel_watch.as_ref(),
5994        )?
5995    } else {
5996        RefreshChecks::default()
5997    };
5998    poll_diff_cancel(&options, owner_poll.as_ref())?;
5999    visit_blob_lookup_batches(blob_stat_batches, |entry_index, lookup| {
6000        poll_diff_cancel(&options, owner_poll.as_ref())?;
6001        let path_owned = crate::repo_relpath::storage_string(&index.entries[entry_index].path);
6002        let path_str_ref = path_owned.as_str();
6003        if use_fsmonitor_stat_skip {
6004            let ie_skip = &index.entries[entry_index];
6005            if crate::fsmonitor::entry_skips_worktree_stat(ie_skip, index) {
6006                return Ok(());
6007            }
6008        }
6009        match lookup {
6010            BlobDiskLookup::Missing => {
6011                if icase_collisions.as_ref().is_some_and(|map| {
6012                    map.preserve_missing_collision_loser(&index.entries[entry_index].path)
6013                }) {
6014                    return Ok(());
6015                }
6016                let ie = &index.entries[entry_index];
6017                result.push(DiffEntry {
6018                    status: DiffStatus::Deleted,
6019                    old_path: Some(path_str_ref.to_owned()),
6020                    new_path: None,
6021                    old_mode: FileMode::new(ie.mode),
6022                    new_mode: FileMode::ABSENT,
6023                    old_oid: ie.oid,
6024                    new_oid: zero_oid(),
6025                    score: None,
6026                });
6027            }
6028            BlobDiskLookup::Io(e) => {
6029                blob_disk_lookup_io_result(
6030                    options.diagnostics.as_ref(),
6031                    path_str_ref,
6032                    e,
6033                    options.for_status,
6034                    materialize_dirty_blobs,
6035                )?;
6036            }
6037            BlobDiskLookup::Present(stat) => {
6038                if materialize_dirty_blobs {
6039                    staging_stat_cache.insert(path_str_ref.to_owned(), stat.fields);
6040                }
6041                if refresh_index_stat_in_pass
6042                    && worktree_diff_refreshes_entry(
6043                        &index.entries[entry_index],
6044                        &stat,
6045                        index_mtime,
6046                        stat_policy,
6047                        options.for_status,
6048                        || conversion_is_identity(entry_index, path_str_ref),
6049                    )
6050                {
6051                    let content_matches = RefreshContent::matches(
6052                        refresh_checks.get(entry_index),
6053                        &index.entries[entry_index].oid,
6054                    );
6055                    let refresh = refresh_index_blob_stat_from_scan(
6056                        &mut index.entries[entry_index],
6057                        &stat,
6058                        index_mtime,
6059                        stat_policy,
6060                        content_matches,
6061                    );
6062                    if refresh.index_changed {
6063                        index_stat_refresh_changed = true;
6064                    }
6065                    if refresh.content_verified {
6066                        refresh_content_verified[entry_index] = true;
6067                        let path = index.entries[entry_index].path.clone();
6068                        let oid = index.entries[entry_index].oid;
6069                        index.mark_content_verified(&path, oid);
6070                    }
6071                }
6072                let ie = &index.entries[entry_index];
6073                let worktree_mode =
6074                    mode_policy.index_mode_for_worktree(stat.git_mode, Some(ie.mode));
6075                // The stat refresh already hashed this file and found other content, so do not
6076                // read it again. Staging hashes again because it needs the blob stored.
6077                if !materialize_dirty_blobs
6078                    && !stat.is_dir
6079                    && stat.git_mode != crate::index::MODE_SYMLINK
6080                    && RefreshContent::differs(refresh_checks.get(entry_index), &ie.oid)
6081                {
6082                    result.push(worktree_content_change_entry(
6083                        ie,
6084                        path_str_ref,
6085                        worktree_mode,
6086                        zero_oid(),
6087                    ));
6088                    return Ok(());
6089                }
6090                // Streaming jobs read regular files. Links and directories need the shared
6091                // classifier; use the physical type so core.symlinks=false placeholders
6092                // keep the regular-file fast path even with an effective symlink mode.
6093                if parallel_blob_work && !stat.is_dir && stat.git_mode != crate::index::MODE_SYMLINK
6094                {
6095                    let stat_same = stat_matches_blob_stat(ie, &stat, stat_policy);
6096                    if stat_same
6097                        && worktree_mode == ie.mode
6098                        && index_entry_skips_worktree_content_hash_after_stat_match(
6099                            ie,
6100                            index_mtime,
6101                            stat_policy,
6102                        )
6103                    {
6104                        if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(index, entry_index) {
6105                            fsmonitor_valid_updated = true;
6106                        }
6107                        return Ok(());
6108                    }
6109                    if stat_same && worktree_mode != ie.mode {
6110                        result.push(worktree_content_change_entry(
6111                            ie,
6112                            path_str_ref,
6113                            worktree_mode,
6114                            ie.oid,
6115                        ));
6116                        return Ok(());
6117                    }
6118                    if !icase_collisions.as_ref().is_some_and(|map| {
6119                        map.diff_requires_worktree_hash(&index.entries[entry_index].path)
6120                    }) && try_push_status_modified_on_size_mismatch(
6121                        odb,
6122                        ie,
6123                        path_str_ref,
6124                        stat.worktree_size(),
6125                        worktree_mode,
6126                        stat.raw_size,
6127                        options.for_status,
6128                        || conversion_is_identity(entry_index, path_str_ref),
6129                        &mut result,
6130                    )? {
6131                        return Ok(());
6132                    }
6133                    if refresh_content_verified[entry_index] {
6134                        if worktree_mode == ie.mode {
6135                            if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(
6136                                index,
6137                                entry_index,
6138                            ) {
6139                                fsmonitor_valid_updated = true;
6140                            }
6141                        } else {
6142                            result.push(worktree_content_change_entry(
6143                                ie,
6144                                path_str_ref,
6145                                worktree_mode,
6146                                ie.oid,
6147                            ));
6148                        }
6149                    } else {
6150                        staging_hash_jobs.push(StagingHashJob::new(
6151                            entry_index,
6152                            worktree_mode,
6153                            file_attrs_for(path_str_ref),
6154                        ));
6155                    }
6156                } else {
6157                    // The scan's lstat already proves an unchanged, non-racy entry clean, and the
6158                    // stat refresh above may have just verified a racy entry by content; only
6159                    // re-stat paths that `push_index_blob_worktree_diff` has to inspect.
6160                    let same_file_type = (worktree_mode == crate::index::MODE_SYMLINK)
6161                        == (ie.mode == crate::index::MODE_SYMLINK);
6162                    if !stat.is_dir
6163                        && worktree_mode == ie.mode
6164                        && ((refresh_content_verified[entry_index] && same_file_type)
6165                            || (stat_matches_blob_stat(ie, &stat, stat_policy)
6166                                && index_entry_skips_worktree_content_hash_after_stat_match(
6167                                    ie,
6168                                    index_mtime,
6169                                    stat_policy,
6170                                )))
6171                    {
6172                        if crate::fsmonitor::mark_fsmonitor_valid_proven_clean(index, entry_index) {
6173                            fsmonitor_valid_updated = true;
6174                        }
6175                        return Ok(());
6176                    }
6177                    let file_path = index_entry_worktree_abs_bytes(
6178                        work_tree,
6179                        &index.entries[entry_index].path,
6180                        precompose_unicode,
6181                        ignorecase,
6182                    );
6183                    let Some(meta) = symlink_metadata_worktree_diff(
6184                        &file_path,
6185                        path_str_ref,
6186                        options.diagnostics.as_ref(),
6187                        options.for_status,
6188                        materialize_dirty_blobs,
6189                    )?
6190                    else {
6191                        return Ok(());
6192                    };
6193                    push_index_blob_worktree_diff(
6194                        odb,
6195                        ie,
6196                        path_str_ref,
6197                        &file_path,
6198                        meta,
6199                        index_mtime,
6200                        mode_policy,
6201                        stat_policy,
6202                        &file_attrs_for,
6203                        &conv,
6204                        materialize_dirty_blobs,
6205                        hash_blobs_without_write,
6206                        options.for_status,
6207                        options.diagnostics.as_ref(),
6208                        filter_fp,
6209                        icase_collisions.as_ref(),
6210                        &mut result,
6211                    )?;
6212                }
6213            }
6214        }
6215        Ok(())
6216    })?;
6217    if parallel_blob_work && !staging_hash_jobs.is_empty() {
6218        parallel_process_blob_worktree_jobs(
6219            odb,
6220            &StagingHashSource {
6221                index,
6222                work_tree,
6223                precompose_unicode,
6224                ignorecase,
6225            },
6226            &staging_hash_jobs,
6227            &thread_policy,
6228            &config,
6229            materialize_dirty_blobs,
6230            hash_blobs_without_write,
6231            options.for_status,
6232            options.diagnostics.as_ref(),
6233            &conv,
6234            filter_fp,
6235            icase_collisions.as_ref(),
6236            &mut result,
6237        )?;
6238    }
6239
6240    for (path, (_, base_entry)) in unmerged_base {
6241        let file_path = index_entry_worktree_abs(work_tree, &path, precompose_unicode, ignorecase);
6242        let wt_meta = match fs::symlink_metadata(&file_path) {
6243            Ok(meta) => Some(meta),
6244            Err(e)
6245                if e.kind() == std::io::ErrorKind::NotFound
6246                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
6247            {
6248                None
6249            }
6250            Err(e) => return Err(Error::Io(e)),
6251        };
6252
6253        let new_mode = wt_meta.as_ref().map_or_else(
6254            || FileMode::ABSENT,
6255            |meta| FileMode::new(mode_from_metadata(meta)),
6256        );
6257        result.push(DiffEntry {
6258            status: DiffStatus::Unmerged,
6259            old_path: Some(path.clone()),
6260            new_path: Some(path.clone()),
6261            old_mode: FileMode::ABSENT,
6262            new_mode,
6263            old_oid: zero_oid(),
6264            new_oid: zero_oid(),
6265            score: None,
6266        });
6267
6268        if !options.for_status {
6269            if let Some(meta) = wt_meta {
6270                let file_attrs = file_attrs_for(&path);
6271                let wt_oid = worktree_file_oid(
6272                    odb,
6273                    &file_path,
6274                    &meta,
6275                    &conv,
6276                    &file_attrs,
6277                    &path,
6278                    Some(&base_entry),
6279                    materialize_dirty_blobs,
6280                    filter_fp,
6281                )?;
6282                let wt_mode = mode_from_metadata(&meta);
6283                if wt_oid != base_entry.oid || wt_mode != base_entry.mode {
6284                    result.push(DiffEntry {
6285                        status: DiffStatus::for_content_change(base_entry.mode, wt_mode),
6286                        old_path: Some(path.clone()),
6287                        new_path: Some(path),
6288                        old_mode: FileMode::new(base_entry.mode),
6289                        new_mode: FileMode::new(wt_mode),
6290                        old_oid: base_entry.oid,
6291                        new_oid: wt_oid,
6292                        score: None,
6293                    });
6294                }
6295            }
6296        }
6297    }
6298
6299    if fsmonitor_valid_updated {
6300        index.fsmonitor_validity_changed();
6301        index_stat_refresh_changed = true;
6302    }
6303
6304    Ok((result, index_stat_refresh_changed, staging_stat_cache))
6305}
6306
6307/// Per-worker cache answering "is any ancestor directory of this index path a symlink?".
6308///
6309/// It remembers the last chain of ancestors verified to be real directories, plus the first
6310/// ancestor found not to be one. Index paths arrive in sorted order, so consecutive queries
6311/// share most of their leading directories and only the differing components are probed.
6312/// Each scan worker owns its own cache: there is no locking and no per-directory allocation.
6313#[derive(Default)]
6314pub(crate) struct SymlinkDirCache {
6315    /// Leading directory (raw index bytes, no trailing `/`) whose every component is a real
6316    /// directory on disk.
6317    real_dirs: Vec<u8>,
6318    /// First ancestor found not to be a real directory, and whether it is a symlink. Every
6319    /// path under it shares that answer.
6320    blocked: Option<(Vec<u8>, bool)>,
6321    /// Single-walk check for directories several components below `real_dirs`.
6322    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
6323    chain: Option<crate::worktree_scan::RealDirChain>,
6324    /// Filesystem queries made so far.
6325    #[cfg(test)]
6326    fs_queries: usize,
6327}
6328
6329/// Whether `path` is `dir` itself or lies under it (`dir` has no trailing `/`).
6330fn path_within_dir(path: &[u8], dir: &[u8]) -> bool {
6331    path.starts_with(dir) && path.get(dir.len()).is_none_or(|&b| b == b'/')
6332}
6333
6334/// Length of the longest leading run of whole components shared by two directory paths.
6335fn common_dir_prefix_len(a: &[u8], b: &[u8]) -> usize {
6336    let same = a.iter().zip(b).take_while(|(x, y)| x == y).count();
6337    let ends_component = |p: &[u8]| p.get(same).is_none_or(|&c| c == b'/');
6338    if ends_component(a) && ends_component(b) {
6339        return same;
6340    }
6341    a[..same].iter().rposition(|&c| c == b'/').unwrap_or(0)
6342}
6343
6344pub(crate) fn index_entry_worktree_abs(
6345    work_tree: &Path,
6346    index_relpath: &str,
6347    precompose_unicode: bool,
6348    ignorecase: bool,
6349) -> PathBuf {
6350    index_entry_worktree_abs_bytes(
6351        work_tree,
6352        index_relpath.as_bytes(),
6353        precompose_unicode,
6354        ignorecase,
6355    )
6356}
6357
6358pub(crate) fn index_entry_worktree_abs_bytes(
6359    work_tree: &Path,
6360    index_relpath: &[u8],
6361    precompose_unicode: bool,
6362    ignorecase: bool,
6363) -> PathBuf {
6364    if precompose_unicode || ignorecase {
6365        if let Ok(s) = std::str::from_utf8(index_relpath) {
6366            if precompose_unicode {
6367                return crate::unicode_normalization::resolve_worktree_path_for_staging(
6368                    work_tree, s, true,
6369                )
6370                .abs;
6371            }
6372            return crate::path_icase::worktree_path_for_index_entry(work_tree, s, ignorecase);
6373        }
6374    }
6375    crate::index::worktree_path_from_index_rel(work_tree, index_relpath)
6376}
6377
6378/// Effective index-file mtime for racy-git checks.
6379///
6380/// Uses an explicit override when provided, otherwise [`Index::source_mtime`] from the last
6381/// on-disk read.
6382#[must_use]
6383pub fn index_mtime_for_diff(index: &Index, explicit: Option<(u32, u32)>) -> Option<(u32, u32)> {
6384    explicit.or(index.source_mtime)
6385}
6386
6387impl SymlinkDirCache {
6388    /// Whether any parent directory of `rel_path` (raw index path bytes) is a symlink.
6389    ///
6390    /// Assumes the worktree does not change during the scan this cache serves. A missing
6391    /// ancestor, or one that is a file, is not a symlink and hides everything below it.
6392    pub(crate) fn has_symlink_in_path_bytes(
6393        &mut self,
6394        work_tree: &Path,
6395        rel_path: &[u8],
6396        precompose_unicode: bool,
6397        ignorecase: bool,
6398    ) -> bool {
6399        use crate::worktree_scan::{ancestor_kind, AncestorKind};
6400
6401        let Some(dir_len) = rel_path.iter().rposition(|&b| b == b'/') else {
6402            return false;
6403        };
6404        let dir = &rel_path[..dir_len];
6405        if let Some((blocked, is_symlink)) = &self.blocked {
6406            if path_within_dir(dir, blocked) {
6407                return *is_symlink;
6408            }
6409        }
6410        let verified = common_dir_prefix_len(dir, &self.real_dirs);
6411        self.real_dirs.truncate(verified);
6412        #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
6413        if !precompose_unicode
6414            && !ignorecase
6415            && dir[verified..]
6416                .split(|&b| b == b'/')
6417                .filter(|component| !component.is_empty())
6418                .nth(1)
6419                .is_some()
6420        {
6421            let chain = match &mut self.chain {
6422                Some(chain) if chain.work_tree() == work_tree => chain,
6423                slot => slot.insert(crate::worktree_scan::RealDirChain::open(work_tree)),
6424            };
6425            #[cfg(test)]
6426            {
6427                self.fs_queries += 1;
6428            }
6429            if chain.confirms(dir) {
6430                self.real_dirs.clear();
6431                self.real_dirs.extend_from_slice(dir);
6432                return false;
6433            }
6434        }
6435        let mut start = verified;
6436        while start < dir.len() {
6437            if dir[start] == b'/' {
6438                start += 1;
6439                continue;
6440            }
6441            let end = dir[start..]
6442                .iter()
6443                .position(|&b| b == b'/')
6444                .map_or(dir.len(), |i| start + i);
6445            let prefix = &dir[..end];
6446            let abs =
6447                index_entry_worktree_abs_bytes(work_tree, prefix, precompose_unicode, ignorecase);
6448            #[cfg(test)]
6449            {
6450                self.fs_queries += 1;
6451            }
6452            match ancestor_kind(&abs) {
6453                AncestorKind::Directory => {
6454                    let known = self.real_dirs.len();
6455                    self.real_dirs.extend_from_slice(&prefix[known..]);
6456                }
6457                AncestorKind::Symlink => {
6458                    self.blocked = Some((prefix.to_vec(), true));
6459                    return true;
6460                }
6461                AncestorKind::Other => {
6462                    self.blocked = Some((prefix.to_vec(), false));
6463                    return false;
6464                }
6465            }
6466            start = end;
6467        }
6468        false
6469    }
6470}
6471
6472/// Whether an index entry's cached mtime is at or after the on-disk index file's mtime.
6473///
6474/// Git labels such entries racy: stat data can look clean even though the worktree changed
6475/// within the same second the index was written. Callers pass the index file mtime sampled when
6476/// the index was read (before any rewrite). When `index_mtime` is `None`, returns `false`.
6477#[must_use]
6478pub fn entry_is_racy(ie: &IndexEntry, index_mtime: Option<(u32, u32)>) -> bool {
6479    entry_is_racy_with_policy(ie, index_mtime, crate::index::StatMatchPolicy::default())
6480}
6481
6482/// Like [`entry_is_racy`], honoring `core.usenanosec` for sub-second racy detection.
6483///
6484/// Sub-second racy detection applies only when stat equality also compares the fractions
6485/// (`core.usenanosec` with `core.checkstat=default`); otherwise a same-second, same-size edit
6486/// would be neither racy nor a stat mismatch.
6487#[must_use]
6488pub fn entry_is_racy_with_policy(
6489    ie: &IndexEntry,
6490    index_mtime: Option<(u32, u32)>,
6491    policy: crate::index::StatMatchPolicy,
6492) -> bool {
6493    let Some((index_mtime_sec, index_mtime_nsec)) = index_mtime else {
6494        return false;
6495    };
6496    if index_mtime_sec == 0 {
6497        return false;
6498    }
6499    if policy.use_nanosec && policy.check_stat {
6500        index_mtime_sec < ie.mtime_sec
6501            || (index_mtime_sec == ie.mtime_sec && index_mtime_nsec <= ie.mtime_nsec)
6502    } else {
6503        index_mtime_sec <= ie.mtime_sec
6504    }
6505}
6506
6507/// Clear racily-clean cache lines on entries whose blob no longer matches the worktree.
6508///
6509/// Before Git writes the index, it walks stage-0 file and symlink entries and sets the cached
6510/// size to zero when stat data still matches the worktree but content does not match the
6511/// recorded OID and the entry timestamp is racy relative to `index_mtime`. That forces the
6512/// next diff or status pass to re-hash the path instead of trusting stat alone.
6513///
6514/// Skip-worktree, assume-unchanged, and intent-to-add entries are left unchanged, and so are
6515/// entries this process just hashed from the worktree to their current OID (Git skips its
6516/// `CE_UPTODATE` entries the same way). When `index_mtime` is `None` (no prior on-disk index),
6517/// nothing is smudged.
6518///
6519/// Content equality uses `git_dir` config and worktree attributes with [`hash_worktree_file`]
6520/// so the repository hash algorithm and clean (CRLF/filter) conversion match index OIDs.
6521///
6522/// Returns `true` when at least one entry was smudged.
6523pub fn smudge_racily_clean_entries(
6524    odb: &Odb,
6525    git_dir: &Path,
6526    index: &mut Index,
6527    work_tree: &Path,
6528    index_mtime: Option<(u32, u32)>,
6529    config: Option<&ConfigSet>,
6530    filter_process: Option<&crate::filter_process::FilterProcessState>,
6531) -> bool {
6532    use crate::crlf;
6533    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
6534
6535    let Some(index_mtime) = index_mtime else {
6536        return false;
6537    };
6538
6539    let config = config.cloned().unwrap_or_else(|| {
6540        ConfigSet::load(
6541            &crate::environment::Environment::empty(),
6542            Some(git_dir),
6543            true,
6544        )
6545        .unwrap_or_default()
6546    });
6547    let conv = crlf::ConversionConfig::from_config(&config);
6548    let attrs = crlf::load_gitattributes(work_tree);
6549    let verified = std::mem::take(&mut index.content_verified);
6550
6551    let mut changed = false;
6552    for ie in &mut index.entries {
6553        let marked_verified = ie.take_content_verified();
6554        if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
6555            continue;
6556        }
6557        if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
6558            continue;
6559        }
6560        if ie.size == 0 {
6561            continue;
6562        }
6563        if !entry_is_racy(ie, Some(index_mtime)) {
6564            continue;
6565        }
6566        if marked_verified || verified.get(&ie.path) == Some(&ie.oid) {
6567            continue;
6568        }
6569        let Ok(path) = std::str::from_utf8(&ie.path) else {
6570            continue;
6571        };
6572        let abs = work_tree.join(path);
6573        let Ok(meta) = fs::symlink_metadata(&abs) else {
6574            continue;
6575        };
6576        if !stat_matches(ie, &meta) {
6577            continue;
6578        }
6579        let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
6580        let content_matches = hash_worktree_file(
6581            odb,
6582            &abs,
6583            &meta,
6584            &conv,
6585            &file_attrs,
6586            path,
6587            Some(ie),
6588            filter_process,
6589        )
6590        .map(|oid| oid == ie.oid)
6591        .unwrap_or(false);
6592        if content_matches {
6593            continue;
6594        }
6595        ie.size = 0;
6596        changed = true;
6597    }
6598    changed
6599}
6600/// Quick stat check: does the index entry's cached stat data match the file?
6601/// Returns true when the file at `ie`'s path differs from the index entry (mode or blob).
6602///
6603/// Used by commands such as `git mv` to detect "dirty" paths under sparse checkout.
6604/// Symlinks and submodules are compared in a Git-compatible way.
6605///
6606/// `ignore_submodule_untracked` mirrors [`diff_index_to_worktree`]'s same flag for gitlinks.
6607pub fn worktree_differs_from_index_entry(
6608    odb: &Odb,
6609    repository_git_dir: &Path,
6610    work_tree: &Path,
6611    ie: &IndexEntry,
6612    ignore_submodule_untracked: bool,
6613    config: Option<&ConfigSet>,
6614    filter_process: Option<&crate::filter_process::FilterProcessState>,
6615) -> Result<bool> {
6616    WorktreeEntryComparer::new(repository_git_dir, work_tree, config).differs(
6617        odb,
6618        ie,
6619        ignore_submodule_untracked,
6620        filter_process,
6621    )
6622}
6623
6624/// Repeated [`worktree_differs_from_index_entry`] checks against one worktree.
6625///
6626/// Loads config-derived settings and the root `.gitattributes` once instead of per entry.
6627pub(crate) struct WorktreeEntryComparer<'a> {
6628    work_tree: &'a Path,
6629    config: ConfigSet,
6630    mode_policy: crate::index::WorktreeModePolicy,
6631    precompose_unicode: bool,
6632    ignorecase: bool,
6633    conv: std::cell::OnceCell<crate::crlf::ConversionConfig>,
6634    attrs: std::cell::OnceCell<Vec<crate::crlf::AttrRule>>,
6635}
6636
6637impl<'a> WorktreeEntryComparer<'a> {
6638    /// Capture settings for `work_tree`; `config` falls back to the repository config files.
6639    pub(crate) fn new(
6640        repository_git_dir: &Path,
6641        work_tree: &'a Path,
6642        config: Option<&ConfigSet>,
6643    ) -> Self {
6644        let config = config.cloned().unwrap_or_else(|| {
6645            ConfigSet::load(
6646                &crate::environment::Environment::empty(),
6647                Some(repository_git_dir),
6648                true,
6649            )
6650            .unwrap_or_else(|_| ConfigSet::new())
6651        });
6652        let flag =
6653            |key: &str, default: bool| config.get_bool(key).and_then(|r| r.ok()).unwrap_or(default);
6654        Self {
6655            work_tree,
6656            mode_policy: crate::index::WorktreeModePolicy::from_config(&config),
6657            precompose_unicode: flag("core.precomposeunicode", false),
6658            ignorecase: flag("core.ignorecase", false),
6659            config,
6660            conv: std::cell::OnceCell::new(),
6661            attrs: std::cell::OnceCell::new(),
6662        }
6663    }
6664
6665    /// Same contract as [`worktree_differs_from_index_entry`].
6666    pub(crate) fn differs(
6667        &self,
6668        odb: &Odb,
6669        ie: &IndexEntry,
6670        ignore_submodule_untracked: bool,
6671        filter_process: Option<&crate::filter_process::FilterProcessState>,
6672    ) -> Result<bool> {
6673        use crate::crlf;
6674
6675        let work_tree = self.work_tree;
6676        let path_stored = crate::repo_relpath::storage_string(&ie.path);
6677        let path_str_ref = crate::repo_relpath::as_utf8_for_matching(&path_stored);
6678        let file_path = index_entry_worktree_abs_bytes(
6679            work_tree,
6680            &ie.path,
6681            self.precompose_unicode,
6682            self.ignorecase,
6683        );
6684
6685        if ie.mode == 0o160000 {
6686            let sub_head_oid = read_submodule_head(&file_path);
6687            let ref_matches = match sub_head_oid {
6688                Some(oid) => oid == ie.oid,
6689                None => uninitialized_gitlink_worktree_matches_index(&file_path),
6690            };
6691            let mut flags = submodule_porcelain_flags(work_tree, path_str_ref.as_ref(), ie.oid);
6692            if ignore_submodule_untracked {
6693                flags.untracked = false;
6694            }
6695            return Ok(!ref_matches || flags.modified || flags.untracked);
6696        }
6697
6698        let meta = match fs::symlink_metadata(&file_path) {
6699            Ok(m) => m,
6700            Err(e)
6701                if e.kind() == std::io::ErrorKind::NotFound
6702                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
6703            {
6704                return Ok(true);
6705            }
6706            Err(e) => return Err(Error::Io(e)),
6707        };
6708
6709        if meta.is_dir() {
6710            return Ok(true);
6711        }
6712
6713        if !self
6714            .mode_policy
6715            .worktree_mode_matches(mode_from_metadata(&meta), ie.mode)
6716        {
6717            return Ok(true);
6718        }
6719
6720        let conv = self
6721            .conv
6722            .get_or_init(|| crlf::ConversionConfig::from_config(&self.config));
6723        let attrs = self
6724            .attrs
6725            .get_or_init(|| crlf::load_gitattributes(work_tree));
6726        let file_attrs = crlf::get_file_attrs(attrs, path_str_ref.as_ref(), false, &self.config);
6727        let worktree_oid = hash_worktree_file(
6728            odb,
6729            &file_path,
6730            &meta,
6731            conv,
6732            &file_attrs,
6733            path_str_ref.as_ref(),
6734            Some(ie),
6735            filter_process,
6736        )?;
6737
6738        let mut eff_oid = worktree_oid;
6739        if eff_oid != ie.oid {
6740            if let Ok(raw) = fs::read(&file_path) {
6741                let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
6742                if raw_oid == ie.oid {
6743                    eff_oid = ie.oid;
6744                }
6745            }
6746        }
6747
6748        Ok(eff_oid != ie.oid)
6749    }
6750}
6751
6752/// Inputs for [`classify_worktree_entry_for_add`].
6753pub struct WorktreeAddRefreshParams<'a> {
6754    /// Object database used to verify blob OIDs when stat data is inconclusive.
6755    pub odb: &'a Odb,
6756    /// Index entry being compared.
6757    pub ie: &'a IndexEntry,
6758    /// `lstat` metadata for the worktree path.
6759    pub meta: &'a fs::Metadata,
6760    /// Absolute worktree path to the file.
6761    pub abs_path: &'a Path,
6762    /// Repository-relative path string.
6763    pub rel_path: &'a str,
6764    /// CRLF / filter conversion settings.
6765    pub conv: &'a crate::crlf::ConversionConfig,
6766    /// Per-path attribute state.
6767    pub file_attrs: &'a crate::crlf::FileAttrs,
6768    /// On-disk index mtime when the index was loaded; `None` skips racy checks.
6769    pub index_mtime: Option<(u32, u32)>,
6770    /// Mode that would be written, after [`crate::index::WorktreeModePolicy`] against `ie.mode`
6771    /// and any `--chmod` hint.
6772    pub staged_mode: u32,
6773    /// Repository-scoped filter-process registry (when staging through a [`crate::repo::Repository`]).
6774    pub filter_process: Option<&'a crate::filter_process::FilterProcessState>,
6775}
6776
6777/// Outcome of comparing a tracked index entry to its worktree path for staging refresh.
6778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6779pub enum WorktreeAddRefresh {
6780    /// Index already reflects the worktree; caller may skip staging.
6781    UpToDate,
6782    /// Executable bit differs while blob content is unchanged.
6783    ModeOnly {
6784        /// Mode to store in the index.
6785        mode: u32,
6786    },
6787    /// Blob OID and mode are unchanged; only cached stat fields should be refreshed.
6788    StatOnly,
6789    /// Content, type, or mode/blob mismatch requires a full restage (read, convert, hash).
6790    NeedsRestage,
6791}
6792
6793/// Whether stat-only fast paths may skip re-hashing during staging.
6794#[must_use]
6795pub(crate) fn index_stat_cache_trustworthy(index_mtime: Option<(u32, u32)>) -> bool {
6796    match index_mtime {
6797        None => false,
6798        Some((0, _)) => false,
6799        Some(_) => true,
6800    }
6801}
6802
6803/// When true, after worktree metadata has been read and matches the index entry, blob content
6804/// hashing may be skipped (Git `diff-files` / `update-index --refresh` style).
6805///
6806/// Does not permit skipping worktree observation; fsmonitor-valid entries use a separate path.
6807/// Independent of `core.filemode`: callers compare the executable bit separately.
6808///
6809/// A zero cached size is trusted only for the empty blob; any other blob with size zero was
6810/// smudged by a racy index write and must be re-hashed (Git `ce_match_stat_basic`).
6811#[must_use]
6812pub(crate) fn index_entry_skips_worktree_content_hash_after_stat_match(
6813    ie: &IndexEntry,
6814    index_mtime: Option<(u32, u32)>,
6815    stat_policy: crate::index::StatMatchPolicy,
6816) -> bool {
6817    if ie.stat_cache_unset() || (ie.size == 0 && !ie.oid.is_empty_blob()) {
6818        return false;
6819    }
6820    if !index_stat_cache_trustworthy(index_mtime) {
6821        return false;
6822    }
6823    !entry_is_racy_with_policy(ie, index_mtime, stat_policy)
6824}
6825
6826/// Decide whether staging must re-read and re-hash a tracked path.
6827///
6828/// Mirrors the fast paths in [`diff_index_to_worktree`] and Git's `refresh_index` /
6829/// `add_file_to_index`: trust matching stat when the entry is not racy and
6830/// `index_stat_cache_trustworthy` allows it; re-hash when stat matches but the entry is racy
6831/// or when stat differs; apply CRLF-aware hashing like staging.
6832///
6833/// Returns [`WorktreeAddRefresh::UpToDate`] when the index entry already matches the worktree and
6834/// no restaging is required; other variants describe mode/stat-only updates or a full restage.
6835///
6836/// # Errors
6837///
6838/// Returns [`Error::Io`] when the worktree file cannot be read for content verification.
6839pub fn classify_worktree_entry_for_add(
6840    params: &WorktreeAddRefreshParams<'_>,
6841) -> Result<WorktreeAddRefresh> {
6842    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
6843
6844    let ie = params.ie;
6845    let meta = params.meta;
6846    let staged_mode = params.staged_mode;
6847
6848    if ie.intent_to_add() {
6849        return Ok(WorktreeAddRefresh::NeedsRestage);
6850    }
6851
6852    let racy = entry_is_racy(ie, params.index_mtime);
6853    let stat_same = stat_matches(ie, meta);
6854    let trust_stat = index_stat_cache_trustworthy(params.index_mtime);
6855
6856    if ie.mode == MODE_SYMLINK {
6857        // With `core.symlinks=false` the policy keeps a plain file holding the link target as
6858        // a symlink entry, so it is compared by content like a real link.
6859        if !meta.file_type().is_symlink() && staged_mode != MODE_SYMLINK {
6860            return Ok(WorktreeAddRefresh::NeedsRestage);
6861        }
6862        if stat_same && !racy && trust_stat && staged_mode == ie.mode {
6863            return Ok(WorktreeAddRefresh::UpToDate);
6864        }
6865        if stat_same && !racy && trust_stat && staged_mode != ie.mode {
6866            return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
6867        }
6868        return worktree_effective_oid_matches_index(params);
6869    }
6870
6871    if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE {
6872        return Ok(WorktreeAddRefresh::NeedsRestage);
6873    }
6874
6875    if meta.file_type().is_symlink() || meta.is_dir() {
6876        return Ok(WorktreeAddRefresh::NeedsRestage);
6877    }
6878
6879    if stat_same && !racy && trust_stat {
6880        if staged_mode == ie.mode {
6881            return Ok(WorktreeAddRefresh::UpToDate);
6882        }
6883        return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
6884    }
6885
6886    worktree_effective_oid_matches_index(params)
6887}
6888
6889fn worktree_effective_oid_matches_index(
6890    params: &WorktreeAddRefreshParams<'_>,
6891) -> Result<WorktreeAddRefresh> {
6892    let WorktreeAddRefreshParams {
6893        odb,
6894        ie,
6895        meta,
6896        abs_path,
6897        rel_path,
6898        conv,
6899        file_attrs,
6900        staged_mode,
6901        ..
6902    } = params;
6903    let worktree_oid = hash_worktree_file(
6904        odb,
6905        abs_path,
6906        meta,
6907        conv,
6908        file_attrs,
6909        rel_path,
6910        Some(ie),
6911        params.filter_process,
6912    )?;
6913    let mut eff_oid = worktree_oid;
6914    if eff_oid != ie.oid {
6915        if let Ok(raw) = fs::read(abs_path) {
6916            let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
6917            if raw_oid == ie.oid {
6918                eff_oid = ie.oid;
6919            }
6920        }
6921    }
6922    if eff_oid != ie.oid {
6923        return Ok(WorktreeAddRefresh::NeedsRestage);
6924    }
6925    if *staged_mode != ie.mode {
6926        return Ok(WorktreeAddRefresh::ModeOnly { mode: *staged_mode });
6927    }
6928    if stat_matches(ie, meta) {
6929        Ok(WorktreeAddRefresh::UpToDate)
6930    } else {
6931        Ok(WorktreeAddRefresh::StatOnly)
6932    }
6933}
6934
6935/// Whether worktree metadata matches cached index stat data (Git `ie_match_stat` / `match_stat_data`).
6936#[must_use]
6937pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
6938    stat_matches_with_policy(ie, meta, crate::index::StatMatchPolicy::default())
6939}
6940
6941/// Whether compact worktree stat from a directory scan matches the index row.
6942pub(crate) fn stat_matches_blob_stat(
6943    ie: &IndexEntry,
6944    stat: &crate::worktree_scan::BlobDiskStat,
6945    policy: crate::index::StatMatchPolicy,
6946) -> bool {
6947    stat.fields.matches_index_entry(ie, policy)
6948}
6949
6950/// Like [`stat_matches`], honoring `core.trustctime`, `core.checkstat`, and `core.usenanosec`.
6951#[must_use]
6952pub fn stat_matches_with_policy(
6953    ie: &IndexEntry,
6954    meta: &fs::Metadata,
6955    policy: crate::index::StatMatchPolicy,
6956) -> bool {
6957    if crate::index::munge_index_size(meta.len()) != ie.size {
6958        return false;
6959    }
6960    #[cfg(unix)]
6961    {
6962        use std::os::unix::fs::MetadataExt;
6963        if meta.mtime() as u32 != ie.mtime_sec {
6964            return false;
6965        }
6966        if policy.use_nanosec && policy.check_stat && meta.mtime_nsec() as u32 != ie.mtime_nsec {
6967            return false;
6968        }
6969        if policy.trust_ctime && policy.check_stat {
6970            if meta.ctime() as u32 != ie.ctime_sec {
6971                return false;
6972            }
6973            if policy.use_nanosec && meta.ctime_nsec() as u32 != ie.ctime_nsec {
6974                return false;
6975            }
6976        }
6977        if policy.check_stat && meta.ino() as u32 != ie.ino {
6978            return false;
6979        }
6980    }
6981    #[cfg(not(unix))]
6982    {
6983        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = crate::index::metadata_times(meta);
6984        if mtime_sec != ie.mtime_sec {
6985            return false;
6986        }
6987        if policy.use_nanosec && policy.check_stat && mtime_nsec != ie.mtime_nsec {
6988            return false;
6989        }
6990        if policy.trust_ctime && policy.check_stat {
6991            if ctime_sec != ie.ctime_sec {
6992                return false;
6993            }
6994            if policy.use_nanosec && ctime_nsec != ie.ctime_nsec {
6995                return false;
6996            }
6997        }
6998    }
6999    true
7000}
7001
7002/// Refresh cached stat data for stage-0 file/symlink entries whose worktree content still matches
7003/// the recorded OID but whose on-disk stat went stale.
7004///
7005/// This mirrors Git's `refresh_index` / `refresh_cache_ent`: an entry is only marked clean (stat
7006/// adopted from the worktree) after its content is re-verified against the index OID. A genuinely
7007/// modified entry keeps its stale stat so `diff-files` / `status` continue to report it. Operations
7008/// that rewrite the worktree (`status`, `reset --mixed`, `stash`) call this before writing the
7009/// index so a subsequent `git diff-files` sees refreshed entries as clean.
7010///
7011/// Gitlinks, sparse (`skip_worktree`), `assume_unchanged` and intent-to-add entries are skipped.
7012/// The blob comparison is a raw-content hash, so a CRLF-smudged match is conservatively missed
7013/// (the entry simply stays stat-dirty and is re-hashed next time — never the reverse).
7014///
7015/// `index_mtime` is the on-disk index file's `(mtime_sec, mtime_nsec)` (see
7016/// `entry_is_racy` / Git `is_racy_timestamp`); pass `None` when unknown — racy detection is
7017/// then skipped, which is conservative for tree-built indexes whose zeroed stat never matches.
7018///
7019/// `stat_parallel_threads` overrides parallel stat workers (`Some(1)` forces serial); when `None`,
7020/// honors `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
7021///
7022/// Returns `Ok(true)` when at least one entry was refreshed or invalidated, so callers can write
7023/// the index opportunistically (Git only persists a refresh that changed something).
7024pub fn refresh_index_stat_content_verified(
7025    odb: &Odb,
7026    git_dir: &Path,
7027    index: &mut Index,
7028    work_tree: &Path,
7029    index_mtime: Option<(u32, u32)>,
7030    config: Option<&ConfigSet>,
7031    _stat_parallel_threads: Option<usize>,
7032) -> Result<bool> {
7033    let _ = _stat_parallel_threads;
7034    refresh_index_stat_content_verified_with_rules(
7035        odb,
7036        git_dir,
7037        index,
7038        work_tree,
7039        index_mtime,
7040        config,
7041        _stat_parallel_threads,
7042        None,
7043    )
7044}
7045
7046/// Like [`refresh_index_stat_content_verified`], resolving attributes and conversion through a
7047/// shared [`crate::worktree_rules::WorktreeRules`] when one is supplied (its config snapshot then
7048/// takes precedence over `config`).
7049///
7050/// # Errors
7051///
7052/// Propagates I/O errors from the directory-grouped worktree scan.
7053#[allow(clippy::too_many_arguments)]
7054pub fn refresh_index_stat_content_verified_with_rules(
7055    odb: &Odb,
7056    git_dir: &Path,
7057    index: &mut Index,
7058    work_tree: &Path,
7059    index_mtime: Option<(u32, u32)>,
7060    config: Option<&ConfigSet>,
7061    stat_parallel_threads: Option<usize>,
7062    worktree_rules: Option<&std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
7063) -> Result<bool> {
7064    use crate::config::ConfigSet;
7065    use crate::crlf;
7066    use crate::worktree_scan::{
7067        collect_blob_lookup_batches, group_blob_entries_by_dir, visit_blob_lookup_batches,
7068        BlobDiskLookup, BlobScanSparseMode, WorktreeBlobScanOptions,
7069    };
7070
7071    let index_mtime = index_mtime_for_diff(index, index_mtime);
7072    let rules_lock = worktree_rules
7073        .map(|shared| {
7074            shared
7075                .lock()
7076                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
7077        })
7078        .transpose()?;
7079    let config = rules_lock
7080        .as_ref()
7081        .map(|r| r.config().clone())
7082        .unwrap_or_else(|| {
7083            config.cloned().unwrap_or_else(|| {
7084                ConfigSet::load(
7085                    &crate::environment::Environment::empty(),
7086                    Some(git_dir),
7087                    true,
7088                )
7089                .unwrap_or_default()
7090            })
7091        });
7092    let preload_index = config
7093        .get_bool("core.preloadIndex")
7094        .and_then(|r| r.ok())
7095        .unwrap_or(true);
7096    let blob_scan = WorktreeBlobScanOptions {
7097        preload_index,
7098        stat_parallel_threads,
7099        ..WorktreeBlobScanOptions::default()
7100    };
7101    let conv = rules_lock
7102        .as_ref()
7103        .map(|r| r.conversion().clone())
7104        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
7105    let legacy_attrs = if rules_lock.is_none() {
7106        Some(crlf::load_gitattributes_parsed(work_tree))
7107    } else {
7108        None
7109    };
7110    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
7111        if let Some(rules) = rules_lock.as_ref() {
7112            rules.file_attrs(path, false)
7113        } else if let Some(parsed) = legacy_attrs.as_ref() {
7114            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
7115        } else {
7116            crlf::FileAttrs::default()
7117        }
7118    };
7119    let precompose_unicode = config
7120        .get_bool("core.precomposeunicode")
7121        .and_then(|r| r.ok())
7122        .unwrap_or(false);
7123    let ignorecase = config
7124        .get_bool("core.ignorecase")
7125        .and_then(|r| r.ok())
7126        .unwrap_or(false);
7127    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
7128
7129    let blob_dirs = group_blob_entries_by_dir(index, false);
7130    let work_tree_owned = work_tree.to_path_buf();
7131    let dir_abs = move |dir: &[u8]| {
7132        if dir.is_empty() {
7133            work_tree_owned.clone()
7134        } else {
7135            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
7136        }
7137    };
7138    let work_tree_for_files = work_tree.to_path_buf();
7139    let file_abs = move |rel: &[u8]| {
7140        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
7141    };
7142    let mut changed = false;
7143    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
7144    let thread_policy = crate::parallel::ThreadPolicy::default();
7145    // Symlink ancestor detection must run even when core.symlinks is false.
7146    let refresh_batches = collect_blob_lookup_batches(
7147        index,
7148        &blob_dirs,
7149        blob_scan,
7150        &thread_policy,
7151        &config,
7152        dir_abs,
7153        file_abs,
7154        || {
7155            let mut dir_symlinks = SymlinkDirCache::default();
7156            move |rel_path: &[u8]| {
7157                dir_symlinks.has_symlink_in_path_bytes(
7158                    work_tree,
7159                    rel_path,
7160                    precompose_unicode,
7161                    ignorecase,
7162                )
7163            }
7164        },
7165        BlobScanSparseMode::StatRefresh {
7166            stat_policy,
7167            index_mtime,
7168        },
7169    )?;
7170    let par_opts = refresh_hash_parallel_options(&thread_policy, &config, stat_parallel_threads);
7171    let refresh_checks = refresh_content_checks(
7172        odb,
7173        index,
7174        &refresh_batches,
7175        index_mtime,
7176        stat_policy,
7177        &|_, _, _| true,
7178        &WorktreeHashContext {
7179            work_tree,
7180            precompose_unicode,
7181            ignorecase,
7182            conv: &conv,
7183            file_attrs_for: &file_attrs_for,
7184            filter_process: filter_fp,
7185        },
7186        par_opts,
7187        None,
7188    )?;
7189    visit_blob_lookup_batches(refresh_batches, |entry_index, lookup| {
7190        let BlobDiskLookup::Present(stat) = lookup else {
7191            return Ok(());
7192        };
7193        let content_matches = RefreshContent::matches(
7194            refresh_checks.get(entry_index),
7195            &index.entries[entry_index].oid,
7196        );
7197        let refresh = refresh_index_blob_stat_from_scan(
7198            &mut index.entries[entry_index],
7199            &stat,
7200            index_mtime,
7201            stat_policy,
7202            content_matches,
7203        );
7204        if refresh.index_changed {
7205            changed = true;
7206        }
7207        if refresh.content_verified {
7208            let path = index.entries[entry_index].path.clone();
7209            let oid = index.entries[entry_index].oid;
7210            index.mark_content_verified(&path, oid);
7211        }
7212        Ok(())
7213    })?;
7214    Ok(changed)
7215}
7216
7217/// Result of refreshing one index entry's cached stat during a worktree scan.
7218struct BlobStatRefreshOutcome {
7219    /// Whether the in-memory index entry was updated (stat fields or invalidation).
7220    index_changed: bool,
7221    /// Blob content was read and matches the recorded OID (skip a second hash in the same pass).
7222    content_verified: bool,
7223}
7224
7225/// Whether the stat refresh may adopt worktree stat data for `ie` at all.
7226fn refresh_index_entry_eligible(ie: &IndexEntry) -> bool {
7227    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
7228    ie.stage() == 0
7229        && !ie.skip_worktree()
7230        && !ie.assume_unchanged()
7231        && !ie.intent_to_add()
7232        && matches!(ie.mode, MODE_REGULAR | MODE_EXECUTABLE | MODE_SYMLINK)
7233}
7234
7235/// Whether refreshing `ie` from the scanned `stat` must first prove by content that the
7236/// worktree still holds `ie.oid`: the stat data changed, or it matches but is racy.
7237fn refresh_needs_content_check(
7238    ie: &IndexEntry,
7239    stat: &crate::worktree_scan::BlobDiskStat,
7240    index_mtime: Option<(u32, u32)>,
7241    stat_policy: crate::index::StatMatchPolicy,
7242) -> bool {
7243    refresh_index_entry_eligible(ie)
7244        && !stat.is_dir
7245        && (!stat_matches_blob_stat(ie, stat, stat_policy)
7246            || entry_is_racy_with_policy(ie, index_mtime, stat_policy))
7247}
7248
7249/// Whether a worktree diff with `refresh_index_stat_in_pass` refreshes `ie` from `stat`.
7250///
7251/// Status leaves out entries whose stat data already decides the answer: clean and not racy,
7252/// or a changed size that [`status_size_change_proves_modified`] accepts (reported as modified
7253/// without reading the file). A size change under a non-identity clean conversion is refreshed:
7254/// when the cleaned content still matches, the entry adopts the new stat, so later scans trust it.
7255pub(crate) fn worktree_diff_refreshes_entry(
7256    ie: &IndexEntry,
7257    stat: &crate::worktree_scan::BlobDiskStat,
7258    index_mtime: Option<(u32, u32)>,
7259    stat_policy: crate::index::StatMatchPolicy,
7260    for_status: bool,
7261    conversion_is_identity: impl FnOnce() -> bool,
7262) -> bool {
7263    if !for_status {
7264        return true;
7265    }
7266    if stat_matches_blob_stat(ie, stat, stat_policy) {
7267        return entry_is_racy_with_policy(ie, index_mtime, stat_policy);
7268    }
7269    !status_size_change_proves_modified(
7270        ie,
7271        stat.worktree_size(),
7272        for_status,
7273        conversion_is_identity,
7274    )
7275}
7276
7277/// Pool options for the stat refresh's content checks.
7278///
7279/// `thread_policy` and config pick the worker count; `stat_parallel_threads` caps it the same
7280/// way it caps the stat scan (`Some(0)` or `Some(1)` keeps hashing on the calling thread).
7281/// There is no byte threshold: opening each file, not hashing its bytes, dominates for the
7282/// small files a checkout leaves racy.
7283fn refresh_hash_parallel_options(
7284    thread_policy: &crate::parallel::ThreadPolicy,
7285    config: &ConfigSet,
7286    stat_parallel_threads: Option<usize>,
7287) -> crate::parallel::ParallelOptions {
7288    let policy = crate::parallel::ThreadPolicy {
7289        min_items_override: thread_policy.min_items_override.or(Some(0)),
7290        min_total_bytes_override: thread_policy.min_total_bytes_override.or(Some(0)),
7291        ..*thread_policy
7292    };
7293    let mut par_opts = policy.resolve(crate::parallel::ParallelOp::IndexRefresh, config);
7294    if let Some(cap) = stat_parallel_threads {
7295        par_opts.threads = par_opts
7296            .threads
7297            .min(std::num::NonZeroUsize::new(cap).unwrap_or(std::num::NonZeroUsize::MIN));
7298    }
7299    par_opts.min_items = 0;
7300    par_opts.min_total_bytes = None;
7301    par_opts
7302}
7303
7304/// Worktree layout and attribute context for hashing index entries during a scan.
7305struct WorktreeHashContext<'a> {
7306    work_tree: &'a Path,
7307    precompose_unicode: bool,
7308    ignorecase: bool,
7309    conv: &'a crate::crlf::ConversionConfig,
7310    file_attrs_for: &'a dyn Fn(&str) -> crate::crlf::FileAttrs,
7311    filter_process: Option<&'a crate::filter_process::FilterProcessState>,
7312}
7313
7314/// Run every content check the stat refresh needs before the serial visit of `batches`.
7315///
7316/// Racily clean entries are the common case right after a checkout writes the index: every
7317/// file then shares the index's timestamp second. Hashing them here, in parallel when
7318/// `par_opts` and the total size allow it, keeps that first scan from reading files one by one.
7319///
7320/// Returns a check for each scanned entry that `include` (given the entry index) admits and
7321/// [`refresh_needs_content_check`] selects. Read and filter failures are
7322/// [`RefreshContent::Failed`], so those entries stay unverified.
7323#[allow(clippy::too_many_arguments)]
7324fn refresh_content_checks(
7325    odb: &Odb,
7326    index: &Index,
7327    batches: &[Vec<(usize, crate::worktree_scan::BlobDiskLookup)>],
7328    index_mtime: Option<(u32, u32)>,
7329    stat_policy: crate::index::StatMatchPolicy,
7330    include: &dyn Fn(usize, &IndexEntry, &crate::worktree_scan::BlobDiskStat) -> bool,
7331    ctx: &WorktreeHashContext<'_>,
7332    par_opts: crate::parallel::ParallelOptions,
7333    cancel_watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
7334) -> Result<RefreshChecks> {
7335    use crate::progress::{poll_watch, CANCEL_POLL_STRIDE};
7336    use crate::worktree_scan::BlobDiskLookup;
7337
7338    /// Attributes are resolved on the calling thread: the shared rules are not `Sync`.
7339    struct ContentCheck {
7340        entry_index: usize,
7341        worktree_mode: u32,
7342        file_attrs: crate::crlf::FileAttrs,
7343    }
7344
7345    let mut jobs = Vec::new();
7346    let mut total_bytes = 0usize;
7347    let mut cancel_stride = 0usize;
7348    for (entry_index, lookup) in batches.iter().flatten() {
7349        cancel_stride = cancel_stride.wrapping_add(1);
7350        if cancel_stride.is_multiple_of(CANCEL_POLL_STRIDE) {
7351            if let Some(w) = cancel_watch {
7352                poll_watch(w)?;
7353            }
7354        }
7355        let BlobDiskLookup::Present(stat) = lookup else {
7356            continue;
7357        };
7358        let ie = &index.entries[*entry_index];
7359        if !refresh_needs_content_check(ie, stat, index_mtime, stat_policy)
7360            || !include(*entry_index, ie, stat)
7361        {
7362            continue;
7363        }
7364        total_bytes = total_bytes.saturating_add(ie.size as usize);
7365        jobs.push(ContentCheck {
7366            entry_index: *entry_index,
7367            worktree_mode: stat.git_mode,
7368            file_attrs: (ctx.file_attrs_for)(&crate::repo_relpath::storage_string(&ie.path)),
7369        });
7370    }
7371    if jobs.is_empty() {
7372        return Ok(RefreshChecks::default());
7373    }
7374    let mut checks = vec![None; index.entries.len()];
7375    let (work_tree, precompose_unicode, ignorecase) =
7376        (ctx.work_tree, ctx.precompose_unicode, ctx.ignorecase);
7377    let (conv, filter_process) = (ctx.conv, ctx.filter_process);
7378    let entries = index.entries.as_slice();
7379    let watch_for_hash = cancel_watch.cloned();
7380    let hash_one = |job: &ContentCheck| -> Result<RefreshContent> {
7381        if job.entry_index.is_multiple_of(CANCEL_POLL_STRIDE) {
7382            if let Some(w) = watch_for_hash.as_ref() {
7383                poll_watch(w)?;
7384            }
7385        }
7386        let ie = &entries[job.entry_index];
7387        let file_path =
7388            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
7389        Ok(RefreshContent::from(hash_worktree_file_with_mode(
7390            odb,
7391            &file_path,
7392            job.worktree_mode,
7393            conv,
7394            &job.file_attrs,
7395            &crate::repo_relpath::storage_string(&ie.path),
7396            Some(ie),
7397            filter_process,
7398        )))
7399    };
7400    let matches = if par_opts.threads.get() > 1 && jobs.len() > 1 {
7401        crate::parallel::try_par_map_force(&jobs, par_opts, hash_one).map_err(|e| match e {
7402            crate::parallel::ParallelHashError::Task(err) => err,
7403        })?
7404    } else {
7405        let mut out = Vec::with_capacity(jobs.len());
7406        let mut serial_stride = 0usize;
7407        for job in &jobs {
7408            serial_stride = serial_stride.wrapping_add(1);
7409            if serial_stride.is_multiple_of(CANCEL_POLL_STRIDE) {
7410                if let Some(w) = cancel_watch {
7411                    poll_watch(w)?;
7412                }
7413            }
7414            out.push(hash_one(job)?);
7415        }
7416        out
7417    };
7418    for (job, content) in jobs.iter().zip(matches) {
7419        checks[job.entry_index] = Some(content);
7420    }
7421    Ok(RefreshChecks(checks))
7422}
7423
7424/// Results of [`refresh_content_checks`], by index entry position.
7425///
7426/// Empty when no entry needed a check (a clean, non-racy work tree), so the common case
7427/// allocates nothing.
7428#[derive(Default)]
7429struct RefreshChecks(Vec<Option<RefreshContent>>);
7430
7431impl RefreshChecks {
7432    /// The check that ran for the entry at `entry_index`, if any.
7433    fn get(&self, entry_index: usize) -> Option<RefreshContent> {
7434        self.0.get(entry_index).copied().flatten()
7435    }
7436}
7437
7438/// What the stat refresh learned by hashing one entry's worktree file.
7439#[derive(Clone, Copy)]
7440enum RefreshContent {
7441    /// The file could not be read or clean-converted.
7442    Failed,
7443    /// The cleaned file hashes to this blob id.
7444    Hashed(ObjectId),
7445}
7446
7447impl From<Result<ObjectId>> for RefreshContent {
7448    fn from(hashed: Result<ObjectId>) -> Self {
7449        hashed.map_or(Self::Failed, Self::Hashed)
7450    }
7451}
7452
7453impl RefreshContent {
7454    /// Whether `check` proved the worktree still holds `oid`.
7455    fn matches(check: Option<Self>, oid: &ObjectId) -> bool {
7456        matches!(check, Some(Self::Hashed(hashed)) if hashed == *oid)
7457    }
7458
7459    /// Whether `check` proved the worktree holds other content than `oid`.
7460    fn differs(check: Option<Self>, oid: &ObjectId) -> bool {
7461        matches!(check, Some(Self::Hashed(hashed)) if hashed != *oid)
7462    }
7463}
7464
7465/// Adopt the scanned `stat` for `ie` where that cannot hide a worktree change.
7466///
7467/// `content_matches` is the [`refresh_content_checks`] result for this entry; it is only
7468/// consulted when [`refresh_needs_content_check`] holds. A racy entry whose content no longer
7469/// matches has its stat cache invalidated so later scans re-hash it.
7470fn refresh_index_blob_stat_from_scan(
7471    ie: &mut IndexEntry,
7472    stat: &crate::worktree_scan::BlobDiskStat,
7473    index_mtime: Option<(u32, u32)>,
7474    stat_policy: crate::index::StatMatchPolicy,
7475    content_matches: bool,
7476) -> BlobStatRefreshOutcome {
7477    const UNCHANGED: BlobStatRefreshOutcome = BlobStatRefreshOutcome {
7478        index_changed: false,
7479        content_verified: false,
7480    };
7481    if !refresh_index_entry_eligible(ie) || stat.is_dir {
7482        return UNCHANGED;
7483    }
7484    let cached = crate::index::IndexStatFields::of_entry(ie);
7485    let stat_same = cached.equivalent_to(&stat.fields, stat_policy);
7486    let stat_matches = stat_matches_blob_stat(ie, stat, stat_policy);
7487    if !stat_matches || entry_is_racy_with_policy(ie, index_mtime, stat_policy) {
7488        if !content_matches {
7489            if stat_matches {
7490                invalidate_index_stat_cache(ie);
7491                return BlobStatRefreshOutcome {
7492                    index_changed: true,
7493                    content_verified: false,
7494                };
7495            }
7496            return UNCHANGED;
7497        }
7498        if !stat_same {
7499            stat.fields.store_in(ie);
7500        }
7501        return BlobStatRefreshOutcome {
7502            index_changed: !stat_same,
7503            content_verified: true,
7504        };
7505    }
7506    if stat_same {
7507        return BlobStatRefreshOutcome {
7508            index_changed: false,
7509            content_verified: true,
7510        };
7511    }
7512    stat.fields.store_in(ie);
7513    BlobStatRefreshOutcome {
7514        index_changed: true,
7515        content_verified: false,
7516    }
7517}
7518
7519/// Whether path-only checkout can skip writing a blob because the worktree is up to date.
7520///
7521/// Matching stat data is trusted unless the entry is racy relative to the on-disk index mtime;
7522/// then the worktree content (after clean/filter conversion) must hash to `expected_oid`
7523/// ([`hash_worktree_file`]). `config` is an optional preloaded configuration.
7524///
7525/// Returns `Ok(true)` when the caller should skip rewriting the path.
7526///
7527/// # Errors
7528///
7529/// Propagates I/O or hashing failures from [`hash_worktree_file`].
7530#[allow(clippy::too_many_arguments)]
7531pub(crate) fn path_checkout_skip_blob_write_when_up_to_date_with_config(
7532    odb: &Odb,
7533    git_dir: &Path,
7534    work_tree: &Path,
7535    index: &Index,
7536    entry: &IndexEntry,
7537    expected_oid: &ObjectId,
7538    expected_mode: u32,
7539    rel_path: &str,
7540    filter_process: Option<&crate::filter_process::FilterProcessState>,
7541    preloaded_config: Option<&crate::config::ConfigSet>,
7542) -> Result<bool> {
7543    use crate::config::ConfigSet;
7544    use crate::crlf;
7545    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR};
7546
7547    if entry.oid != *expected_oid || entry.mode != expected_mode {
7548        return Ok(false);
7549    }
7550    if expected_mode != MODE_REGULAR && expected_mode != MODE_EXECUTABLE {
7551        return Ok(false);
7552    }
7553    let abs_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
7554    let meta = match fs::symlink_metadata(&abs_path) {
7555        Ok(m) => m,
7556        Err(_) => return Ok(false),
7557    };
7558    if !meta.is_file() || !stat_matches(entry, &meta) {
7559        return Ok(false);
7560    }
7561    let owned_config;
7562    let config = match preloaded_config {
7563        Some(cfg) => cfg,
7564        None => {
7565            owned_config = ConfigSet::load(
7566                &crate::environment::Environment::empty(),
7567                Some(git_dir),
7568                true,
7569            )
7570            .unwrap_or_default();
7571            &owned_config
7572        }
7573    };
7574    if !crate::index::WorktreeModePolicy::from_config(config)
7575        .worktree_mode_matches(mode_from_metadata(&meta), expected_mode)
7576    {
7577        return Ok(false);
7578    }
7579    let index_mtime = index_mtime_for_diff(index, None);
7580    if !entry_is_racy(entry, index_mtime) {
7581        return Ok(true);
7582    }
7583    let conv = crlf::ConversionConfig::from_config(config);
7584    let attrs = crlf::load_gitattributes(work_tree);
7585    let file_attrs = crlf::get_file_attrs(&attrs, rel_path, false, config);
7586    let wt_oid = hash_worktree_file(
7587        odb,
7588        &abs_path,
7589        &meta,
7590        &conv,
7591        &file_attrs,
7592        rel_path,
7593        Some(entry),
7594        filter_process,
7595    )?;
7596    Ok(wt_oid == *expected_oid)
7597}
7598
7599/// Clear cached stat fields so the next diff/status pass re-reads the work tree.
7600fn invalidate_index_stat_cache(ie: &mut IndexEntry) {
7601    ie.ctime_sec = 0;
7602    ie.ctime_nsec = 0;
7603    ie.mtime_sec = 0;
7604    ie.mtime_nsec = 0;
7605    ie.dev = 0;
7606    ie.ino = 0;
7607    ie.size = 0;
7608}
7609
7610/// Read worktree content for diffing, honoring Git tree modes.
7611///
7612/// For [`MODE_SYMLINK`] entries backed by a symlink on disk this returns the link target path
7613/// bytes (via [`read_link`](std::fs::read_link)), not the contents of the followed file. A
7614/// symlink entry checked out as a plain file (`core.symlinks=false`) already holds the target,
7615/// so it is read like other modes, with a plain [`read`](std::fs::read) of the path.
7616///
7617/// # Errors
7618///
7619/// Returns [`std::io::Error`] when the path cannot be read.
7620pub fn read_worktree_bytes_for_mode(path: &Path, mode: u32) -> std::io::Result<Vec<u8>> {
7621    if mode == MODE_SYMLINK && fs::symlink_metadata(path)?.file_type().is_symlink() {
7622        let target = fs::read_link(path)?;
7623        return Ok(symlink_target_bytes(&target));
7624    }
7625    fs::read(path)
7626}
7627
7628/// Read worktree file bytes in Git (repository) form for line diffs against index or tree blobs.
7629///
7630/// Applies the same `.gitattributes`, filter, and line-ending rules as
7631/// [`diff_index_to_worktree`]. Pass the stage-0 [`IndexEntry`] when available so
7632/// `autocrlf` index-guard normalization matches status and raw diff collection.
7633///
7634/// # Errors
7635///
7636/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
7637pub fn read_worktree_side_bytes_for_diff(
7638    odb: &Odb,
7639    work_tree: &Path,
7640    rel_path: &str,
7641    mode: u32,
7642    config: &crate::config::ConfigSet,
7643    index_entry: Option<&IndexEntry>,
7644    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
7645) -> Result<Vec<u8>> {
7646    Ok(load_worktree_side_for_diff(
7647        odb,
7648        work_tree,
7649        rel_path,
7650        mode,
7651        config,
7652        index_entry,
7653        worktree_rules,
7654        false,
7655    )?
7656    .0
7657    .data)
7658}
7659
7660/// Load worktree bytes for diffing, optionally skipping reads above `core.bigFileThreshold`.
7661///
7662/// Returns empty [`super::structured::SideBytes`] with `binary = true` when the file is not read.
7663/// When `force_read_content` is true, large files are read and binary payload bytes are retained
7664/// (for `GIT binary patch` / `git apply`).
7665///
7666/// # Errors
7667///
7668/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
7669#[allow(clippy::too_many_arguments)]
7670pub fn load_worktree_side_for_diff(
7671    odb: &Odb,
7672    work_tree: &Path,
7673    rel_path: &str,
7674    mode: u32,
7675    config: &crate::config::ConfigSet,
7676    index_entry: Option<&IndexEntry>,
7677    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
7678    force_read_content: bool,
7679) -> Result<(super::structured::SideBytes, bool)> {
7680    use super::tree_content::{side_bytes_after_blob_read, treat_side_as_binary_without_reading};
7681    let file_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
7682    let rel_match = crate::repo_relpath::as_utf8_for_matching(rel_path);
7683    let meta = fs::symlink_metadata(&file_path).map_err(|source| {
7684        crate::worktree::map_worktree_io(
7685            rel_match.as_ref(),
7686            crate::worktree::WorktreePathOperation::Metadata,
7687            source,
7688        )
7689    })?;
7690    if mode == MODE_SYMLINK && meta.file_type().is_symlink() {
7691        let target = fs::read_link(&file_path).map_err(|source| {
7692            crate::worktree::map_worktree_io(
7693                rel_match.as_ref(),
7694                crate::worktree::WorktreePathOperation::Read,
7695                source,
7696            )
7697        })?;
7698        return Ok((
7699            super::structured::SideBytes::from_bytes(symlink_target_bytes(&target)),
7700            false,
7701        ));
7702    }
7703    if !force_read_content
7704        && treat_side_as_binary_without_reading(odb, meta.len(), Some(rel_match.as_ref()), false)?
7705    {
7706        return Ok((super::structured::SideBytes::from_bytes(Vec::new()), true));
7707    }
7708    let data = if let Some(rules) = worktree_rules {
7709        worktree_blob_bytes(
7710            odb,
7711            &file_path,
7712            &meta,
7713            rules.conversion(),
7714            &rules.file_attrs(rel_match.as_ref(), meta.is_dir()),
7715            rel_match.as_ref(),
7716            index_entry,
7717            Some(rules.filter_process()),
7718        )?
7719    } else {
7720        let conv = crate::crlf::ConversionConfig::from_config(config);
7721        let attrs = crate::crlf::load_gitattributes(work_tree);
7722        let file_attrs =
7723            crate::crlf::get_file_attrs(&attrs, rel_match.as_ref(), meta.is_dir(), config);
7724        worktree_blob_bytes(
7725            odb,
7726            &file_path,
7727            &meta,
7728            &conv,
7729            &file_attrs,
7730            rel_match.as_ref(),
7731            index_entry,
7732            None,
7733        )?
7734    };
7735    Ok(side_bytes_after_blob_read(data, force_read_content))
7736}
7737
7738/// Symlink target as the byte string Git hashes for the blob OID.
7739///
7740/// On Unix the raw `OsStr` bytes are used verbatim. On Windows `OsStr` is WTF-8
7741/// and has no stable byte view, so the lossy UTF-8 form is used (symlink targets
7742/// in Git trees are UTF-8 in practice).
7743pub(crate) fn symlink_target_bytes(target: &Path) -> Vec<u8> {
7744    #[cfg(unix)]
7745    {
7746        use std::os::unix::ffi::OsStrExt as _;
7747        target.as_os_str().as_bytes().to_vec()
7748    }
7749    #[cfg(not(unix))]
7750    {
7751        target.as_os_str().to_string_lossy().as_bytes().to_vec()
7752    }
7753}
7754
7755/// A worktree file's blob content: normalized bytes, or an open file that is hashed or
7756/// stored as a stream.
7757///
7758/// Regular files of at least the caller's size threshold whose bytes can be stored without clean
7759/// conversion ([`crate::crlf::worktree_allows_raw_byte_stream`], including binary files under
7760/// `core.autocrlf`) are streamed so memory use does not depend on the file size.
7761pub(crate) enum WorktreeBlobContent {
7762    /// Content after the clean conversion (filters, encoding, line endings).
7763    Bytes(Vec<u8>),
7764    /// Unconverted big file, positioned at its start.
7765    Stream {
7766        /// Open handle on the worktree file.
7767        file: fs::File,
7768        /// File size when it was opened.
7769        size: u64,
7770    },
7771}
7772
7773impl WorktreeBlobContent {
7774    /// Object id of the content, without storing it.
7775    pub(crate) fn hash(self, odb: &Odb) -> Result<ObjectId> {
7776        match self {
7777            Self::Bytes(data) => odb.hash(ObjectKind::Blob, &data),
7778            Self::Stream { mut file, size } => odb.hash_stream(ObjectKind::Blob, size, &mut file),
7779        }
7780    }
7781
7782    /// Store the content as a blob and return its id.
7783    pub(crate) fn store(self, odb: &Odb, options: crate::odb::WriteOptions) -> Result<ObjectId> {
7784        match self {
7785            Self::Bytes(data) => odb.write_with_options(ObjectKind::Blob, &data, options),
7786            Self::Stream { mut file, size } => {
7787                odb.write_stream(ObjectKind::Blob, size, &mut file, options)
7788            }
7789        }
7790    }
7791
7792    /// [`Self::store`] with `options` when given, otherwise [`Self::hash`].
7793    fn object_id(self, odb: &Odb, store: Option<crate::odb::WriteOptions>) -> Result<ObjectId> {
7794        match store {
7795            Some(options) => self.store(odb, options),
7796            None => self.hash(odb),
7797        }
7798    }
7799}
7800
7801/// Read and clean-convert an open regular worktree file of `size` bytes.
7802#[allow(clippy::too_many_arguments)]
7803fn read_regular_worktree_bytes(
7804    odb: &Odb,
7805    mut file: fs::File,
7806    size: u64,
7807    conv: &crate::crlf::ConversionConfig,
7808    file_attrs: &crate::crlf::FileAttrs,
7809    rel_path: &str,
7810    index_entry: Option<&IndexEntry>,
7811    filter_process: Option<&crate::filter_process::FilterProcessState>,
7812) -> Result<Vec<u8>> {
7813    let needs_index_blob =
7814        index_entry.is_some_and(|_| crate::crlf::clean_uses_autocrlf_index_guard(file_attrs, conv));
7815    let prior_blob: Option<Vec<u8>> = if needs_index_blob {
7816        index_entry
7817            .filter(|e| e.oid != zero_oid())
7818            .and_then(|e| odb.read(&e.oid).ok().map(|o| o.data))
7819    } else {
7820        None
7821    };
7822    let mut raw = Vec::with_capacity(usize::try_from(size).unwrap_or_default());
7823    std::io::Read::read_to_end(&mut file, &mut raw).map_err(|source| {
7824        crate::worktree::map_worktree_io(
7825            rel_path,
7826            crate::worktree::WorktreePathOperation::Read,
7827            source,
7828        )
7829    })?;
7830    #[cfg(test)]
7831    odb.hot_path_metrics().record_blob_content_read();
7832    let opts = crate::crlf::ConvertToGitOpts {
7833        index_blob: prior_blob.as_deref(),
7834        renormalize: false,
7835        check_safecrlf: false,
7836        filter_process,
7837        command_runner: None,
7838    };
7839    Ok(crate::crlf::convert_to_git_owned(
7840        raw, rel_path, conv, file_attrs, opts,
7841    )?)
7842}
7843
7844fn open_with_size(path: &Path, rel_path: &str) -> Result<(fs::File, u64)> {
7845    let file = fs::File::open(path).map_err(|source| {
7846        crate::worktree::map_worktree_io(
7847            rel_path,
7848            crate::worktree::WorktreePathOperation::Read,
7849            source,
7850        )
7851    })?;
7852    let size = file
7853        .metadata()
7854        .map_err(|source| {
7855            crate::worktree::map_worktree_io(
7856                rel_path,
7857                crate::worktree::WorktreePathOperation::Metadata,
7858                source,
7859            )
7860        })?
7861        .len();
7862    Ok((file, size))
7863}
7864
7865/// Open a regular worktree file and clean-convert it, or hand it out as a stream when it is
7866/// at least `stream_from` bytes and its clean conversion is the identity.
7867#[allow(clippy::too_many_arguments)]
7868fn read_regular_worktree_blob(
7869    odb: &Odb,
7870    path: &Path,
7871    conv: &crate::crlf::ConversionConfig,
7872    file_attrs: &crate::crlf::FileAttrs,
7873    rel_path: &str,
7874    index_entry: Option<&IndexEntry>,
7875    filter_process: Option<&crate::filter_process::FilterProcessState>,
7876    stream_from: u64,
7877) -> Result<WorktreeBlobContent> {
7878    let (mut file, size) = open_with_size(path, rel_path)?;
7879    if size >= stream_from
7880        && crate::crlf::worktree_allows_raw_byte_stream(
7881            conv,
7882            file_attrs,
7883            index_entry,
7884            &mut file,
7885            size,
7886        )?
7887    {
7888        #[cfg(test)]
7889        odb.hot_path_metrics().record_blob_content_read();
7890        return Ok(WorktreeBlobContent::Stream { file, size });
7891    }
7892    read_regular_worktree_bytes(
7893        odb,
7894        file,
7895        size,
7896        conv,
7897        file_attrs,
7898        rel_path,
7899        index_entry,
7900        filter_process,
7901    )
7902    .map(WorktreeBlobContent::Bytes)
7903}
7904
7905/// Blob content for a worktree path: symlink target, empty for a directory, or the
7906/// clean-converted (or streamed, see [`WorktreeBlobContent`]) file content.
7907#[allow(clippy::too_many_arguments)]
7908pub(crate) fn worktree_blob_content(
7909    odb: &Odb,
7910    path: &Path,
7911    meta: &fs::Metadata,
7912    conv: &crate::crlf::ConversionConfig,
7913    file_attrs: &crate::crlf::FileAttrs,
7914    rel_path: &str,
7915    index_entry: Option<&IndexEntry>,
7916    filter_process: Option<&crate::filter_process::FilterProcessState>,
7917) -> Result<WorktreeBlobContent> {
7918    worktree_blob_content_from(
7919        odb,
7920        path,
7921        meta,
7922        conv,
7923        file_attrs,
7924        rel_path,
7925        index_entry,
7926        filter_process,
7927        hash_stream_threshold(odb)?,
7928    )
7929}
7930
7931/// [`worktree_blob_content`], streaming unconverted regular files from `stream_from` bytes.
7932#[allow(clippy::too_many_arguments)]
7933fn worktree_blob_content_from(
7934    odb: &Odb,
7935    path: &Path,
7936    meta: &fs::Metadata,
7937    conv: &crate::crlf::ConversionConfig,
7938    file_attrs: &crate::crlf::FileAttrs,
7939    rel_path: &str,
7940    index_entry: Option<&IndexEntry>,
7941    filter_process: Option<&crate::filter_process::FilterProcessState>,
7942    stream_from: u64,
7943) -> Result<WorktreeBlobContent> {
7944    if meta.file_type().is_symlink() {
7945        let target = fs::read_link(path).map_err(|source| {
7946            crate::worktree::map_worktree_io(
7947                rel_path,
7948                crate::worktree::WorktreePathOperation::Read,
7949                source,
7950            )
7951        })?;
7952        return Ok(WorktreeBlobContent::Bytes(symlink_target_bytes(&target)));
7953    }
7954    if meta.is_dir() {
7955        return Ok(WorktreeBlobContent::Bytes(Vec::new()));
7956    }
7957    read_regular_worktree_blob(
7958        odb,
7959        path,
7960        conv,
7961        file_attrs,
7962        rel_path,
7963        index_entry,
7964        filter_process,
7965        stream_from,
7966    )
7967}
7968
7969/// Like [`worktree_blob_content`], always returning the bytes (for callers that need the
7970/// content itself, such as line diffs).
7971#[allow(clippy::too_many_arguments)]
7972pub(crate) fn worktree_blob_bytes(
7973    odb: &Odb,
7974    path: &Path,
7975    meta: &fs::Metadata,
7976    conv: &crate::crlf::ConversionConfig,
7977    file_attrs: &crate::crlf::FileAttrs,
7978    rel_path: &str,
7979    index_entry: Option<&IndexEntry>,
7980    filter_process: Option<&crate::filter_process::FilterProcessState>,
7981) -> Result<Vec<u8>> {
7982    if meta.file_type().is_symlink() {
7983        let target = fs::read_link(path).map_err(|source| {
7984            crate::worktree::map_worktree_io(
7985                rel_path,
7986                crate::worktree::WorktreePathOperation::Read,
7987                source,
7988            )
7989        })?;
7990        return Ok(symlink_target_bytes(&target));
7991    }
7992    if meta.is_dir() {
7993        return Ok(Vec::new());
7994    }
7995    let (file, size) = open_with_size(path, rel_path)?;
7996    read_regular_worktree_bytes(
7997        odb,
7998        file,
7999        size,
8000        conv,
8001        file_attrs,
8002        rel_path,
8003        index_entry,
8004        filter_process,
8005    )
8006}
8007
8008#[allow(clippy::too_many_arguments)]
8009fn worktree_file_oid(
8010    odb: &Odb,
8011    path: &Path,
8012    meta: &fs::Metadata,
8013    conv: &crate::crlf::ConversionConfig,
8014    file_attrs: &crate::crlf::FileAttrs,
8015    rel_path: &str,
8016    index_entry: Option<&IndexEntry>,
8017    materialize: bool,
8018    filter_process: Option<&crate::filter_process::FilterProcessState>,
8019) -> Result<ObjectId> {
8020    let stream_from = hash_stream_threshold(odb)?;
8021    worktree_blob_content_from(
8022        odb,
8023        path,
8024        meta,
8025        conv,
8026        file_attrs,
8027        rel_path,
8028        index_entry,
8029        filter_process,
8030        stream_from,
8031    )?
8032    .object_id(odb, materialize.then(|| odb.bulk_stage_write_options()))
8033}
8034
8035/// Hash normalized worktree bytes for a path (does not write to the object database).
8036///
8037/// Files that need no clean conversion are hashed as a stream, without reading them into
8038/// memory, from 1 MiB (or `core.bigFileThreshold` when that is smaller).
8039///
8040/// # Errors
8041///
8042/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
8043/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
8044/// stored or hashed unfiltered in that case.
8045#[allow(clippy::too_many_arguments)]
8046pub fn hash_worktree_file(
8047    odb: &Odb,
8048    path: &Path,
8049    meta: &fs::Metadata,
8050    conv: &crate::crlf::ConversionConfig,
8051    file_attrs: &crate::crlf::FileAttrs,
8052    rel_path: &str,
8053    index_entry: Option<&IndexEntry>,
8054    filter_process: Option<&crate::filter_process::FilterProcessState>,
8055) -> Result<ObjectId> {
8056    worktree_file_oid(
8057        odb,
8058        path,
8059        meta,
8060        conv,
8061        file_attrs,
8062        rel_path,
8063        index_entry,
8064        false,
8065        filter_process,
8066    )
8067}
8068
8069/// Work-tree files at least this large are hashed as a stream (when their content goes into
8070/// Git unchanged) even below `core.bigFileThreshold`, which only governs how blobs are stored.
8071const STREAMED_HASH_MIN_SIZE: u64 = 1024 * 1024;
8072
8073/// Size from which an unconverted work-tree file is hashed as a stream rather than read whole.
8074fn hash_stream_threshold(odb: &Odb) -> Result<u64> {
8075    Ok(odb.big_file_threshold()?.min(STREAMED_HASH_MIN_SIZE))
8076}
8077
8078/// Like [`hash_worktree_file`], for callers that already know the worktree file type from a
8079/// directory scan (`worktree_mode` is the Git mode derived from that stat), so no metadata
8080/// probe is needed.
8081#[allow(clippy::too_many_arguments)]
8082pub(crate) fn hash_worktree_file_with_mode(
8083    odb: &Odb,
8084    path: &Path,
8085    worktree_mode: u32,
8086    conv: &crate::crlf::ConversionConfig,
8087    file_attrs: &crate::crlf::FileAttrs,
8088    rel_path: &str,
8089    index_entry: Option<&IndexEntry>,
8090    filter_process: Option<&crate::filter_process::FilterProcessState>,
8091) -> Result<ObjectId> {
8092    if worktree_mode == MODE_SYMLINK {
8093        let data = read_worktree_bytes_for_mode(path, worktree_mode)?;
8094        return odb.hash(ObjectKind::Blob, &data);
8095    }
8096    read_regular_worktree_blob(
8097        odb,
8098        path,
8099        conv,
8100        file_attrs,
8101        rel_path,
8102        index_entry,
8103        filter_process,
8104        hash_stream_threshold(odb)?,
8105    )?
8106    .hash(odb)
8107}
8108
8109/// Read, normalize (CRLF/attributes), and store a worktree blob once.
8110///
8111/// # Errors
8112///
8113/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
8114/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
8115/// stored or hashed unfiltered in that case.
8116#[allow(clippy::too_many_arguments)]
8117pub fn materialize_worktree_blob(
8118    odb: &Odb,
8119    path: &Path,
8120    meta: &fs::Metadata,
8121    conv: &crate::crlf::ConversionConfig,
8122    file_attrs: &crate::crlf::FileAttrs,
8123    rel_path: &str,
8124    index_entry: Option<&IndexEntry>,
8125    filter_process: Option<&crate::filter_process::FilterProcessState>,
8126) -> Result<ObjectId> {
8127    worktree_file_oid(
8128        odb,
8129        path,
8130        meta,
8131        conv,
8132        file_attrs,
8133        rel_path,
8134        index_entry,
8135        true,
8136        filter_process,
8137    )
8138}
8139
8140/// Derive a Git file mode from filesystem metadata.
8141pub fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
8142    if meta.file_type().is_symlink() {
8143        0o120000
8144    } else {
8145        #[cfg(unix)]
8146        {
8147            if meta.mode() & 0o111 != 0 {
8148                return 0o100755;
8149            }
8150        }
8151        0o100644
8152    }
8153}
8154
8155/// Compare a tree against the working tree.
8156///
8157/// Shows changes from `tree_oid` to the current working directory state.
8158/// Files tracked in the index but not in the tree are shown as Added.
8159/// Files in the tree but missing from the working tree are shown as Deleted.
8160///
8161/// # Parameters
8162///
8163/// - `odb` — object database.
8164/// - `tree_oid` — the tree to compare against (`None` for empty tree).
8165/// - `work_tree` — path to the working tree root.
8166/// - `index` — current index (used to discover new tracked files not in tree).
8167///
8168/// # Errors
8169///
8170/// Returns errors from ODB reads or I/O.
8171pub fn diff_tree_to_worktree(
8172    odb: &Odb,
8173    tree_oid: Option<&ObjectId>,
8174    work_tree: &Path,
8175    index: &Index,
8176) -> Result<Vec<DiffEntry>> {
8177    diff_tree_to_worktree_with_git_dir(
8178        odb,
8179        tree_oid,
8180        work_tree,
8181        &work_tree.join(".git"),
8182        index,
8183        None,
8184    )
8185}
8186
8187/// Like [`diff_tree_to_worktree`], but loads config from `repository_git_dir` (linked worktrees / gitfiles).
8188///
8189/// Pass `config` when the caller already holds a repository config snapshot.
8190pub fn diff_tree_to_worktree_with_git_dir(
8191    odb: &Odb,
8192    tree_oid: Option<&ObjectId>,
8193    work_tree: &Path,
8194    repository_git_dir: &Path,
8195    index: &Index,
8196    config: Option<&ConfigSet>,
8197) -> Result<Vec<DiffEntry>> {
8198    diff_tree_to_worktree_with_git_dir_and_rules(
8199        odb,
8200        tree_oid,
8201        work_tree,
8202        repository_git_dir,
8203        index,
8204        config,
8205        None,
8206    )
8207}
8208
8209/// Like [`diff_tree_to_worktree_with_git_dir`], with optional shared [`crate::worktree_rules::WorktreeRules`].
8210pub fn diff_tree_to_worktree_with_git_dir_and_rules(
8211    odb: &Odb,
8212    tree_oid: Option<&ObjectId>,
8213    work_tree: &Path,
8214    repository_git_dir: &Path,
8215    index: &Index,
8216    config: Option<&ConfigSet>,
8217    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8218) -> Result<Vec<DiffEntry>> {
8219    use crate::config::ConfigSet;
8220    use crate::crlf;
8221
8222    if worktree_rules.is_none() {
8223        if let Some(tree_oid) = tree_oid {
8224            if index.cache_tree_root.as_ref() == Some(tree_oid)
8225                && crate::write_tree::cache_tree_fully_valid(odb, index.cache_tree.as_ref())
8226            {
8227                let mut idx = index.clone();
8228                let options = DiffIndexToWorktreeOptions {
8229                    repository_git_dir: Some(repository_git_dir.to_path_buf()),
8230                    refresh_index_stat_in_pass: false,
8231                    // Tree↔worktree raw lines use a null worktree OID when content differs; the
8232                    // status fast path (`for_status`) uses the same null OID for size-only drift
8233                    // without hashing (see `status_size_change_proves_modified`).
8234                    for_status: false,
8235                    config: config.cloned().map(std::sync::Arc::new),
8236                    ..DiffIndexToWorktreeOptions::default()
8237                };
8238                let (entries, _, _) = diff_index_to_worktree_inner(
8239                    odb,
8240                    &mut idx,
8241                    work_tree,
8242                    repository_git_dir,
8243                    options,
8244                    false,
8245                    None,
8246                )?;
8247                return Ok(entries);
8248            }
8249        }
8250    }
8251
8252    let config = worktree_rules
8253        .map(|r| r.config().clone())
8254        .unwrap_or_else(|| {
8255            config.cloned().unwrap_or_else(|| {
8256                ConfigSet::load(
8257                    &crate::environment::Environment::empty(),
8258                    Some(repository_git_dir),
8259                    true,
8260                )
8261                .unwrap_or_else(|_| ConfigSet::new())
8262            })
8263        });
8264    let conv = worktree_rules
8265        .map(|r| r.conversion().clone())
8266        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
8267    let legacy_attrs = if worktree_rules.is_none() {
8268        Some(crlf::load_gitattributes_parsed(work_tree))
8269    } else {
8270        None
8271    };
8272    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
8273        if let Some(rules) = worktree_rules {
8274            rules.file_attrs(path, false)
8275        } else if let Some(parsed) = legacy_attrs.as_ref() {
8276            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
8277        } else {
8278            crlf::FileAttrs::default()
8279        }
8280    };
8281    let filter_fp = worktree_rules.map(|r| r.filter_process());
8282
8283    let sparse_index = index.has_sparse_directory_placeholders();
8284    // Flatten the tree into a BTreeMap keyed by path
8285    let tree_flat = match tree_oid {
8286        Some(oid) if sparse_index => flatten_tree_with_index(odb, oid, "", Some(index))?,
8287        Some(oid) => flatten_tree(odb, oid, "")?,
8288        None => Vec::new(),
8289    };
8290    let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
8291        tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
8292
8293    // Build index lookup: path → &IndexEntry (stage 0 only)
8294    let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
8295        std::collections::BTreeMap::new();
8296    let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
8297    let mut stage0_paths: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
8298    for ie in &index.entries {
8299        if ie.stage() != 0 {
8300            continue;
8301        }
8302        let path = String::from_utf8_lossy(&ie.path).to_string();
8303        index_entries.insert(&ie.path, ie);
8304        index_paths.insert(path);
8305        stage0_paths.insert(ie.path.clone());
8306    }
8307
8308    // Paths with only unmerged stages (1–3) and no stage 0 — `git diff <rev>` must still list them
8309    // so combined `diff --cc` conflict hunks can be emitted (`t4108-apply-threeway`).
8310    let mut unmerged_only_paths: std::collections::BTreeSet<String> =
8311        std::collections::BTreeSet::new();
8312    for ie in &index.entries {
8313        if !(1..=3).contains(&ie.stage()) {
8314            continue;
8315        }
8316        if stage0_paths.contains(&ie.path) {
8317            continue;
8318        }
8319        unmerged_only_paths.insert(String::from_utf8_lossy(&ie.path).into_owned());
8320    }
8321
8322    // Union of tree paths + index paths
8323    let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
8324    all_paths.extend(tree_map.keys().cloned());
8325    all_paths.extend(index_paths.iter().cloned());
8326    all_paths.extend(unmerged_only_paths.iter().cloned());
8327
8328    let index_mtime = index_mtime_for_diff(index, None);
8329    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
8330    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
8331    let mut result = Vec::new();
8332
8333    for path in &all_paths {
8334        if index_entries
8335            .get(path.as_bytes())
8336            .is_some_and(|ie| ie.skip_worktree())
8337        {
8338            // Sparse checkout: `git diff <rev>` does not report tree↔worktree drift for
8339            // skip-worktree paths (they are outside the sparse cone). Matches t7012 stash flow.
8340            continue;
8341        }
8342        if index_entries
8343            .get(path.as_bytes())
8344            .is_some_and(|ie| ie.assume_unchanged())
8345        {
8346            continue;
8347        }
8348
8349        let tree_entry = tree_map.get(path.as_str());
8350
8351        // Gitlink entries (submodules) — compare HEAD commit, not file content.
8352        let is_gitlink = tree_entry.is_some_and(|te| te.mode == 0o160000)
8353            || index_entries
8354                .get(path.as_bytes())
8355                .is_some_and(|ie| ie.mode == 0o160000);
8356        if is_gitlink {
8357            let sub_dir = work_tree.join(path);
8358            let index_gitlink_oid = index_entries
8359                .get(path.as_bytes())
8360                .filter(|ie| ie.mode == 0o160000)
8361                .map(|ie| ie.oid);
8362            match (tree_entry, index_gitlink_oid) {
8363                (Some(te), _) => {
8364                    let sub_head = read_submodule_head_oid(&sub_dir);
8365                    // A gitlink whose worktree directory no longer exists is a deleted submodule:
8366                    // Git's `diff-lib.c` reports it as a deletion (status `D`, new mode 000000) and
8367                    // `--submodule` renders the `(submodule deleted)` summary (t4041 #46).
8368                    if sub_head.is_none() && !sub_dir.exists() {
8369                        result.push(DiffEntry {
8370                            status: DiffStatus::Deleted,
8371                            old_path: Some(path.clone()),
8372                            new_path: Some(path.clone()),
8373                            old_mode: FileMode::new(te.mode),
8374                            new_mode: FileMode::ABSENT,
8375                            old_oid: te.oid,
8376                            new_oid: zero_oid(),
8377                            score: None,
8378                        });
8379                        continue;
8380                    }
8381                    let ref_matches = if let Some(head) = sub_head {
8382                        head == te.oid
8383                    } else {
8384                        uninitialized_gitlink_worktree_matches_index(&sub_dir)
8385                    };
8386                    let index_matches_tree = index_gitlink_oid.is_some_and(|oid| oid == te.oid);
8387                    // Default tree↔worktree diff matches Git: untracked-only paths inside a
8388                    // checked-out submodule do not make the gitlink differ (see `git diff <tree>`).
8389                    let sub_flags = submodule_porcelain_flags(work_tree, path, te.oid);
8390                    let dirty_while_aligned =
8391                        index_matches_tree && ref_matches && sub_flags.modified;
8392                    if !ref_matches || dirty_while_aligned {
8393                        // Raw `git diff <tree>` lines use a null OID on the worktree side when the
8394                        // checked-out submodule HEAD differs from the tree's gitlink; patch output
8395                        // still resolves the real commit from the submodule directory.
8396                        let new_oid = if !ref_matches {
8397                            sub_head.unwrap_or_else(zero_oid)
8398                        } else {
8399                            zero_oid()
8400                        };
8401                        result.push(DiffEntry {
8402                            status: DiffStatus::Modified,
8403                            old_path: Some(path.clone()),
8404                            new_path: Some(path.clone()),
8405                            old_mode: FileMode::new(te.mode),
8406                            new_mode: FileMode::new(te.mode),
8407                            old_oid: te.oid,
8408                            new_oid,
8409                            score: None,
8410                        });
8411                    }
8412                }
8413                (None, Some(idx_oid)) => {
8414                    // Gitlink staged in the index but absent from the tree: a new submodule. Git
8415                    // reports it as an addition (status `A`) with the index gitlink on the new side,
8416                    // so `--submodule` renders `(new submodule)` (t4041 #46). The patch renderer
8417                    // resolves the real commit from the index OID / submodule HEAD.
8418                    let new_oid = read_submodule_head_oid(&sub_dir).unwrap_or(idx_oid);
8419                    result.push(DiffEntry {
8420                        status: DiffStatus::Added,
8421                        old_path: Some(path.clone()),
8422                        new_path: Some(path.clone()),
8423                        old_mode: FileMode::ABSENT,
8424                        new_mode: FileMode::new(0o160000),
8425                        old_oid: zero_oid(),
8426                        new_oid,
8427                        score: None,
8428                    });
8429                }
8430                (None, None) => {}
8431            }
8432            continue;
8433        }
8434
8435        let file_path = work_tree.join(path);
8436
8437        let wt_meta = match fs::symlink_metadata(&file_path) {
8438            Ok(m) => Some(m),
8439            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
8440            Err(e) => return Err(Error::Io(e)),
8441        };
8442
8443        if unmerged_only_paths.contains(path) {
8444            if let (Some(te), Some(meta)) = (tree_entry, wt_meta.as_ref()) {
8445                let tree_mode = crate::index::canon_mode(te.mode);
8446                let file_attrs = file_attrs_for(path);
8447                let wt_oid = hash_worktree_file(
8448                    odb,
8449                    &file_path,
8450                    meta,
8451                    &conv,
8452                    &file_attrs,
8453                    path,
8454                    None,
8455                    filter_fp,
8456                )?;
8457                let wt_mode =
8458                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(tree_mode));
8459                if wt_oid != te.oid || wt_mode != tree_mode {
8460                    result.push(DiffEntry {
8461                        status: DiffStatus::for_content_change(tree_mode, wt_mode),
8462                        old_path: Some(path.clone()),
8463                        new_path: Some(path.clone()),
8464                        old_mode: FileMode::new(tree_mode),
8465                        new_mode: FileMode::new(wt_mode),
8466                        old_oid: te.oid,
8467                        new_oid: wt_oid,
8468                        score: None,
8469                    });
8470                }
8471            }
8472            continue;
8473        }
8474
8475        match (tree_entry, wt_meta) {
8476            (Some(te), Some(ref meta)) => {
8477                let tree_mode = crate::index::canon_mode(te.mode);
8478                let Some(ie) = index_entries.get(path.as_bytes()) else {
8479                    continue;
8480                };
8481                let wt_mode =
8482                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(ie.mode));
8483
8484                let index_matches_tree = ie.oid == te.oid && ie.mode == tree_mode;
8485
8486                // Fully clean: index matches `HEAD`, worktree matches index, stat cache fresh.
8487                let stat_trusted = stat_matches_with_policy(ie, meta, stat_policy)
8488                    && !entry_is_racy_with_policy(ie, index_mtime, stat_policy);
8489                if index_matches_tree && stat_trusted && wt_mode == tree_mode {
8490                    continue;
8491                }
8492
8493                let file_attrs = file_attrs_for(path);
8494                let idx_ent = index_entries.get(path.as_bytes()).copied();
8495
8496                // Staged mode (same blob as `HEAD`, different mode recorded in the index) with
8497                // the worktree still matching the index. `core.filemode=false` does not discard
8498                // an explicit index chmod vs HEAD. Once the worktree drifts from the index, the
8499                // diff must describe the worktree side, so fall through.
8500                if ie.oid == te.oid && ie.mode != tree_mode && stat_trusted && wt_mode == ie.mode {
8501                    result.push(DiffEntry {
8502                        status: DiffStatus::for_content_change(tree_mode, ie.mode),
8503                        old_path: Some(path.clone()),
8504                        new_path: Some(path.clone()),
8505                        old_mode: FileMode::new(tree_mode),
8506                        new_mode: FileMode::new(ie.mode),
8507                        old_oid: te.oid,
8508                        new_oid: te.oid,
8509                        score: None,
8510                    });
8511                    continue;
8512                }
8513
8514                // Index still matches `HEAD`: only unstaged worktree drift (content and/or
8515                // worktree-only exec bit when `update-index` was not run — t4049 harness).
8516                if index_matches_tree {
8517                    let wt_oid = hash_worktree_file(
8518                        odb,
8519                        &file_path,
8520                        meta,
8521                        &conv,
8522                        &file_attrs,
8523                        path,
8524                        None,
8525                        filter_fp,
8526                    )?;
8527                    if wt_oid != te.oid {
8528                        result.push(DiffEntry {
8529                            status: DiffStatus::for_content_change(tree_mode, wt_mode),
8530                            old_path: Some(path.clone()),
8531                            new_path: Some(path.clone()),
8532                            old_mode: FileMode::new(tree_mode),
8533                            new_mode: FileMode::new(wt_mode),
8534                            old_oid: te.oid,
8535                            // Raw `git diff <tree>` uses a null OID on the worktree side (patch
8536                            // output resolves content from the working tree).
8537                            new_oid: zero_oid(),
8538                            score: None,
8539                        });
8540                    } else if wt_mode != tree_mode {
8541                        result.push(DiffEntry {
8542                            status: DiffStatus::for_content_change(tree_mode, wt_mode),
8543                            old_path: Some(path.clone()),
8544                            new_path: Some(path.clone()),
8545                            old_mode: FileMode::new(tree_mode),
8546                            new_mode: FileMode::new(wt_mode),
8547                            old_oid: te.oid,
8548                            new_oid: te.oid,
8549                            score: None,
8550                        });
8551                    }
8552                    continue;
8553                }
8554
8555                // Staged content (and possibly mode): `git diff <rev>` is tree vs working tree.
8556                let wt_oid = hash_worktree_file(
8557                    odb,
8558                    &file_path,
8559                    meta,
8560                    &conv,
8561                    &file_attrs,
8562                    path,
8563                    idx_ent,
8564                    filter_fp,
8565                )?;
8566                if wt_oid != te.oid || wt_mode != tree_mode {
8567                    let new_oid = if wt_oid != te.oid { zero_oid() } else { te.oid };
8568                    result.push(DiffEntry {
8569                        status: DiffStatus::for_content_change(tree_mode, wt_mode),
8570                        old_path: Some(path.clone()),
8571                        new_path: Some(path.clone()),
8572                        old_mode: FileMode::new(tree_mode),
8573                        new_mode: FileMode::new(wt_mode),
8574                        old_oid: te.oid,
8575                        new_oid,
8576                        score: None,
8577                    });
8578                }
8579            }
8580            (Some(te), None) => {
8581                let tree_mode = crate::index::canon_mode(te.mode);
8582                // In tree but missing from worktree
8583                result.push(DiffEntry {
8584                    status: DiffStatus::Deleted,
8585                    old_path: Some(path.clone()),
8586                    new_path: None,
8587                    old_mode: FileMode::new(tree_mode),
8588                    new_mode: FileMode::ABSENT,
8589                    old_oid: te.oid,
8590                    new_oid: zero_oid(),
8591                    score: None,
8592                });
8593            }
8594            (None, Some(ref meta)) => {
8595                // In index but not in tree, and exists in worktree
8596                let file_attrs = file_attrs_for(path);
8597                let idx_ent = index_entries.get(path.as_bytes()).copied();
8598                let wt_oid = materialize_worktree_blob(
8599                    odb,
8600                    &file_path,
8601                    meta,
8602                    &conv,
8603                    &file_attrs,
8604                    path,
8605                    idx_ent,
8606                    filter_fp,
8607                )?;
8608                let wt_mode = mode_from_metadata(meta);
8609                result.push(DiffEntry {
8610                    status: DiffStatus::Added,
8611                    old_path: None,
8612                    new_path: Some(path.clone()),
8613                    old_mode: FileMode::ABSENT,
8614                    new_mode: FileMode::new(wt_mode),
8615                    old_oid: zero_oid(),
8616                    new_oid: wt_oid,
8617                    score: None,
8618                });
8619            }
8620            (None, None) => {
8621                // Tracked in index but neither in tree nor worktree — skip
8622            }
8623        }
8624    }
8625
8626    sort_entries_by_path(&mut result);
8627    Ok(result)
8628}
8629
8630// ── Rename detection ────────────────────────────────────────────────
8631
8632fn read_added_entry_bytes(
8633    odb: &Odb,
8634    entry: &DiffEntry,
8635    work_root: Option<&Path>,
8636) -> Option<Vec<u8>> {
8637    if entry.new_oid != zero_oid() {
8638        if let Ok(obj) = odb.read(&entry.new_oid) {
8639            return Some(obj.data);
8640        }
8641    }
8642    let path = entry.new_path.as_deref()?;
8643    let root = work_root?;
8644    fs::read(root.join(path)).ok()
8645}
8646
8647/// Detect renames by pairing Deleted and Added entries with similar content.
8648///
8649/// `threshold` is the minimum similarity percentage (0–100) for a pair to
8650/// be considered a rename (Git's default is 50%).  The function reads blob
8651/// content from the ODB to compute a line-level similarity score.
8652///
8653/// Exact-OID matches are always 100% similar regardless of content.
8654///
8655/// When `work_root` is set, added entries whose `new_oid` is the zero placeholder (as in
8656/// uncached `diff-index` when the work tree diverged from the index) load content from disk
8657/// under that root instead of the object database.
8658#[allow(clippy::expect_used)]
8659pub fn detect_renames(
8660    odb: &Odb,
8661    work_root: Option<&Path>,
8662    entries: Vec<DiffEntry>,
8663    threshold: u32,
8664) -> Vec<DiffEntry> {
8665    let config = ConfigSet::new();
8666    let policy = ThreadPolicy::default();
8667    detect_renames_with_policy(odb, work_root, entries, threshold, &config, &policy, None)
8668        .expect("rename detection")
8669}
8670
8671#[inline]
8672fn poll_rename_cancel(
8673    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
8674) -> crate::error::Result<()> {
8675    if let Some(watch) = cancel_watch {
8676        crate::progress::poll_watch(watch)?;
8677    }
8678    Ok(())
8679}
8680
8681/// Like [`detect_renames`], but honors `index.threads` / `--threads` via `thread_policy`.
8682///
8683/// A list without both an added and a deleted entry has nothing to pair. When its paths
8684/// are also unique and in [`sort_entries_by_path`] order, as tree diffs return them, the
8685/// list comes back unchanged without reading blobs or allocating.
8686///
8687/// # Errors
8688///
8689/// Returns object database errors from reading candidate blobs, and the cancellation
8690/// error when `cancel_watch` is set.
8691pub fn detect_renames_with_policy(
8692    odb: &Odb,
8693    work_root: Option<&Path>,
8694    entries: Vec<DiffEntry>,
8695    threshold: u32,
8696    config: &ConfigSet,
8697    thread_policy: &ThreadPolicy,
8698    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
8699) -> Result<Vec<DiffEntry>> {
8700    poll_rename_cancel(cancel_watch)?;
8701    let has_deleted = entries.iter().any(|e| e.status == DiffStatus::Deleted);
8702    let has_added = entries.iter().any(|e| e.status == DiffStatus::Added);
8703    if (!has_deleted || !has_added) && entries_strictly_sorted_by_path(&entries) {
8704        // With unique sorted paths, regrouping and re-sorting would rebuild the same list.
8705        return Ok(entries);
8706    }
8707    // Split entries into deleted, added, and others.
8708    let mut deleted: Vec<DiffEntry> = Vec::new();
8709    let mut added: Vec<DiffEntry> = Vec::new();
8710    let mut others: Vec<DiffEntry> = Vec::new();
8711
8712    for entry in entries {
8713        match entry.status {
8714            DiffStatus::Deleted => deleted.push(entry),
8715            DiffStatus::Added => added.push(entry),
8716            _ => others.push(entry),
8717        }
8718    }
8719
8720    if deleted.is_empty() || added.is_empty() {
8721        // Nothing to pair — return original order.
8722        let mut result = others;
8723        result.extend(deleted);
8724        result.extend(added);
8725        sort_entries_by_path(&mut result);
8726        return Ok(result);
8727    }
8728
8729    fn is_regularish_mode(mode: FileMode) -> bool {
8730        mode == FileMode::REGULAR || mode == FileMode::EXECUTABLE
8731    }
8732
8733    fn same_path_same_blob(del: &DiffEntry, add: &DiffEntry) -> bool {
8734        del.old_path == add.new_path && del.old_oid == add.new_oid && del.old_mode == add.new_mode
8735    }
8736
8737    fn sort_rename_scores(
8738        scores: &mut [(u32, usize, usize)],
8739        deleted: &[DiffEntry],
8740        added: &[DiffEntry],
8741    ) {
8742        scores.sort_by(|a, b| {
8743            let a_noop = same_path_same_blob(&deleted[a.1], &added[a.2]);
8744            let b_noop = same_path_same_blob(&deleted[b.1], &added[b.2]);
8745            let a_same = same_basename(&deleted[a.1], &added[a.2]);
8746            let b_same = same_basename(&deleted[b.1], &added[b.2]);
8747            a_noop
8748                .cmp(&b_noop)
8749                .then_with(|| b_same.cmp(&a_same))
8750                .then_with(|| b.0.cmp(&a.0))
8751        });
8752    }
8753
8754    fn apply_rename_scores(
8755        scores: &[(u32, usize, usize)],
8756        deleted: &[DiffEntry],
8757        added: &[DiffEntry],
8758        used_deleted: &mut [bool],
8759        used_added: &mut [bool],
8760        renames: &mut Vec<DiffEntry>,
8761    ) {
8762        for (score, di, ai) in scores {
8763            if used_deleted[*di] || used_added[*ai] {
8764                continue;
8765            }
8766            used_deleted[*di] = true;
8767            used_added[*ai] = true;
8768
8769            let del = &deleted[*di];
8770            let add = &added[*ai];
8771            if same_path_same_blob(del, add) {
8772                continue;
8773            }
8774
8775            renames.push(DiffEntry {
8776                status: DiffStatus::Renamed,
8777                old_path: del.old_path.clone(),
8778                new_path: add.new_path.clone(),
8779                old_mode: del.old_mode,
8780                new_mode: add.new_mode,
8781                old_oid: del.old_oid,
8782                new_oid: add.new_oid,
8783                score: Some(*score),
8784            });
8785        }
8786    }
8787
8788    let mut used_deleted = vec![false; deleted.len()];
8789    let mut used_added = vec![false; added.len()];
8790    let mut renames: Vec<DiffEntry> = Vec::new();
8791
8792    // Git always runs exact rename detection, regardless of renameLimit.
8793    let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
8794    for (di, del) in deleted.iter().enumerate() {
8795        poll_rename_cancel(cancel_watch)?;
8796        for (ai, add) in added.iter().enumerate() {
8797            if del.old_oid == add.new_oid {
8798                exact_scores.push((100, di, ai));
8799            }
8800        }
8801    }
8802    sort_rename_scores(&mut exact_scores, &deleted, &added);
8803    apply_rename_scores(
8804        &exact_scores,
8805        &deleted,
8806        &added,
8807        &mut used_deleted,
8808        &mut used_added,
8809        &mut renames,
8810    );
8811
8812    let remaining_deleted: Vec<usize> = used_deleted
8813        .iter()
8814        .enumerate()
8815        .filter_map(|(i, used)| (!*used).then_some(i))
8816        .collect();
8817    let remaining_added: Vec<usize> = used_added
8818        .iter()
8819        .enumerate()
8820        .filter_map(|(i, used)| (!*used).then_some(i))
8821        .collect();
8822
8823    let rename_limit = parse_rename_limit(config);
8824    if !remaining_deleted.is_empty()
8825        && !remaining_added.is_empty()
8826        && !inexact_matrix_exceeds_limit(
8827            remaining_added.len(),
8828            remaining_deleted.len(),
8829            rename_limit,
8830        )
8831    {
8832        let par_opts = diff_rename_parallel_options(config, thread_policy);
8833
8834        let mut prefetch_entries: Vec<DiffEntry> =
8835            Vec::with_capacity(remaining_deleted.len() + remaining_added.len());
8836        for &di in &remaining_deleted {
8837            prefetch_entries.push(deleted[di].clone());
8838        }
8839        for &ai in &remaining_added {
8840            prefetch_entries.push(added[ai].clone());
8841        }
8842        super::tree_content::prefetch_tree_diff_blobs(odb, &prefetch_entries)?;
8843
8844        let deleted_sizes: Vec<Option<usize>> = remaining_deleted
8845            .iter()
8846            .map(|&di| blob_payload_size(odb, &deleted[di].old_oid))
8847            .collect();
8848        let added_sizes: Vec<Option<usize>> = remaining_added
8849            .iter()
8850            .map(|&ai| added_entry_payload_size(odb, &added[ai], work_root))
8851            .collect();
8852
8853        let mut load_deleted = vec![false; remaining_deleted.len()];
8854        let mut load_added = vec![false; remaining_added.len()];
8855        let mut candidate_pairs: Vec<(usize, usize)> = Vec::new();
8856        for (row, &di) in remaining_deleted.iter().enumerate() {
8857            poll_rename_cancel(cancel_watch)?;
8858            let del = &deleted[di];
8859            if !is_regularish_mode(del.old_mode) {
8860                continue;
8861            }
8862            let Some(ds) = deleted_sizes[row] else {
8863                continue;
8864            };
8865            for (col, &ai) in remaining_added.iter().enumerate() {
8866                let add = &added[ai];
8867                if !is_regularish_mode(add.new_mode) {
8868                    continue;
8869                }
8870                let Some(asz) = added_sizes[col] else {
8871                    continue;
8872                };
8873                if rename_pair_size_reachable(ds, asz, threshold) {
8874                    load_deleted[row] = true;
8875                    load_added[col] = true;
8876                    candidate_pairs.push((row, col));
8877                }
8878            }
8879        }
8880
8881        if !candidate_pairs.is_empty() {
8882            let loads_needed = load_deleted.iter().filter(|&&b| b).count()
8883                + load_added.iter().filter(|&&b| b).count();
8884            let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
8885                if !load_deleted[i] {
8886                    return None;
8887                }
8888                odb.read(&deleted[remaining_deleted[i]].old_oid)
8889                    .ok()
8890                    .map(|obj| obj.data)
8891            };
8892            let load_added_blob = |i: usize| -> Option<Vec<u8>> {
8893                if !load_added[i] {
8894                    return None;
8895                }
8896                read_added_entry_bytes(odb, &added[remaining_added[i]], work_root)
8897            };
8898            let (deleted_contents, added_contents) = if loads_needed <= 32 {
8899                (
8900                    (0..remaining_deleted.len())
8901                        .map(load_deleted_blob)
8902                        .collect(),
8903                    (0..remaining_added.len()).map(load_added_blob).collect(),
8904                )
8905            } else {
8906                (
8907                    parallel_load_optional_blobs(
8908                        odb,
8909                        remaining_deleted.len(),
8910                        par_opts,
8911                        0,
8912                        load_deleted_blob,
8913                    ),
8914                    parallel_load_optional_blobs(
8915                        odb,
8916                        remaining_added.len(),
8917                        par_opts,
8918                        0,
8919                        load_added_blob,
8920                    ),
8921                )
8922            };
8923
8924            let est_table_bytes: usize = deleted_contents
8925                .iter()
8926                .chain(added_contents.iter())
8927                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
8928                .sum();
8929            let build_deleted_table = |row: usize| -> std::result::Result<
8930                Option<RenameLineTable>,
8931                std::convert::Infallible,
8932            > {
8933                Ok(deleted_contents[row]
8934                    .as_ref()
8935                    .map(|d| RenameLineTable::from_blob(d)))
8936            };
8937            let build_added_table = |col: usize| -> std::result::Result<
8938                Option<RenameLineTable>,
8939                std::convert::Infallible,
8940            > {
8941                Ok(added_contents[col]
8942                    .as_ref()
8943                    .map(|d| RenameLineTable::from_blob(d)))
8944            };
8945            let deleted_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
8946                (0..remaining_deleted.len())
8947                    .map(|row| build_deleted_table(row).unwrap())
8948                    .collect()
8949            } else {
8950                parallel_map_indices(
8951                    odb,
8952                    remaining_deleted.len(),
8953                    par_opts,
8954                    est_table_bytes,
8955                    build_deleted_table,
8956                )
8957                .expect("deleted rename line tables")
8958            };
8959            let added_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
8960                (0..remaining_added.len())
8961                    .map(|col| build_added_table(col).unwrap())
8962                    .collect()
8963            } else {
8964                parallel_map_indices(
8965                    odb,
8966                    remaining_added.len(),
8967                    par_opts,
8968                    est_table_bytes,
8969                    build_added_table,
8970                )
8971                .expect("added rename line tables")
8972            };
8973
8974            let mut inexact_scores: Vec<(u32, usize, usize)> = Vec::new();
8975            for (row, col) in candidate_pairs {
8976                poll_rename_cancel(cancel_watch)?;
8977                let di = remaining_deleted[row];
8978                let ai = remaining_added[col];
8979                let (Some(old_table), Some(new_table)) = (&deleted_tables[row], &added_tables[col])
8980                else {
8981                    continue;
8982                };
8983                let score = RenameLineTable::similarity_score(old_table, new_table);
8984                if score >= threshold {
8985                    inexact_scores.push((score, di, ai));
8986                }
8987            }
8988            sort_rename_scores(&mut inexact_scores, &deleted, &added);
8989            apply_rename_scores(
8990                &inexact_scores,
8991                &deleted,
8992                &added,
8993                &mut used_deleted,
8994                &mut used_added,
8995                &mut renames,
8996            );
8997        }
8998    }
8999
9000    // Collect unmatched entries.
9001    let mut result = others;
9002    result.extend(renames);
9003    for (i, entry) in deleted.into_iter().enumerate() {
9004        if !used_deleted[i] {
9005            result.push(entry);
9006        }
9007    }
9008    for (i, entry) in added.into_iter().enumerate() {
9009        if !used_added[i] {
9010            result.push(entry);
9011        }
9012    }
9013
9014    result = prune_break_deletions_obviated_by_renames(result);
9015    sort_entries_by_path(&mut result);
9016    Ok(result)
9017}
9018
9019/// Detect copies among diff entries.
9020///
9021/// This first runs rename detection (pairing Deleted+Added), then for any
9022/// remaining Added entries, looks for copy sources.
9023///
9024/// - `find_copies_harder` = false: only Modified entries are copy source candidates.
9025/// - `find_copies_harder` = true: also examine unmodified files from `source_tree_entries`.
9026///
9027/// `source_tree_entries` should be a list of (path, mode, oid) from the source tree;
9028/// used when `find_copies_harder` is true to consider unmodified files as copy sources.
9029/// # Errors
9030///
9031/// Returns [`Error::HashCollision`] when hashing worktree blob content for copy detection
9032/// would produce a known-bad SHA-1 object id.
9033pub fn detect_copies(
9034    odb: &Odb,
9035    work_root: Option<&Path>,
9036    entries: Vec<DiffEntry>,
9037    threshold: u32,
9038    find_copies_harder: bool,
9039    source_tree_entries: &[(String, FileMode, ObjectId)],
9040) -> Result<Vec<DiffEntry>> {
9041    let config = ConfigSet::new();
9042    let policy = ThreadPolicy::default();
9043    detect_copies_with_policy(
9044        odb,
9045        work_root,
9046        entries,
9047        threshold,
9048        find_copies_harder,
9049        source_tree_entries,
9050        &config,
9051        &policy,
9052    )
9053}
9054
9055/// Like [`detect_copies`], but honors parallel thread policy and rename limits.
9056///
9057/// # Errors
9058///
9059/// Same as [`detect_copies`].
9060#[expect(clippy::too_many_arguments)]
9061pub fn detect_copies_with_policy(
9062    odb: &Odb,
9063    work_root: Option<&Path>,
9064    entries: Vec<DiffEntry>,
9065    threshold: u32,
9066    find_copies_harder: bool,
9067    source_tree_entries: &[(String, FileMode, ObjectId)],
9068    config: &ConfigSet,
9069    thread_policy: &ThreadPolicy,
9070) -> Result<Vec<DiffEntry>> {
9071    use std::collections::{HashMap, HashSet};
9072
9073    // Separate entries by status.
9074    let mut deleted: Vec<DiffEntry> = Vec::new();
9075    let mut added: Vec<DiffEntry> = Vec::new();
9076    let mut others: Vec<DiffEntry> = Vec::new();
9077
9078    for entry in entries {
9079        match entry.status {
9080            DiffStatus::Deleted => deleted.push(entry),
9081            DiffStatus::Added => added.push(entry),
9082            _ => others.push(entry),
9083        }
9084    }
9085
9086    // Build source candidates: deleted files, modified files, and optionally tree entries.
9087    // Track which sources are from deleted files (can become renames).
9088    let mut sources: Vec<(String, ObjectId, bool)> = Vec::new(); // (path, oid, is_deleted)
9089    let mut deleted_source_idx: HashMap<String, usize> = HashMap::new();
9090
9091    for entry in &deleted {
9092        if let Some(ref path) = entry.old_path {
9093            deleted_source_idx.insert(path.clone(), sources.len());
9094            sources.push((path.clone(), entry.old_oid, true));
9095        }
9096    }
9097
9098    // Modified and type-changed files are candidates for `-C` (e.g. symlink rewrite leaves the
9099    // old blob available as a copy source for another path; see `t4008-diff-break-rewrite`).
9100    for entry in &others {
9101        if matches!(entry.status, DiffStatus::Modified | DiffStatus::TypeChanged) {
9102            if let Some(ref old_path) = entry.old_path {
9103                if !sources.iter().any(|(p, _, _)| p == old_path) {
9104                    sources.push((old_path.clone(), entry.old_oid, false));
9105                }
9106            }
9107        }
9108    }
9109
9110    // With find_copies_harder, add all source tree entries.
9111    if find_copies_harder {
9112        for (path, _mode, oid) in source_tree_entries {
9113            if !sources.iter().any(|(p, _, _)| p == path) {
9114                sources.push((path.clone(), *oid, false));
9115            }
9116        }
9117    }
9118
9119    // Modes of every source, for the old side of copy and rename entries: the deleted and
9120    // modified entries carry their own, and `source_tree_entries` the rest.
9121    let mut source_modes: Vec<(String, FileMode, ObjectId)> = source_tree_entries.to_vec();
9122    for entry in deleted.iter().chain(&others) {
9123        if let Some(ref path) = entry.old_path {
9124            if !source_modes.iter().any(|(p, _, _)| p == path) {
9125                source_modes.push((path.clone(), entry.old_mode, entry.old_oid));
9126            }
9127        }
9128    }
9129
9130    if sources.is_empty() {
9131        let mut result = others;
9132        result.extend(deleted);
9133        result.extend(added);
9134        sort_entries_by_path(&mut result);
9135        return Ok(result);
9136    }
9137
9138    let par_opts = diff_rename_parallel_options(config, thread_policy);
9139    let rename_limit = parse_rename_limit(config);
9140
9141    fn apply_copy_score_assignments(
9142        scores: &[(u32, usize, usize)],
9143        sources: &[(String, ObjectId, bool)],
9144        added: &[DiffEntry],
9145        source_tree_entries: &[(String, FileMode, ObjectId)],
9146        used_added2: &mut [bool],
9147        renamed_deleted: &mut HashSet<usize>,
9148        result_entries: &mut Vec<DiffEntry>,
9149    ) {
9150        let mut used_added = vec![false; added.len()];
9151        let mut source_to_added: HashMap<usize, Vec<(usize, u32)>> = HashMap::new();
9152        for &(score, si, ai) in scores {
9153            if used_added[ai] {
9154                continue;
9155            }
9156            used_added[ai] = true;
9157            source_to_added.entry(si).or_default().push((ai, score));
9158        }
9159
9160        for (&si, assignments_for_src) in &source_to_added {
9161            let (_, _, is_deleted) = &sources[si];
9162            if *is_deleted && !assignments_for_src.is_empty() {
9163                let rename_ai = assignments_for_src
9164                    .iter()
9165                    .max_by_key(|(ai, _score)| added[*ai].path().to_string())
9166                    .map(|(ai, _)| *ai);
9167
9168                for &(ai, score) in assignments_for_src {
9169                    let (ref src_path, _, _) = sources[si];
9170                    let add = &added[ai];
9171                    let src_mode = source_tree_entries
9172                        .iter()
9173                        .find(|(p, _, _)| p == src_path)
9174                        .map_or(add.old_mode, |(_, m, _)| *m);
9175
9176                    let is_rename = Some(ai) == rename_ai;
9177                    result_entries.push(DiffEntry {
9178                        status: if is_rename {
9179                            DiffStatus::Renamed
9180                        } else {
9181                            DiffStatus::Copied
9182                        },
9183                        old_path: Some(src_path.clone()),
9184                        new_path: add.new_path.clone(),
9185                        old_mode: src_mode,
9186                        new_mode: add.new_mode,
9187                        old_oid: sources[si].1,
9188                        new_oid: add.new_oid,
9189                        score: Some(score),
9190                    });
9191                    used_added2[ai] = true;
9192                }
9193                renamed_deleted.insert(si);
9194            } else {
9195                for &(ai, score) in assignments_for_src {
9196                    let (ref src_path, _, _) = sources[si];
9197                    let add = &added[ai];
9198                    let src_mode = source_tree_entries
9199                        .iter()
9200                        .find(|(p, _, _)| p == src_path)
9201                        .map_or(add.old_mode, |(_, m, _)| *m);
9202
9203                    result_entries.push(DiffEntry {
9204                        status: DiffStatus::Copied,
9205                        old_path: Some(src_path.clone()),
9206                        new_path: add.new_path.clone(),
9207                        old_mode: src_mode,
9208                        new_mode: add.new_mode,
9209                        old_oid: sources[si].1,
9210                        new_oid: add.new_oid,
9211                        score: Some(score),
9212                    });
9213                    used_added2[ai] = true;
9214                }
9215            }
9216        }
9217    }
9218
9219    fn added_entry_oid(
9220        odb: &Odb,
9221        add: &DiffEntry,
9222        added_contents: &[Option<Vec<u8>>],
9223        ai: usize,
9224    ) -> Result<ObjectId> {
9225        if add.new_oid != zero_oid() {
9226            Ok(add.new_oid)
9227        } else if let Some(ref data) = added_contents[ai] {
9228            odb.hash(ObjectKind::Blob, data)
9229        } else {
9230            Ok(zero_oid())
9231        }
9232    }
9233
9234    let mut result_entries: Vec<DiffEntry> = Vec::new();
9235    let mut renamed_deleted: HashSet<usize> = HashSet::new();
9236    let mut used_added2 = vec![false; added.len()];
9237    let mut source_contents = vec![None; sources.len()];
9238
9239    if !added.is_empty() {
9240        let added_contents = parallel_load_optional_blobs(odb, added.len(), par_opts, 0, |ai| {
9241            read_added_entry_bytes(odb, &added[ai], work_root)
9242        });
9243
9244        // Git always scores exact OID copy/rename pairs before applying renameLimit.
9245        let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
9246        for (si, (src_path, src_oid, _)) in sources.iter().enumerate() {
9247            for (ai, add) in added.iter().enumerate() {
9248                if add.new_path.as_deref() == Some(src_path.as_str()) {
9249                    continue;
9250                }
9251                let add_oid = added_entry_oid(odb, add, &added_contents, ai)?;
9252                if *src_oid == add_oid && add_oid != zero_oid() {
9253                    exact_scores.push((100, si, ai));
9254                }
9255            }
9256        }
9257        exact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
9258        apply_copy_score_assignments(
9259            &exact_scores,
9260            &sources,
9261            &added,
9262            &source_modes,
9263            &mut used_added2,
9264            &mut renamed_deleted,
9265            &mut result_entries,
9266        );
9267
9268        let remaining_added: Vec<usize> = used_added2
9269            .iter()
9270            .enumerate()
9271            .filter_map(|(i, used)| (!*used).then_some(i))
9272            .collect();
9273        let remaining_source_count = sources
9274            .iter()
9275            .enumerate()
9276            .filter(|(si, (_, _, is_deleted))| !*is_deleted || !renamed_deleted.contains(si))
9277            .count();
9278
9279        let skip_inexact = remaining_added.is_empty()
9280            || inexact_matrix_exceeds_limit(
9281                remaining_added.len(),
9282                remaining_source_count,
9283                rename_limit,
9284            );
9285
9286        if !skip_inexact {
9287            source_contents = parallel_load_optional_blobs(odb, sources.len(), par_opts, 0, |si| {
9288                odb.read(&sources[si].1).ok().map(|obj| obj.data)
9289            });
9290
9291            let est_bytes: usize = source_contents
9292                .iter()
9293                .chain(added_contents.iter())
9294                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
9295                .sum();
9296
9297            let source_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
9298                odb,
9299                sources.len(),
9300                par_opts,
9301                est_bytes,
9302                |si| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
9303                    Ok(source_contents[si]
9304                        .as_ref()
9305                        .map(|d| RenameLineTable::from_blob(d)))
9306                },
9307            )
9308            .expect("copy source line tables");
9309            let added_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
9310                odb,
9311                added.len(),
9312                par_opts,
9313                est_bytes,
9314                |ai| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
9315                    Ok(added_contents[ai]
9316                        .as_ref()
9317                        .map(|d| RenameLineTable::from_blob(d)))
9318                },
9319            )
9320            .expect("copy added line tables");
9321
9322            let score_rows: Vec<Vec<(u32, usize, usize)>> = parallel_map_indices(
9323                odb,
9324                sources.len(),
9325                par_opts,
9326                est_bytes,
9327                |si| -> std::result::Result<Vec<(u32, usize, usize)>, std::convert::Infallible> {
9328                    if renamed_deleted.contains(&si) {
9329                        return Ok(Vec::new());
9330                    }
9331                    let (src_path, _, _) = &sources[si];
9332                    let mut row: Vec<(u32, usize, usize)> = Vec::new();
9333                    for &ai in &remaining_added {
9334                        let add = &added[ai];
9335                        if add.new_path.as_deref() == Some(src_path.as_str()) {
9336                            continue;
9337                        }
9338                        let score = match (&source_tables[si], &added_tables[ai]) {
9339                            (Some(old_table), Some(new_table)) => {
9340                                RenameLineTable::similarity_score(old_table, new_table)
9341                            }
9342                            _ => 0,
9343                        };
9344                        if score >= threshold {
9345                            row.push((score, si, ai));
9346                        }
9347                    }
9348                    Ok(row)
9349                },
9350            )
9351            .expect("copy score rows");
9352            let mut inexact_scores: Vec<(u32, usize, usize)> =
9353                score_rows.into_iter().flatten().collect();
9354            inexact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
9355            apply_copy_score_assignments(
9356                &inexact_scores,
9357                &sources,
9358                &added,
9359                &source_modes,
9360                &mut used_added2,
9361                &mut renamed_deleted,
9362                &mut result_entries,
9363            );
9364        }
9365    }
9366
9367    // Keep deleted entries that weren't consumed by a rename.
9368    for entry in deleted.into_iter() {
9369        if let Some(ref path) = entry.old_path {
9370            if let Some(&si) = deleted_source_idx.get(path) {
9371                if renamed_deleted.contains(&si) {
9372                    // This deletion was consumed by a rename; skip it.
9373                    continue;
9374                }
9375            }
9376        }
9377        result_entries.push(entry);
9378    }
9379
9380    let mut result = others;
9381    result.extend(result_entries);
9382    // Keep unmatched added entries.
9383    for (i, entry) in added.into_iter().enumerate() {
9384        if !used_added2[i] {
9385            result.push(entry);
9386        }
9387    }
9388
9389    // Like `git diff -C`, only created paths become copies: a modification is a copy
9390    // destination only after `-B` splits it into a deletion and an addition, which
9391    // `postprocess_diff_entries` does before calling this.
9392    let mut final_result = result;
9393    sort_entries_by_path(&mut final_result);
9394    Ok(final_result)
9395}
9396
9397/// Options for Git-style rename, copy, and break-rewrite post-processing on tree diffs.
9398#[derive(Debug, Clone, Copy)]
9399pub struct DiffPostprocessOptions {
9400    /// When set, `--break-rewrites` threshold on Git's internal 0–[`GIT_DIFF_MAX_SCORE`] scale
9401    /// (`None` = off).
9402    pub break_score: Option<u64>,
9403    /// `-M` similarity threshold (0–100); `100` disables rename detection.
9404    pub rename_threshold: u32,
9405    /// `-C` copy threshold (0–100); `None` skips copy detection.
9406    pub copy_threshold: Option<u32>,
9407    /// `-C --find-copies-harder`: treat unmodified source-tree paths as copy sources.
9408    pub find_copies_harder: bool,
9409    /// When break-rewrite runs on tree↔worktree diffs, fill `new_oid` from worktree bytes for
9410    /// `--raw` output (matches `git diff <tree> -B`; leave false for index↔worktree / diff-files).
9411    pub materialize_break_worktree_new_oid: bool,
9412}
9413
9414impl Default for DiffPostprocessOptions {
9415    fn default() -> Self {
9416        Self {
9417            break_score: None,
9418            rename_threshold: 100,
9419            copy_threshold: None,
9420            find_copies_harder: false,
9421            materialize_break_worktree_new_oid: false,
9422        }
9423    }
9424}
9425
9426/// Index and git directory used to read canonical (clean) worktree bytes during break-rewrite
9427/// post-processing on tree↔worktree diffs.
9428#[derive(Debug, Clone, Copy)]
9429pub struct DiffPostprocessWorktree<'a> {
9430    /// Git directory for config and attributes (linked worktrees / gitfiles).
9431    pub repository_git_dir: &'a Path,
9432    /// Current index; stage-0 entries supply autocrlf index-guard blobs when needed.
9433    pub index: &'a Index,
9434}
9435
9436fn blob_bytes(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
9437    Ok(odb.read(oid)?.data)
9438}
9439
9440/// Bytes on the "new" side of a diff entry (ODB blob, or worktree file when `new_oid` is null).
9441///
9442/// When `worktree` is set, worktree paths are normalized with the same attribute/filter rules as
9443/// [`diff_tree_to_worktree`]; otherwise raw filesystem bytes are read.
9444fn read_new_side_bytes(
9445    odb: &Odb,
9446    entry: &DiffEntry,
9447    work_root: Option<&Path>,
9448    worktree: Option<&DiffPostprocessWorktree<'_>>,
9449    config: &ConfigSet,
9450) -> Result<Vec<u8>> {
9451    if entry.new_oid != zero_oid() {
9452        return blob_bytes(odb, &entry.new_oid);
9453    }
9454    let path = entry.new_path.as_deref().ok_or_else(|| {
9455        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
9456    })?;
9457    let root = work_root.ok_or_else(|| {
9458        Error::ObjectNotFound(format!(
9459            "worktree bytes required for {} but no work_root was provided",
9460            path
9461        ))
9462    })?;
9463    if let Some(wctx) = worktree {
9464        let mode = entry.new_mode.bits();
9465        let index_entry = wctx.index.get(path.as_bytes(), 0);
9466        return read_worktree_side_bytes_for_diff(odb, root, path, mode, config, index_entry, None);
9467    }
9468    fs::read(root.join(path)).map_err(Error::Io)
9469}
9470
9471/// True when clean-smudged worktree bytes differ from the raw on-disk file (e.g. `eol=lf` + CRLF).
9472fn worktree_side_differs_after_normalize(
9473    odb: &Odb,
9474    entry: &DiffEntry,
9475    work_root: &Path,
9476    worktree: &DiffPostprocessWorktree<'_>,
9477    config: &ConfigSet,
9478) -> Result<bool> {
9479    let path = entry.new_path.as_deref().ok_or_else(|| {
9480        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
9481    })?;
9482    let raw = fs::read(work_root.join(path)).map_err(Error::Io)?;
9483    let canonical = read_new_side_bytes(odb, entry, Some(work_root), Some(worktree), config)?;
9484    Ok(raw != canonical)
9485}
9486
9487fn split_broken_modifications(
9488    odb: &Odb,
9489    work_root: Option<&Path>,
9490    entries: Vec<DiffEntry>,
9491    break_score: u64,
9492    worktree: Option<&DiffPostprocessWorktree<'_>>,
9493    config: &ConfigSet,
9494) -> Result<Vec<DiffEntry>> {
9495    let mut out = Vec::with_capacity(entries.len());
9496    for e in entries {
9497        if e.status != DiffStatus::Modified {
9498            out.push(e);
9499            continue;
9500        }
9501        let old = blob_bytes(odb, &e.old_oid)?;
9502        let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
9503        if should_break_rewrite_pair(&old, &new, break_score) {
9504            let path = e.path().to_owned();
9505            out.push(DiffEntry {
9506                status: DiffStatus::Deleted,
9507                old_path: Some(path.clone()),
9508                new_path: None,
9509                old_mode: e.old_mode,
9510                new_mode: FileMode::ABSENT,
9511                old_oid: e.old_oid,
9512                new_oid: zero_oid(),
9513                score: None,
9514            });
9515            out.push(DiffEntry {
9516                status: DiffStatus::Added,
9517                old_path: None,
9518                new_path: Some(path),
9519                old_mode: FileMode::ABSENT,
9520                new_mode: e.new_mode,
9521                old_oid: zero_oid(),
9522                new_oid: e.new_oid,
9523                score: None,
9524            });
9525        } else {
9526            out.push(e);
9527        }
9528    }
9529    Ok(out)
9530}
9531
9532fn annotate_broken_modifications(
9533    odb: &Odb,
9534    work_root: Option<&Path>,
9535    entries: Vec<DiffEntry>,
9536    break_score: u64,
9537    worktree: Option<&DiffPostprocessWorktree<'_>>,
9538    config: &ConfigSet,
9539    materialize_worktree_oid: bool,
9540) -> Result<Vec<DiffEntry>> {
9541    let mut out = Vec::with_capacity(entries.len());
9542    for mut e in entries {
9543        if e.status == DiffStatus::Modified {
9544            let old = blob_bytes(odb, &e.old_oid)?;
9545            let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
9546            if should_break_rewrite_pair(&old, &new, break_score) {
9547                // `git diff-files -B` keeps plain `M` when the worktree side is still null.
9548                // `git diff <tree> -B` materializes the clean OID; `M###` appears only when
9549                // attributes/filters changed the bytes used for break scoring (e.g. `eol=lf`).
9550                let show_score = if e.new_oid != zero_oid() {
9551                    true
9552                } else if materialize_worktree_oid {
9553                    match (work_root, worktree) {
9554                        (Some(root), Some(wctx)) => {
9555                            worktree_side_differs_after_normalize(odb, &e, root, wctx, config)?
9556                        }
9557                        _ => false,
9558                    }
9559                } else {
9560                    false
9561                };
9562                if show_score {
9563                    e.score = rewrite_dissimilarity_index_percent(&old, &new);
9564                }
9565            }
9566        }
9567        out.push(e);
9568    }
9569    Ok(out)
9570}
9571
9572fn materialize_break_worktree_new_oids(
9573    odb: &Odb,
9574    work_root: Option<&Path>,
9575    entries: &mut [DiffEntry],
9576    worktree: Option<&DiffPostprocessWorktree<'_>>,
9577    config: &ConfigSet,
9578) -> Result<()> {
9579    for e in entries {
9580        if e.new_oid != zero_oid() {
9581            continue;
9582        }
9583        if !matches!(e.status, DiffStatus::Modified | DiffStatus::Added) {
9584            continue;
9585        }
9586        let bytes = read_new_side_bytes(odb, e, work_root, worktree, config)?;
9587        e.new_oid = odb.hash(ObjectKind::Blob, &bytes)?;
9588    }
9589    Ok(())
9590}
9591
9592/// After break-rewrite splits a modified path into delete+add, rename detection may pair the add
9593/// with another deletion (`g` → `f`). Git drops the unused delete at the rename target path.
9594fn prune_break_deletions_obviated_by_renames(entries: Vec<DiffEntry>) -> Vec<DiffEntry> {
9595    use std::collections::HashSet;
9596    let rename_targets: HashSet<String> = entries
9597        .iter()
9598        .filter(|e| e.status == DiffStatus::Renamed)
9599        .filter_map(|e| e.new_path.clone())
9600        .collect();
9601    if rename_targets.is_empty() {
9602        return entries;
9603    }
9604    entries
9605        .into_iter()
9606        .filter(|e| {
9607            if e.status != DiffStatus::Deleted {
9608                return true;
9609            }
9610            e.old_path
9611                .as_ref()
9612                .is_none_or(|p| !rename_targets.contains(p))
9613        })
9614        .collect()
9615}
9616
9617/// Apply break-rewrite, rename, and copy detection to a flat tree or index diff.
9618///
9619/// `source_tree_oid` supplies `--find-copies-harder` candidates; `work_root` is used when added
9620/// entries carry a zero OID (index↔worktree). Pass `worktree` when break-rewrite runs on a
9621/// tree↔worktree diff so worktree-side bytes honor attributes and filters.
9622///
9623/// # Errors
9624///
9625/// Propagates object read failures while evaluating break scores.
9626#[allow(clippy::too_many_arguments)]
9627pub fn postprocess_diff_entries(
9628    odb: &Odb,
9629    entries: Vec<DiffEntry>,
9630    source_tree_oid: Option<&ObjectId>,
9631    work_root: Option<&Path>,
9632    worktree: Option<&DiffPostprocessWorktree<'_>>,
9633    config: &ConfigSet,
9634    thread_policy: &ThreadPolicy,
9635    opts: &DiffPostprocessOptions,
9636) -> Result<Vec<DiffEntry>> {
9637    let mut entries = entries;
9638    if let Some(break_score) = opts.break_score {
9639        let rename_or_copy = opts.rename_threshold < 100 || opts.copy_threshold.is_some();
9640        entries = if rename_or_copy {
9641            split_broken_modifications(odb, work_root, entries, break_score, worktree, config)?
9642        } else {
9643            annotate_broken_modifications(
9644                odb,
9645                work_root,
9646                entries,
9647                break_score,
9648                worktree,
9649                config,
9650                opts.materialize_break_worktree_new_oid,
9651            )?
9652        };
9653        if opts.materialize_break_worktree_new_oid {
9654            materialize_break_worktree_new_oids(odb, work_root, &mut entries, worktree, config)?;
9655        }
9656    }
9657
9658    let source_tree_entries = source_tree_oid
9659        .map(|t| flatten_tree_entries(odb, t))
9660        .transpose()?
9661        .unwrap_or_default();
9662
9663    if opts.copy_threshold.is_some() || opts.find_copies_harder {
9664        let threshold = opts.copy_threshold.unwrap_or(opts.rename_threshold.min(99));
9665        detect_copies_with_policy(
9666            odb,
9667            work_root,
9668            entries,
9669            threshold,
9670            opts.find_copies_harder,
9671            &source_tree_entries,
9672            config,
9673            thread_policy,
9674        )
9675    } else if opts.rename_threshold < 100 {
9676        detect_renames_with_policy(
9677            odb,
9678            work_root,
9679            entries,
9680            opts.rename_threshold,
9681            config,
9682            thread_policy,
9683            None,
9684        )
9685    } else {
9686        Ok(entries)
9687    }
9688}
9689
9690/// Depth-first list of `(path, mode, oid)` blob entries in a tree (for copy detection).
9691pub fn flatten_tree_entries(
9692    odb: &Odb,
9693    tree_oid: &ObjectId,
9694) -> Result<Vec<(String, FileMode, ObjectId)>> {
9695    fn walk(
9696        odb: &Odb,
9697        tree_oid: &ObjectId,
9698        prefix: &str,
9699        out: &mut Vec<(String, FileMode, ObjectId)>,
9700    ) -> Result<()> {
9701        let obj = odb.read(tree_oid)?;
9702        let oid_len = odb.hash_algo().len();
9703        for e in TreeRefIter::with_oid_len(&obj.data, oid_len) {
9704            let e = e?;
9705            let name = String::from_utf8_lossy(e.name);
9706            let path = if prefix.is_empty() {
9707                name.to_string()
9708            } else {
9709                format!("{prefix}/{name}")
9710            };
9711            if e.mode == 0o040000 {
9712                walk(odb, &e.oid, &path, out)?;
9713            } else {
9714                out.push((path, FileMode::new(e.mode), e.oid));
9715            }
9716        }
9717        Ok(())
9718    }
9719    let mut out = Vec::new();
9720    walk(odb, tree_oid, "", &mut out)?;
9721    Ok(out)
9722}
9723
9724/// Apply Git-style rename and optional copy detection for index↔worktree diffs.
9725///
9726/// When `copies` is true (Git `diff.renames` / `status.renames` set to `copy`/`copies`),
9727/// runs copy detection, which also pairs deleted sources with one rename and any additional
9728/// destinations as copies.
9729///
9730/// # Errors
9731///
9732/// Propagates errors from reading the `head_tree` object from `odb`.
9733#[allow(clippy::too_many_arguments)]
9734pub fn status_apply_rename_copy_detection(
9735    odb: &Odb,
9736    unstaged_raw: Vec<DiffEntry>,
9737    threshold: u32,
9738    copies: bool,
9739    head_tree: Option<&ObjectId>,
9740    config: &ConfigSet,
9741    thread_policy: &ThreadPolicy,
9742    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
9743) -> Result<Vec<DiffEntry>> {
9744    if !copies {
9745        return detect_renames_with_policy(
9746            odb,
9747            None,
9748            unstaged_raw,
9749            threshold,
9750            config,
9751            thread_policy,
9752            cancel_watch,
9753        );
9754    }
9755    let source_tree_entries: Vec<(String, FileMode, ObjectId)> = match head_tree {
9756        Some(oid) => flatten_tree(odb, oid, "")?
9757            .into_iter()
9758            .map(|e| (e.path, FileMode::new(e.mode), e.oid))
9759            .collect(),
9760        None => Vec::new(),
9761    };
9762    detect_copies_with_policy(
9763        odb,
9764        None,
9765        unstaged_raw,
9766        threshold,
9767        false,
9768        &source_tree_entries,
9769        config,
9770        thread_policy,
9771    )
9772}
9773
9774/// Check if two entries share the same filename (basename).
9775fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
9776    let old = del.old_path.as_deref().unwrap_or("");
9777    let new = add.new_path.as_deref().unwrap_or("");
9778    let old_base = old.rsplit('/').next().unwrap_or(old);
9779    let new_base = new.rsplit('/').next().unwrap_or(new);
9780    old_base == new_base && !old_base.is_empty()
9781}
9782
9783const RENAME_SCORE_MAX: u32 = 100;
9784
9785/// Uncompressed blob size without inflating object bodies (for rename pre-filtering).
9786fn blob_payload_size(odb: &Odb, oid: &ObjectId) -> Option<usize> {
9787    if oid.is_zero() {
9788        return None;
9789    }
9790    odb.read_info(oid).ok().map(|info| info.size as usize)
9791}
9792
9793/// Payload size for an added diff side (ODB blob or worktree bytes when not stored yet).
9794fn added_entry_payload_size(odb: &Odb, add: &DiffEntry, work_root: Option<&Path>) -> Option<usize> {
9795    if !add.new_oid.is_zero() {
9796        if let Some(size) = blob_payload_size(odb, &add.new_oid) {
9797            return Some(size);
9798        }
9799    }
9800    read_added_entry_bytes(odb, add, work_root).map(|data| data.len())
9801}
9802
9803/// Git `diffcore-delta` size gate: skip pairs that cannot reach `minimum_score`.
9804fn rename_pair_size_reachable(src_size: usize, dst_size: usize, minimum_score: u32) -> bool {
9805    let max_size = src_size.max(dst_size);
9806    let min_size = src_size.min(dst_size);
9807    if max_size == 0 {
9808        return minimum_score <= RENAME_SCORE_MAX;
9809    }
9810    (max_size - min_size).saturating_mul(RENAME_SCORE_MAX as usize)
9811        <= max_size.saturating_mul((RENAME_SCORE_MAX - minimum_score) as usize)
9812}
9813
9814/// Compute a similarity percentage (0–100) between two byte slices.
9815///
9816/// Matches Git rename detection: span-hash intersection (`hash_chars` in diffcore-delta),
9817/// `score = copied_bytes * 100 / max(src_size, dst_size)` on raw blob sizes.
9818fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
9819    RenameLineTable::similarity_score(
9820        &RenameLineTable::from_blob(old),
9821        &RenameLineTable::from_blob(new),
9822    )
9823}
9824
9825/// Compute rename/copy similarity percentage (0–100) between two byte slices.
9826///
9827/// Uses Git `diffcore-delta` span hashing (`hash_chars`): 64-byte or newline-bounded
9828/// chunks, CR skipped before LF in text blobs, `score = copied * 100 / max(len)`.
9829#[must_use]
9830pub fn rename_similarity_score(old: &[u8], new: &[u8]) -> u32 {
9831    compute_similarity(old, new)
9832}
9833
9834// ── Output formatting ───────────────────────────────────────────────
9835
9836/// Format a diff entry in Git's raw diff format.
9837///
9838/// Example: `:100644 100644 abc1234... def5678... M\tfile.txt`
9839pub fn format_raw(entry: &DiffEntry) -> String {
9840    let path = match entry.status {
9841        DiffStatus::Renamed | DiffStatus::Copied => {
9842            format!(
9843                "{}\t{}",
9844                entry.old_path.as_deref().unwrap_or(""),
9845                entry.new_path.as_deref().unwrap_or("")
9846            )
9847        }
9848        _ => entry.path().to_owned(),
9849    };
9850
9851    let status_str = match (entry.status, entry.score) {
9852        (DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
9853        (DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
9854        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
9855        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
9856        _ => entry.status.letter().to_string(),
9857    };
9858
9859    let (old_hex, new_hex) = raw_oid_hex_pair(&entry.old_oid, &entry.new_oid);
9860    format!(
9861        ":{} {} {} {} {}\t{}",
9862        entry.old_mode, entry.new_mode, old_hex, new_hex, status_str, path
9863    )
9864}
9865
9866/// Render a diff entry's `(old, new)` OIDs as full hex for `--raw` output,
9867/// widening any null OID to the repository's hash width.
9868///
9869/// Diff entries store null OIDs at SHA-1 width regardless of the repository
9870/// algorithm, but `git diff --raw` prints them at the real hash width (64
9871/// zeros in a SHA-256 repo). A diff entry never has both sides null, so the
9872/// width is taken from whichever side carries a real object.
9873fn raw_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
9874    let width = if !old.is_zero() {
9875        old.algo().hex_len()
9876    } else if !new.is_zero() {
9877        new.algo().hex_len()
9878    } else {
9879        old.algo().hex_len()
9880    };
9881    let render = |oid: &ObjectId| {
9882        if oid.is_zero() {
9883            "0".repeat(width)
9884        } else {
9885            oid.to_hex()
9886        }
9887    };
9888    (render(old), render(new))
9889}
9890
9891/// Format a diff entry with abbreviated OIDs.
9892pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
9893    let ellipsis = if crate::environment::Environment::empty()
9894        .var("GIT_PRINT_SHA1_ELLIPSIS")
9895        .as_deref()
9896        == Some("yes")
9897    {
9898        "..."
9899    } else {
9900        ""
9901    };
9902    let old_hex = format!("{}", entry.old_oid);
9903    let new_hex = format!("{}", entry.new_oid);
9904    let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
9905    let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
9906
9907    // Renames/copies carry a similarity score and a `<old>\t<new>` path pair.
9908    let path = match entry.status {
9909        DiffStatus::Renamed | DiffStatus::Copied => format!(
9910            "{}\t{}",
9911            entry.old_path.as_deref().unwrap_or(""),
9912            entry.new_path.as_deref().unwrap_or("")
9913        ),
9914        _ => entry.path().to_owned(),
9915    };
9916    let status_str = match (entry.status, entry.score) {
9917        (DiffStatus::Renamed, Some(s)) => format!("R{s:03}"),
9918        (DiffStatus::Copied, Some(s)) => format!("C{s:03}"),
9919        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
9920        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
9921        _ => entry.status.letter().to_string(),
9922    };
9923
9924    format!(
9925        ":{} {} {}{} {}{} {}\t{}",
9926        entry.old_mode,
9927        entry.new_mode,
9928        old_abbrev,
9929        ellipsis,
9930        new_abbrev,
9931        ellipsis,
9932        status_str,
9933        path
9934    )
9935}
9936
9937/// Generate a unified diff patch for two blobs.
9938///
9939/// # Parameters
9940///
9941/// - `old_content` — the old file content (empty for added files).
9942/// - `new_content` — the new file content (empty for deleted files).
9943/// - `old_path` — display path for the old side.
9944/// - `new_path` — display path for the new side.
9945/// - `context_lines` — number of context lines around changes (default: 3).
9946/// - Inter-hunk context defaults to `0` (see [`unified_diff_with_prefix`]).
9947///
9948/// # Returns
9949///
9950/// The unified diff as a string.
9951pub fn unified_diff(
9952    old_content: &str,
9953    new_content: &str,
9954    old_path: &str,
9955    new_path: &str,
9956    context_lines: usize,
9957    indent_heuristic: bool,
9958    quote_path_fully: bool,
9959) -> String {
9960    unified_diff_with_prefix(
9961        old_content,
9962        new_content,
9963        old_path,
9964        new_path,
9965        context_lines,
9966        0,
9967        "a/",
9968        "b/",
9969        indent_heuristic,
9970        quote_path_fully,
9971    )
9972}
9973
9974/// Same as `unified_diff` but with configurable source/destination prefixes.
9975///
9976/// `inter_hunk_context` is Git's `--inter-hunk-context`: adjacent hunks merge when
9977/// the unchanged gap between them is at most `2 * context_lines + inter_hunk_context` lines.
9978#[allow(clippy::too_many_arguments)] // Mirrors Git-style unified diff parameters.
9979pub fn unified_diff_with_prefix(
9980    old_content: &str,
9981    new_content: &str,
9982    old_path: &str,
9983    new_path: &str,
9984    context_lines: usize,
9985    inter_hunk_context: usize,
9986    src_prefix: &str,
9987    dst_prefix: &str,
9988    indent_heuristic: bool,
9989    quote_path_fully: bool,
9990) -> String {
9991    unified_diff_with_prefix_and_funcname(
9992        old_content,
9993        new_content,
9994        old_path,
9995        new_path,
9996        context_lines,
9997        inter_hunk_context,
9998        src_prefix,
9999        dst_prefix,
10000        None,
10001        indent_heuristic,
10002        quote_path_fully,
10003    )
10004}
10005
10006/// Same as [`unified_diff_with_prefix`] with optional custom hunk-header
10007/// function-name matching.
10008#[allow(clippy::too_many_arguments)]
10009pub fn unified_diff_with_prefix_and_funcname(
10010    old_content: &str,
10011    new_content: &str,
10012    old_path: &str,
10013    new_path: &str,
10014    context_lines: usize,
10015    inter_hunk_context: usize,
10016    src_prefix: &str,
10017    dst_prefix: &str,
10018    funcname_matcher: Option<&FuncnameMatcher>,
10019    indent_heuristic: bool,
10020    quote_path_fully: bool,
10021) -> String {
10022    unified_diff_with_prefix_and_funcname_and_algorithm(
10023        old_content,
10024        new_content,
10025        old_path,
10026        new_path,
10027        context_lines,
10028        inter_hunk_context,
10029        src_prefix,
10030        dst_prefix,
10031        funcname_matcher,
10032        similar::Algorithm::Myers,
10033        false,
10034        false,
10035        indent_heuristic,
10036        quote_path_fully,
10037    )
10038}
10039
10040/// Same as [`unified_diff_with_prefix_and_funcname_and_algorithm`], streaming to `out`.
10041///
10042/// # Errors
10043///
10044/// Returns I/O errors from `out`.
10045#[allow(clippy::too_many_arguments)]
10046pub fn write_unified_diff_with_prefix_and_funcname_and_algorithm<W: std::io::Write>(
10047    out: &mut W,
10048    old_content: &str,
10049    new_content: &str,
10050    old_path: &str,
10051    new_path: &str,
10052    context_lines: usize,
10053    inter_hunk_context: usize,
10054    src_prefix: &str,
10055    dst_prefix: &str,
10056    funcname_matcher: Option<&FuncnameMatcher>,
10057    algorithm: similar::Algorithm,
10058    function_context: bool,
10059    use_git_histogram: bool,
10060    indent_heuristic: bool,
10061    quote_path_fully: bool,
10062) -> Result<()> {
10063    if function_context {
10064        let patch = unified_diff_with_function_context(
10065            old_content,
10066            new_content,
10067            old_path,
10068            new_path,
10069            context_lines,
10070            inter_hunk_context,
10071            src_prefix,
10072            dst_prefix,
10073            funcname_matcher,
10074            algorithm,
10075            indent_heuristic,
10076            quote_path_fully,
10077        );
10078        out.write_all(patch.as_bytes())?;
10079        return Ok(());
10080    }
10081
10082    if use_git_histogram {
10083        let patch = unified_diff_histogram_with_prefix_and_funcname_indent(
10084            old_content,
10085            new_content,
10086            old_path,
10087            new_path,
10088            context_lines,
10089            inter_hunk_context,
10090            src_prefix,
10091            dst_prefix,
10092            funcname_matcher,
10093            indent_heuristic,
10094            quote_path_fully,
10095        );
10096        out.write_all(patch.as_bytes())?;
10097        return Ok(());
10098    }
10099
10100    if algorithm == similar::Algorithm::Myers && !function_context {
10101        let patch = unified_diff_git_myers_with_prefix_and_funcname(
10102            old_content,
10103            new_content,
10104            old_path,
10105            new_path,
10106            context_lines,
10107            inter_hunk_context,
10108            src_prefix,
10109            dst_prefix,
10110            funcname_matcher,
10111            indent_heuristic,
10112            quote_path_fully,
10113        );
10114        out.write_all(patch.as_bytes())?;
10115        return Ok(());
10116    }
10117
10118    if algorithm == similar::Algorithm::Lcs && !function_context {
10119        let patch = unified_diff_git_minimal_with_prefix_and_funcname(
10120            old_content,
10121            new_content,
10122            old_path,
10123            new_path,
10124            context_lines,
10125            inter_hunk_context,
10126            src_prefix,
10127            dst_prefix,
10128            funcname_matcher,
10129            indent_heuristic,
10130            quote_path_fully,
10131        );
10132        out.write_all(patch.as_bytes())?;
10133        return Ok(());
10134    }
10135
10136    if algorithm == similar::Algorithm::Patience && !function_context {
10137        let patch = unified_diff_git_patience_with_prefix_and_funcname(
10138            old_content,
10139            new_content,
10140            old_path,
10141            new_path,
10142            context_lines,
10143            inter_hunk_context,
10144            src_prefix,
10145            dst_prefix,
10146            funcname_matcher,
10147            indent_heuristic,
10148            quote_path_fully,
10149        );
10150        out.write_all(patch.as_bytes())?;
10151        return Ok(());
10152    }
10153
10154    use crate::quote_path::format_diff_path_with_prefix;
10155    use similar::{udiff::UnifiedDiffHunk, TextDiff};
10156
10157    let diff = TextDiff::configure()
10158        .algorithm(algorithm)
10159        .diff_lines(old_content, new_content);
10160    let compacted_ops = diff_indent_heuristic::diff_lines_ops_compacted(
10161        old_content,
10162        new_content,
10163        algorithm,
10164        indent_heuristic,
10165    );
10166
10167    if old_path == "/dev/null" {
10168        out.write_all(b"--- /dev/null\n")?;
10169    } else if src_prefix.is_empty() {
10170        writeln!(out, "--- {old_path}")?;
10171    } else {
10172        out.write_all(b"--- ")?;
10173        out.write_all(
10174            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
10175        )?;
10176        out.write_all(b"\n")?;
10177    }
10178    if new_path == "/dev/null" {
10179        out.write_all(b"+++ /dev/null\n")?;
10180    } else if dst_prefix.is_empty() {
10181        writeln!(out, "+++ {new_path}")?;
10182    } else {
10183        out.write_all(b"+++ ")?;
10184        out.write_all(
10185            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
10186        )?;
10187        out.write_all(b"\n")?;
10188    }
10189
10190    let old_lines: Vec<&str> = old_content.lines().collect();
10191    let max_common_gap = context_lines
10192        .saturating_mul(2)
10193        .saturating_add(inter_hunk_context);
10194    let op_groups = group_diff_ops_gap(compacted_ops, context_lines, max_common_gap);
10195
10196    let mut hunk_buf = String::new();
10197    let mut eof_annot = UnifiedDiffNoNewlineAnnotator::new(old_content, new_content);
10198    for ops in op_groups {
10199        if ops.is_empty() {
10200            continue;
10201        }
10202        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
10203        hunk_buf.clear();
10204        use std::fmt::Write as FmtWrite;
10205        let _ = FmtWrite::write_fmt(&mut hunk_buf, format_args!("{hunk}"));
10206        let mut fragment = hunk_buf.clone();
10207        if let Some(first_newline) = hunk_buf.find('\n') {
10208            let header_line = &hunk_buf[..first_newline];
10209            let rest = &hunk_buf[first_newline..];
10210            if let Some(func_ctx) =
10211                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
10212            {
10213                fragment.clear();
10214                fragment.push_str(header_line);
10215                fragment.push(' ');
10216                fragment.push_str(&func_ctx);
10217                fragment.push_str(rest);
10218            }
10219        }
10220        eof_annot.write_hunk(out, &fragment)?;
10221    }
10222    Ok(())
10223}
10224
10225/// Same as [`unified_diff_with_prefix_and_funcname`] but allows callers to
10226/// choose the line diff algorithm used for hunk generation.
10227///
10228/// When `function_context` is true (`git diff -W`), hunks are expanded to
10229/// whole logical functions using the same rules as Git's `XDL_EMIT_FUNCCONTEXT`.
10230#[allow(clippy::too_many_arguments)]
10231pub fn unified_diff_with_prefix_and_funcname_and_algorithm(
10232    old_content: &str,
10233    new_content: &str,
10234    old_path: &str,
10235    new_path: &str,
10236    context_lines: usize,
10237    inter_hunk_context: usize,
10238    src_prefix: &str,
10239    dst_prefix: &str,
10240    funcname_matcher: Option<&FuncnameMatcher>,
10241    algorithm: similar::Algorithm,
10242    function_context: bool,
10243    use_git_histogram: bool,
10244    indent_heuristic: bool,
10245    quote_path_fully: bool,
10246) -> String {
10247    let mut output = Vec::new();
10248    if write_unified_diff_with_prefix_and_funcname_and_algorithm(
10249        &mut output,
10250        old_content,
10251        new_content,
10252        old_path,
10253        new_path,
10254        context_lines,
10255        inter_hunk_context,
10256        src_prefix,
10257        dst_prefix,
10258        funcname_matcher,
10259        algorithm,
10260        function_context,
10261        use_git_histogram,
10262        indent_heuristic,
10263        quote_path_fully,
10264    )
10265    .is_err()
10266    {
10267        output.clear();
10268    }
10269    String::from_utf8_lossy(&output).into_owned()
10270}
10271
10272/// Group diff ops into hunks like Git's `xdl_get_hunk`: two changes merge into one hunk while
10273/// the run of unchanged lines between them is at most `max_common_gap`
10274/// (`2 * context + inter_hunk_context`), and each hunk keeps at most `context` unchanged lines
10275/// at its edges. Unlike `similar::group_diff_ops`, the split threshold is decoupled from the
10276/// edge context so odd gap limits group exactly like Git.
10277pub(crate) fn group_diff_ops_gap(
10278    mut ops: Vec<similar::DiffOp>,
10279    context: usize,
10280    max_common_gap: usize,
10281) -> Vec<Vec<similar::DiffOp>> {
10282    use similar::DiffOp;
10283    if ops.is_empty() {
10284        return vec![];
10285    }
10286
10287    let mut pending_group = Vec::new();
10288    let mut rv = Vec::new();
10289
10290    if let Some(DiffOp::Equal {
10291        old_index,
10292        new_index,
10293        len,
10294    }) = ops.first_mut()
10295    {
10296        let offset = (*len).saturating_sub(context);
10297        *old_index += offset;
10298        *new_index += offset;
10299        *len -= offset;
10300    }
10301
10302    if let Some(DiffOp::Equal { len, .. }) = ops.last_mut() {
10303        *len -= (*len).saturating_sub(context);
10304    }
10305
10306    for op in ops.into_iter() {
10307        if let DiffOp::Equal {
10308            old_index,
10309            new_index,
10310            len,
10311        } = op
10312        {
10313            // End the current group and start a new one whenever the unchanged
10314            // run is too long to fuse the surrounding changes.
10315            if len > max_common_gap {
10316                pending_group.push(DiffOp::Equal {
10317                    old_index,
10318                    new_index,
10319                    len: context,
10320                });
10321                rv.push(pending_group);
10322                let offset = len.saturating_sub(context);
10323                pending_group = vec![DiffOp::Equal {
10324                    old_index: old_index + offset,
10325                    new_index: new_index + offset,
10326                    len: len - offset,
10327                }];
10328                continue;
10329            }
10330        }
10331        pending_group.push(op);
10332    }
10333
10334    match &pending_group[..] {
10335        &[] | &[similar::DiffOp::Equal { .. }] => {}
10336        _ => rv.push(pending_group),
10337    }
10338
10339    rv
10340}
10341#[expect(clippy::too_many_arguments)]
10342/// `git diff -W`: expand each hunk to include full function bodies (see Git `xemit.c`).
10343fn unified_diff_with_function_context(
10344    old_content: &str,
10345    new_content: &str,
10346    old_path: &str,
10347    new_path: &str,
10348    context_lines: usize,
10349    inter_hunk_context: usize,
10350    src_prefix: &str,
10351    dst_prefix: &str,
10352    funcname_matcher: Option<&FuncnameMatcher>,
10353    algorithm: similar::Algorithm,
10354    indent_heuristic: bool,
10355    quote_path_fully: bool,
10356) -> String {
10357    use crate::quote_path::format_diff_path_with_prefix;
10358    use similar::{udiff::UnifiedDiffHunk, TextDiff};
10359
10360    let diff = TextDiff::configure()
10361        .algorithm(algorithm)
10362        .diff_lines(old_content, new_content);
10363
10364    let old_lines: Vec<&str> = old_content.lines().collect();
10365    let new_lines: Vec<&str> = new_content.lines().collect();
10366    let n_old = old_lines.len();
10367    let n_new = new_lines.len();
10368
10369    // Group changes the way Git's xdl_get_hunk does: merge while the unchanged
10370    // gap is at most `2*context + inter_hunk_context`. `similar::group_diff_ops`
10371    // splits only at gap > `2*radius`, which over-merges changes in *different*
10372    // functions (e.g. a leading insertion and a body change) into one hunk and
10373    // then over-expands the function context (t4015 #136). Use the gap-correct
10374    // grouping (same as the non-function-context path).
10375    let max_common_gap = context_lines
10376        .saturating_mul(2)
10377        .saturating_add(inter_hunk_context);
10378    let all_ops = diff.ops().to_vec();
10379    let op_groups = group_diff_ops_gap(all_ops.clone(), context_lines, max_common_gap);
10380
10381    let mut ranges: Vec<(usize, usize, usize, usize)> = Vec::new();
10382
10383    for ops in op_groups {
10384        if ops.is_empty() {
10385            continue;
10386        }
10387        let i1_anchor = func_context_old_anchor(&ops, n_old);
10388        let i1_end = hunk_old_change_end_exclusive(&ops);
10389        let skip_preimage_pull =
10390            append_with_whole_function_added(&ops, n_old, n_new, &new_lines, funcname_matcher);
10391        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
10392        let hunk_str = format!("{hunk}");
10393        let header_line = hunk_str
10394            .lines()
10395            .next()
10396            .unwrap_or("")
10397            .trim_end_matches(['\r', '\n']);
10398        let Some((base_s1, _base_e1, _base_s2, _base_e2)) =
10399            parse_unified_hunk_header_ranges(header_line)
10400        else {
10401            continue;
10402        };
10403
10404        let ctx = context_lines;
10405        let (s1, e1, s2, e2) = if skip_preimage_pull {
10406            let s = n_old.saturating_sub(ctx);
10407            let s2 = map_old_line_to_new(&all_ops, s, n_new).min(n_new);
10408            (s, n_old, s2, n_new)
10409        } else {
10410            let mut s1 = base_s1.saturating_sub(ctx);
10411            let mut s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
10412
10413            let base_pre_s1 = i1_anchor.saturating_sub(ctx);
10414            if base_pre_s1 < s1 {
10415                s1 = base_pre_s1;
10416                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
10417            }
10418
10419            let fs1 = expand_func_pre_start(s1, i1_anchor, n_old, &old_lines, funcname_matcher);
10420            if fs1 < s1 {
10421                s1 = fs1;
10422                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
10423            }
10424
10425            // `i1_end` is the exclusive end of the changed region; its post-image
10426            // context is `ctx` lines (Git's `xche->i1 + xche->chg1 + lctx`). The
10427            // hunk's `base_e1` already includes the group's trailing context, so
10428            // use the change end + ctx directly to avoid double-counting context
10429            // (which over-extended the hunk and merged separate functions — t4015 #136).
10430            let mut e1 = (i1_end + ctx).min(n_old);
10431            let mut e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
10432            let fe1 = expand_func_post_end(e1, i1_end, n_old, &old_lines, funcname_matcher);
10433            if fe1 > e1 {
10434                e1 = fe1;
10435                e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
10436            }
10437            (s1, e1, s2, e2)
10438        };
10439
10440        ranges.push((s1, e1, s2, e2));
10441    }
10442
10443    // Merge ranges whose function-context expansion made them overlap on the old
10444    // side (Git emits a single hunk in that case).
10445    ranges.sort_by_key(|r| (r.0, r.2));
10446    let mut merged: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(ranges.len());
10447    for (s1, e1, s2, e2) in ranges {
10448        if let Some(last) = merged.last_mut() {
10449            if s1 < last.1 {
10450                last.1 = last.1.max(e1);
10451                last.3 = last.3.max(e2);
10452                continue;
10453            }
10454        }
10455        merged.push((s1, e1, s2, e2));
10456    }
10457    let ranges = merged;
10458
10459    let mut output = String::new();
10460    if old_path == "/dev/null" {
10461        output.push_str("--- /dev/null\n");
10462    } else if src_prefix.is_empty() {
10463        output.push_str(&format!("--- {old_path}\n"));
10464    } else {
10465        output.push_str("--- ");
10466        output.push_str(&format_diff_path_with_prefix(
10467            src_prefix,
10468            old_path,
10469            quote_path_fully,
10470        ));
10471        output.push('\n');
10472    }
10473    if new_path == "/dev/null" {
10474        output.push_str("+++ /dev/null\n");
10475    } else if dst_prefix.is_empty() {
10476        output.push_str(&format!("+++ {new_path}\n"));
10477    } else {
10478        output.push_str("+++ ");
10479        output.push_str(&format_diff_path_with_prefix(
10480            dst_prefix,
10481            new_path,
10482            quote_path_fully,
10483        ));
10484        output.push('\n');
10485    }
10486
10487    for (s1, e1, s2, e2) in ranges {
10488        if s1 >= e1 && s2 >= e2 {
10489            continue;
10490        }
10491        let old_seg =
10492            line_slice_for_diff_with_eof_nl(&old_lines, s1, e1, old_content.ends_with('\n'));
10493        let new_seg =
10494            line_slice_for_diff_with_eof_nl(&new_lines, s2, e2, new_content.ends_with('\n'));
10495        let inner_ctx = old_seg.lines().count().max(new_seg.lines().count()).max(1);
10496        let piece = unified_diff_with_prefix_and_funcname_and_algorithm(
10497            &old_seg,
10498            &new_seg,
10499            old_path,
10500            new_path,
10501            inner_ctx,
10502            0,
10503            src_prefix,
10504            dst_prefix,
10505            funcname_matcher,
10506            algorithm,
10507            false,
10508            false,
10509            indent_heuristic,
10510            quote_path_fully,
10511        );
10512        let shifted = shift_unified_hunk_headers_to_full_file(&piece, s1, s2);
10513        let with_func =
10514            enrich_unified_hunk_headers_funcname(&shifted, &old_lines, funcname_matcher);
10515        for line in with_func.lines() {
10516            if line.starts_with("--- ") || line.starts_with("+++ ") {
10517                continue;
10518            }
10519            output.push_str(line);
10520            output.push('\n');
10521        }
10522    }
10523
10524    output
10525}
10526
10527/// `piece` is a unified diff for a slice of the file; hunk headers use 1-based
10528/// coordinates relative to that slice. Shift them by `delta_old` / `delta_new`
10529/// (0-based offsets of the slice in the full file) so the combined patch applies
10530/// to the whole file.
10531fn shift_unified_hunk_headers_to_full_file(
10532    patch: &str,
10533    delta_old: usize,
10534    delta_new: usize,
10535) -> String {
10536    if delta_old == 0 && delta_new == 0 {
10537        return patch.to_owned();
10538    }
10539    let mut out = String::with_capacity(patch.len());
10540    for line in patch.lines() {
10541        if let Some(shifted) = shift_one_unified_hunk_header(line, delta_old, delta_new) {
10542            out.push_str(&shifted);
10543        } else {
10544            out.push_str(line);
10545        }
10546        out.push('\n');
10547    }
10548    out
10549}
10550
10551fn shift_one_unified_hunk_header(line: &str, delta_old: usize, delta_new: usize) -> Option<String> {
10552    let rest = line.strip_prefix("@@ ")?;
10553    let (old_chunk, after_plus) = rest.split_once(" +")?;
10554    let old_spec = old_chunk.strip_prefix('-')?;
10555    let (new_spec, suffix) = after_plus.split_once(" @@")?;
10556    let shifted_old = shift_unified_range_spec(old_spec, delta_old)?;
10557    let shifted_new = shift_unified_range_spec(new_spec, delta_new)?;
10558    Some(format!("@@ -{shifted_old} +{shifted_new} @@{suffix}"))
10559}
10560
10561fn shift_unified_range_spec(spec: &str, delta: usize) -> Option<String> {
10562    let spec = spec.trim();
10563    if let Some((start_s, count_s)) = spec.split_once(',') {
10564        let start: usize = start_s.parse().ok()?;
10565        let count: usize = count_s.parse().ok()?;
10566        Some(format!("{},{}", start.saturating_add(delta), count))
10567    } else {
10568        let start: usize = spec.parse().ok()?;
10569        Some(format!("{}", start.saturating_add(delta)))
10570    }
10571}
10572
10573/// Re-attach `@@ ... @@ <funcname>` using full-file line indices (inner diffs use slices).
10574fn enrich_unified_hunk_headers_funcname(
10575    patch: &str,
10576    full_old_lines: &[&str],
10577    funcname_matcher: Option<&FuncnameMatcher>,
10578) -> String {
10579    let mut out = String::with_capacity(patch.len());
10580    for line in patch.lines() {
10581        if let Some(fixed) = enrich_one_hunk_header_funcname(line, full_old_lines, funcname_matcher)
10582        {
10583            out.push_str(&fixed);
10584        } else {
10585            out.push_str(line);
10586        }
10587        out.push('\n');
10588    }
10589    out
10590}
10591
10592fn enrich_one_hunk_header_funcname(
10593    line: &str,
10594    full_old_lines: &[&str],
10595    funcname_matcher: Option<&FuncnameMatcher>,
10596) -> Option<String> {
10597    let after_at = line.strip_prefix("@@ ")?;
10598    let idx = after_at.find(" @@")?;
10599    let mid = after_at[..idx].trim();
10600    let tail = after_at[idx + 3..].trim_start();
10601    let header_for_parse = format!("@@ {mid} @@");
10602    let func = extract_function_context(&header_for_parse, full_old_lines, funcname_matcher, None);
10603    Some(if let Some(f) = func {
10604        format!("@@ {mid} @@ {f}")
10605    } else if !tail.is_empty() {
10606        format!("@@ {mid} @@ {tail}")
10607    } else {
10608        format!("@@ {mid} @@")
10609    })
10610}
10611
10612fn line_slice_for_diff_with_eof_nl(
10613    lines: &[&str],
10614    start: usize,
10615    end: usize,
10616    full_file_ends_with_newline: bool,
10617) -> String {
10618    if start >= end {
10619        return String::new();
10620    }
10621    let mut s = lines[start..end].join("\n");
10622    let slice_is_suffix_of_file = end == lines.len();
10623    let need_trailing_nl = if slice_is_suffix_of_file {
10624        full_file_ends_with_newline
10625    } else {
10626        true
10627    };
10628    if need_trailing_nl && !s.ends_with('\n') {
10629        s.push('\n');
10630    }
10631    s
10632}
10633
10634/// Map a 0-based old line index to the corresponding 0-based new line index using the full-file
10635/// diff ops (Git aligns context across deletions/insertions).
10636fn map_old_line_to_new(ops: &[similar::DiffOp], old_line: usize, n_new: usize) -> usize {
10637    use similar::DiffOp;
10638    let mut n = 0usize;
10639    for op in ops {
10640        match *op {
10641            DiffOp::Equal {
10642                old_index,
10643                new_index,
10644                len,
10645            } => {
10646                if old_index + len <= old_line {
10647                    n = new_index + len;
10648                    continue;
10649                }
10650                if old_index < old_line {
10651                    let take = old_line - old_index;
10652                    return (new_index + take).min(n_new);
10653                }
10654                return new_index.min(n_new);
10655            }
10656            DiffOp::Delete {
10657                old_index,
10658                old_len,
10659                new_index,
10660            } => {
10661                if old_index + old_len <= old_line {
10662                    n = new_index;
10663                    continue;
10664                }
10665                if old_index < old_line {
10666                    return new_index.min(n_new);
10667                }
10668            }
10669            DiffOp::Insert {
10670                old_index,
10671                new_index,
10672                new_len,
10673            } => {
10674                if old_index < old_line {
10675                    n = new_index + new_len;
10676                    continue;
10677                }
10678                if old_index == old_line {
10679                    // `old_line` is an exclusive end or insertion point aligned with this insert
10680                    // (e.g. EOF append maps to after the inserted block).
10681                    return (new_index + new_len).min(n_new);
10682                }
10683                return new_index.min(n_new);
10684            }
10685            DiffOp::Replace {
10686                old_index,
10687                old_len,
10688                new_index,
10689                new_len,
10690            } => {
10691                if old_index + old_len <= old_line {
10692                    n = new_index + new_len;
10693                    continue;
10694                }
10695                if old_index < old_line {
10696                    let into_old = old_line - old_index;
10697                    let mapped = new_index + into_old.min(new_len);
10698                    return mapped.min(n_new);
10699                }
10700                return new_index.min(n_new);
10701            }
10702        }
10703    }
10704    n.min(n_new)
10705}
10706
10707/// Parse `@@ -old +new @@` into 0-based half-open ranges in each file.
10708fn parse_unified_hunk_header_ranges(header: &str) -> Option<(usize, usize, usize, usize)> {
10709    let rest = header.strip_prefix("@@ ")?;
10710    let (old_tok, rest2) = rest.split_once(" +")?;
10711    let old_tok = old_tok.strip_prefix('-')?;
10712    let new_tok = rest2.split_once(" @@").map(|(a, _)| a)?;
10713
10714    fn parse_side(spec: &str) -> Option<(usize, usize)> {
10715        let spec = spec.trim();
10716        let (start_one_based, count) = if let Some((a, b)) = spec.split_once(',') {
10717            (a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)
10718        } else {
10719            let s = spec.parse::<usize>().ok()?;
10720            (s, 1usize)
10721        };
10722        let s0 = start_one_based.saturating_sub(1);
10723        let e0 = s0.saturating_add(count);
10724        Some((s0, e0))
10725    }
10726
10727    let (os, oe) = parse_side(old_tok)?;
10728    let (ns, ne) = parse_side(new_tok)?;
10729    Some((os, oe, ns, ne))
10730}
10731
10732/// Git `xemit.c`: when a hunk only inserts at EOF (first inserted line is `new_index == n_old`)
10733/// and the added text already contains a funcname line, do not pull extra context from the preimage.
10734fn append_with_whole_function_added(
10735    ops: &[similar::DiffOp],
10736    n_old: usize,
10737    n_new: usize,
10738    new_lines: &[&str],
10739    matcher: Option<&FuncnameMatcher>,
10740) -> bool {
10741    use similar::DiffOp;
10742    if n_old == 0 {
10743        return false;
10744    }
10745    let mut only_ins_or_eq = true;
10746    let mut min_new_ins = usize::MAX;
10747    for op in ops {
10748        match *op {
10749            DiffOp::Equal { .. } => {}
10750            DiffOp::Insert {
10751                new_index, new_len, ..
10752            } => {
10753                min_new_ins = min_new_ins.min(new_index);
10754                if new_len == 0 {
10755                    only_ins_or_eq = false;
10756                }
10757            }
10758            DiffOp::Delete { .. } | DiffOp::Replace { .. } => {
10759                only_ins_or_eq = false;
10760            }
10761        }
10762    }
10763    let mut insert_at_eof = false;
10764    for op in ops {
10765        if let DiffOp::Insert { old_index, .. } = *op {
10766            if old_index == n_old {
10767                insert_at_eof = true;
10768                break;
10769            }
10770        }
10771    }
10772    let append_at_eof = min_new_ins == n_old || insert_at_eof;
10773    if !only_ins_or_eq || !append_at_eof || min_new_ins == usize::MAX {
10774        return false;
10775    }
10776    // Git only skips preimage pull when the inserted block is clearly a new logical
10777    // function (see `xemit.c` walking `xdf2` for `is_func_rec`). A loose "any line
10778    // looks like a function" check would match `return` / `printf` and break `-W`
10779    // hunks that still need preimage context (t4051 `extended`).
10780    let mut j = min_new_ins;
10781    while j < n_new {
10782        let line = new_lines[j];
10783        if line.trim().is_empty() {
10784            j += 1;
10785            continue;
10786        }
10787        if let Some(m) = matcher {
10788            if m.match_line(line).is_some() {
10789                return true;
10790            }
10791        } else if inserted_block_starts_with_c_like_function_definition(line) {
10792            return true;
10793        }
10794        j += 1;
10795    }
10796    false
10797}
10798
10799fn inserted_block_starts_with_c_like_function_definition(line: &str) -> bool {
10800    let t = line.trim_start();
10801    let Some(open_paren) = t.find('(') else {
10802        return false;
10803    };
10804    let head = &t[..open_paren];
10805    let tokens: Vec<&str> = head.split_whitespace().collect();
10806    if tokens.len() < 2 {
10807        // `printf(...)`, `return (`, etc. — not `return_type name(`.
10808        return false;
10809    }
10810    let nameish = tokens.last().copied().unwrap_or("");
10811    let name = nameish.trim_end_matches(['*', '&']);
10812    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
10813        return false;
10814    }
10815    let type_or_modifier = |tok: &str| {
10816        matches!(
10817            tok,
10818            "static"
10819                | "extern"
10820                | "inline"
10821                | "void"
10822                | "int"
10823                | "char"
10824                | "short"
10825                | "long"
10826                | "float"
10827                | "double"
10828                | "unsigned"
10829                | "signed"
10830                | "struct"
10831                | "enum"
10832                | "union"
10833                | "const"
10834                | "volatile"
10835                | "typedef"
10836        )
10837    };
10838    tokens[..tokens.len() - 1]
10839        .iter()
10840        .any(|tok| type_or_modifier(tok))
10841}
10842
10843fn hunk_old_change_end_exclusive(ops: &[similar::DiffOp]) -> usize {
10844    use similar::DiffOp;
10845    let mut max_o = 0usize;
10846    for op in ops {
10847        match *op {
10848            DiffOp::Delete {
10849                old_index, old_len, ..
10850            } => {
10851                max_o = max_o.max(old_index + old_len);
10852            }
10853            DiffOp::Replace {
10854                old_index, old_len, ..
10855            } => {
10856                max_o = max_o.max(old_index + old_len);
10857            }
10858            DiffOp::Insert { old_index, .. } => {
10859                // Pure insertions do not consume old lines; Git's post-context anchor is the
10860                // insertion point (`old_index`), not 0 (t4051 `extended`).
10861                max_o = max_o.max(old_index);
10862            }
10863            DiffOp::Equal { .. } => {}
10864        }
10865    }
10866    max_o
10867}
10868
10869fn func_context_old_anchor(ops: &[similar::DiffOp], n_old: usize) -> usize {
10870    use similar::DiffOp;
10871    let mut has_delete_or_replace = false;
10872    let mut min_del = usize::MAX;
10873    let mut min_ins_old = usize::MAX;
10874
10875    for op in ops {
10876        match *op {
10877            DiffOp::Delete {
10878                old_index, old_len, ..
10879            } => {
10880                has_delete_or_replace = true;
10881                min_del = min_del.min(old_index);
10882                min_del = min_del.min(old_index + old_len.saturating_sub(1));
10883            }
10884            DiffOp::Replace {
10885                old_index, old_len, ..
10886            } => {
10887                has_delete_or_replace = true;
10888                min_del = min_del.min(old_index);
10889                min_del = min_del.min(old_index + old_len.saturating_sub(1));
10890            }
10891            DiffOp::Insert { old_index, .. } => {
10892                min_ins_old = min_ins_old.min(old_index);
10893            }
10894            DiffOp::Equal { .. } => {}
10895        }
10896    }
10897
10898    let mut i1 = if has_delete_or_replace {
10899        min_del
10900    } else if min_ins_old != usize::MAX {
10901        min_ins_old
10902    } else {
10903        0
10904    };
10905
10906    let pure_insert = ops
10907        .iter()
10908        .all(|op| matches!(op, DiffOp::Insert { .. } | DiffOp::Equal { .. }))
10909        && ops.iter().any(|op| matches!(op, DiffOp::Insert { .. }));
10910
10911    if pure_insert && i1 >= n_old && n_old > 0 {
10912        i1 = n_old - 1;
10913    }
10914
10915    i1.min(n_old.saturating_sub(1))
10916}
10917
10918fn expand_func_pre_start(
10919    s1: usize,
10920    i1: usize,
10921    n_old: usize,
10922    old_lines: &[&str],
10923    matcher: Option<&FuncnameMatcher>,
10924) -> usize {
10925    if n_old == 0 {
10926        return s1;
10927    }
10928    let i1 = i1.min(n_old.saturating_sub(1));
10929    let mut fs1 = get_func_line_backward(old_lines, i1, matcher).unwrap_or(i1);
10930    while fs1 > 0
10931        && !is_line_empty_for_func_context(old_lines[fs1 - 1])
10932        && !is_func_line(old_lines[fs1 - 1], matcher)
10933    {
10934        fs1 -= 1;
10935    }
10936    s1.min(fs1)
10937}
10938
10939fn expand_func_post_end(
10940    e1: usize,
10941    i1_end: usize,
10942    n_old: usize,
10943    old_lines: &[&str],
10944    matcher: Option<&FuncnameMatcher>,
10945) -> usize {
10946    let from = i1_end.min(n_old);
10947    let fe1 = get_func_line_forward(old_lines, from, matcher).unwrap_or(n_old);
10948    let mut fe1_adj = fe1;
10949    while fe1_adj > 0 && is_line_empty_for_func_context(old_lines[fe1_adj - 1]) {
10950        fe1_adj -= 1;
10951    }
10952    e1.max(fe1_adj).min(n_old)
10953}
10954
10955fn is_line_empty_for_func_context(line: &str) -> bool {
10956    line.chars().all(|c| c.is_whitespace())
10957}
10958
10959pub(crate) fn is_func_line(line: &str, matcher: Option<&FuncnameMatcher>) -> bool {
10960    if let Some(m) = matcher {
10961        return m.match_line(line).is_some();
10962    }
10963    let t = line.trim_end_matches(['\n', '\r']);
10964    if t.is_empty() {
10965        return false;
10966    }
10967    let b = t.as_bytes()[0];
10968    if !(b.is_ascii_alphabetic() || b == b'_' || b == b'$') {
10969        return false;
10970    }
10971    true
10972}
10973
10974fn get_func_line_backward(
10975    old_lines: &[&str],
10976    start: usize,
10977    matcher: Option<&FuncnameMatcher>,
10978) -> Option<usize> {
10979    let mut l = start.min(old_lines.len().saturating_sub(1));
10980    if old_lines.is_empty() {
10981        return None;
10982    }
10983    loop {
10984        if is_func_line(old_lines[l], matcher) {
10985            return Some(l);
10986        }
10987        if l == 0 {
10988            break;
10989        }
10990        l -= 1;
10991    }
10992    None
10993}
10994
10995fn get_func_line_forward(
10996    old_lines: &[&str],
10997    start: usize,
10998    matcher: Option<&FuncnameMatcher>,
10999) -> Option<usize> {
11000    let mut l = start;
11001    while l < old_lines.len() {
11002        if is_func_line(old_lines[l], matcher) {
11003            return Some(l);
11004        }
11005        l += 1;
11006    }
11007    None
11008}
11009#[expect(clippy::too_many_arguments)]
11010/// Compute a unified diff with anchored lines.
11011///
11012/// Anchored lines that appear exactly once in both old and new content are
11013/// forced to match, splitting the diff into segments around those anchor points.
11014/// This produces diffs where the anchored text stays as context and surrounding
11015/// lines are shown as additions/removals.
11016///
11017/// Segment diffs use `algorithm`. When `use_git_histogram` is true, histogram uses the
11018/// Git-compatible histogram engine; otherwise `algorithm` is passed to `similar`.
11019pub fn anchored_unified_diff(
11020    old_content: &str,
11021    new_content: &str,
11022    old_path: &str,
11023    new_path: &str,
11024    context_lines: usize,
11025    anchors: &[String],
11026    algorithm: similar::Algorithm,
11027    use_git_histogram: bool,
11028    indent_heuristic: bool,
11029    quote_path_fully: bool,
11030) -> String {
11031    use crate::quote_path::format_diff_path_with_prefix;
11032    use similar::TextDiff;
11033
11034    let old_lines: Vec<&str> = old_content.lines().collect();
11035    let new_lines: Vec<&str> = new_content.lines().collect();
11036
11037    // Find anchored lines that appear exactly once in both old and new
11038    let mut anchor_pairs: Vec<(usize, usize)> = Vec::new(); // (old_idx, new_idx)
11039
11040    for anchor in anchors {
11041        let anchor_str = anchor.as_str();
11042
11043        // Count occurrences in old
11044        let old_positions: Vec<usize> = old_lines
11045            .iter()
11046            .enumerate()
11047            .filter(|(_, l)| l.trim_end() == anchor_str)
11048            .map(|(i, _)| i)
11049            .collect();
11050
11051        // Count occurrences in new
11052        let new_positions: Vec<usize> = new_lines
11053            .iter()
11054            .enumerate()
11055            .filter(|(_, l)| l.trim_end() == anchor_str)
11056            .map(|(i, _)| i)
11057            .collect();
11058
11059        // Only anchor if unique in both
11060        if old_positions.len() == 1 && new_positions.len() == 1 {
11061            anchor_pairs.push((old_positions[0], new_positions[0]));
11062        }
11063    }
11064
11065    // If no valid anchors, fall back to normal diff
11066    if anchor_pairs.is_empty() {
11067        return unified_diff_with_prefix_and_funcname_and_algorithm(
11068            old_content,
11069            new_content,
11070            old_path,
11071            new_path,
11072            context_lines,
11073            0,
11074            "a/",
11075            "b/",
11076            None,
11077            algorithm,
11078            false,
11079            use_git_histogram,
11080            indent_heuristic,
11081            quote_path_fully,
11082        );
11083    }
11084
11085    // Sort anchor pairs by their position in the old file
11086    anchor_pairs.sort_by_key(|&(old_idx, _)| old_idx);
11087
11088    // Filter to only keep pairs where new positions are also increasing
11089    // (longest increasing subsequence of new positions)
11090    let mut filtered: Vec<(usize, usize)> = Vec::new();
11091    for &pair in &anchor_pairs {
11092        if filtered.is_empty() || filtered.last().is_some_and(|last| pair.1 > last.1) {
11093            filtered.push(pair);
11094        }
11095    }
11096    let anchor_pairs = filtered;
11097
11098    // Build a modified version of old/new where we diff segments between anchors.
11099    // We'll construct the diff by processing segments:
11100    // - Before first anchor
11101    // - Between consecutive anchors
11102    // - After last anchor
11103    // Each anchor line itself is a fixed context match.
11104
11105    // Collect all diff operations
11106    struct LineDiffOp {
11107        tag: char, // ' ', '+', '-'
11108        line: String,
11109    }
11110
11111    let append_segment_diff =
11112        |ops: &mut Vec<LineDiffOp>, old_seg_input: &str, new_seg_input: &str| {
11113            use similar::ChangeTag;
11114            let old_ls: Vec<&str> = old_seg_input.lines().collect();
11115            let new_ls: Vec<&str> = new_seg_input.lines().collect();
11116            if old_ls.is_empty() && new_ls.is_empty() {
11117                return;
11118            }
11119            let seg_diff = TextDiff::configure()
11120                .algorithm(algorithm)
11121                .diff_slices(&old_ls, &new_ls);
11122            let raw = seg_diff.ops().to_vec();
11123            let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
11124                &raw,
11125                &old_ls,
11126                &new_ls,
11127                indent_heuristic,
11128            );
11129            for op in &compacted {
11130                for ch in op.iter_changes(&old_ls, &new_ls) {
11131                    let t = match ch.tag() {
11132                        ChangeTag::Equal => ' ',
11133                        ChangeTag::Delete => '-',
11134                        ChangeTag::Insert => '+',
11135                    };
11136                    ops.push(LineDiffOp {
11137                        tag: t,
11138                        line: ch.value().to_string(),
11139                    });
11140                }
11141            }
11142        };
11143
11144    let mut ops: Vec<LineDiffOp> = Vec::new();
11145    let mut old_pos = 0usize;
11146    let mut new_pos = 0usize;
11147
11148    for &(old_anchor, new_anchor) in &anchor_pairs {
11149        // Diff the segment before this anchor
11150        let old_segment: Vec<&str> = old_lines[old_pos..old_anchor].to_vec();
11151        let new_segment: Vec<&str> = new_lines[new_pos..new_anchor].to_vec();
11152
11153        let old_seg_text = old_segment.join("\n");
11154        let new_seg_text = new_segment.join("\n");
11155
11156        if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
11157            let old_seg_input = if old_seg_text.is_empty() {
11158                String::new()
11159            } else {
11160                format!("{}\n", old_seg_text)
11161            };
11162            let new_seg_input = if new_seg_text.is_empty() {
11163                String::new()
11164            } else {
11165                format!("{}\n", new_seg_text)
11166            };
11167            append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
11168        }
11169
11170        // The anchor line itself is always context
11171        ops.push(LineDiffOp {
11172            tag: ' ',
11173            line: old_lines[old_anchor].to_string(),
11174        });
11175
11176        old_pos = old_anchor + 1;
11177        new_pos = new_anchor + 1;
11178    }
11179
11180    // Diff the remaining segment after the last anchor
11181    let old_segment: Vec<&str> = old_lines[old_pos..].to_vec();
11182    let new_segment: Vec<&str> = new_lines[new_pos..].to_vec();
11183    let old_seg_text = old_segment.join("\n");
11184    let new_seg_text = new_segment.join("\n");
11185
11186    if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
11187        let old_seg_input = if old_seg_text.is_empty() {
11188            String::new()
11189        } else {
11190            format!("{}\n", old_seg_text)
11191        };
11192        let new_seg_input = if new_seg_text.is_empty() {
11193            String::new()
11194        } else {
11195            format!("{}\n", new_seg_text)
11196        };
11197        append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
11198    }
11199
11200    // Now format as unified diff with hunks
11201    let mut output = String::new();
11202    if old_path == "/dev/null" {
11203        output.push_str("--- /dev/null\n");
11204    } else {
11205        output.push_str("--- ");
11206        output.push_str(&format_diff_path_with_prefix(
11207            "a/",
11208            old_path,
11209            quote_path_fully,
11210        ));
11211        output.push('\n');
11212    }
11213    if new_path == "/dev/null" {
11214        output.push_str("+++ /dev/null\n");
11215    } else {
11216        output.push_str("+++ ");
11217        output.push_str(&format_diff_path_with_prefix(
11218            "b/",
11219            new_path,
11220            quote_path_fully,
11221        ));
11222        output.push('\n');
11223    }
11224
11225    // Group ops into hunks with context
11226    let total_ops = ops.len();
11227    if total_ops == 0 {
11228        return output;
11229    }
11230
11231    // Find ranges of changes
11232    let mut hunks: Vec<(usize, usize)> = Vec::new(); // (start, end) indices into ops
11233    let mut i = 0;
11234    while i < total_ops {
11235        if ops[i].tag != ' ' {
11236            let start = i.saturating_sub(context_lines);
11237            let mut end = i;
11238            // Extend to include consecutive changes and their context
11239            while end < total_ops {
11240                if ops[end].tag != ' ' {
11241                    end += 1;
11242                    continue;
11243                }
11244                // Check if there's another change within context_lines
11245                let mut next_change = end;
11246                while next_change < total_ops && ops[next_change].tag == ' ' {
11247                    next_change += 1;
11248                }
11249                if next_change < total_ops && next_change - end <= context_lines * 2 {
11250                    end = next_change + 1;
11251                } else {
11252                    end = (end + context_lines).min(total_ops);
11253                    break;
11254                }
11255            }
11256            // Merge with previous hunk if overlapping
11257            if let Some(last) = hunks.last_mut() {
11258                if start <= last.1 {
11259                    last.1 = end;
11260                } else {
11261                    hunks.push((start, end));
11262                }
11263            } else {
11264                hunks.push((start, end));
11265            }
11266            i = end;
11267        } else {
11268            i += 1;
11269        }
11270    }
11271
11272    // Output each hunk
11273    for (start, end) in hunks {
11274        // Count old/new lines in this hunk
11275        let mut old_start = 1usize;
11276        let mut new_start = 1usize;
11277        // Calculate line numbers by counting ops before this hunk
11278        for op in &ops[..start] {
11279            match op.tag {
11280                ' ' => {
11281                    old_start += 1;
11282                    new_start += 1;
11283                }
11284                '-' => {
11285                    old_start += 1;
11286                }
11287                '+' => {
11288                    new_start += 1;
11289                }
11290                _ => {}
11291            }
11292        }
11293        let mut old_count = 0usize;
11294        let mut new_count = 0usize;
11295        for op in &ops[start..end] {
11296            match op.tag {
11297                ' ' => {
11298                    old_count += 1;
11299                    new_count += 1;
11300                }
11301                '-' => {
11302                    old_count += 1;
11303                }
11304                '+' => {
11305                    new_count += 1;
11306                }
11307                _ => {}
11308            }
11309        }
11310
11311        output.push_str(&format!(
11312            "@@ -{},{} +{},{} @@\n",
11313            old_start, old_count, new_start, new_count
11314        ));
11315        for op in &ops[start..end] {
11316            output.push(op.tag);
11317            output.push_str(&op.line);
11318            output.push('\n');
11319        }
11320    }
11321
11322    output
11323}
11324
11325/// Extract function context for a hunk header.
11326///
11327/// Given a hunk header like `@@ -8,7 +8,7 @@`, find the last line
11328/// before line 8 in the old content that looks like a function header
11329/// (starts with a non-whitespace character, like Git's default).
11330fn func_context_line_matches(line: &str, funcname_matcher: Option<&FuncnameMatcher>) -> bool {
11331    if let Some(m) = funcname_matcher {
11332        return m.match_line(line).is_some();
11333    }
11334    is_func_line(line, None)
11335}
11336
11337fn extract_function_context(
11338    header: &str,
11339    old_lines: &[&str],
11340    funcname_matcher: Option<&FuncnameMatcher>,
11341    _source_path: Option<&str>,
11342) -> Option<String> {
11343    // Parse the old start line number from "@@ -<start>,<count> ..."
11344    let at_pos = header.find("-")?;
11345    let rest = &header[at_pos + 1..];
11346    let comma_or_space = rest.find([',', ' '])?;
11347    let start_str = &rest[..comma_or_space];
11348    let start_line: usize = start_str.parse().ok()?;
11349
11350    // Parse the old line count; "@@ -<start>,<count> ..." (no comma means count 1).
11351    // Only look for the comma inside the old-range token itself — searching the
11352    // whole remainder would pick up the comma from the new side (e.g. "+0,0").
11353    let old_token_end = rest.find([' ', '\t']).unwrap_or(rest.len());
11354    let old_token = &rest[..old_token_end];
11355    let old_count: usize = if let Some(comma) = old_token.find(',') {
11356        old_token[comma + 1..].parse().unwrap_or(1)
11357    } else {
11358        1
11359    };
11360
11361    if start_line == 0 {
11362        return None;
11363    }
11364
11365    // Look backwards for a line that matches the funcname pattern. start_line is
11366    // 1-indexed. For a normal hunk the first changed pre-image line is
11367    // old_lines[start_line-1], so we search lines strictly before it
11368    // (old_lines[0..start_line-1]). For a pure insertion (old count 0) the
11369    // content is inserted *after* old line start_line, so Git's function search
11370    // begins at that line itself: search old_lines[0..start_line].
11371    if start_line == 0 {
11372        return None;
11373    }
11374    let search_end = if old_count == 0 {
11375        start_line.min(old_lines.len())
11376    } else {
11377        if start_line <= 1 {
11378            return None;
11379        }
11380        (start_line - 1).min(old_lines.len())
11381    };
11382    let truncate = |line: &str| {
11383        let mut end = line.len().min(80);
11384        while end > 0 && !line.is_char_boundary(end) {
11385            end -= 1;
11386        }
11387        let mut out = line[..end].to_owned();
11388        let tail = line[end..].trim_start();
11389        // Git drops trailing whitespace on truncated funcname lines when the
11390        // remainder of the source line continues with normal text (t2021-style
11391        // shell tests), but keeps it when the next token is a string delimiter
11392        // (gettext `.po` msgstr lines).
11393        if tail.is_empty()
11394            || (!tail.starts_with('"')
11395                && !tail.starts_with('\'')
11396                && !line.starts_with("msgstr")
11397                && !line.starts_with("msgid"))
11398        {
11399            out = out.trim_end().to_owned();
11400        }
11401        out
11402    };
11403
11404    for i in (0..search_end).rev() {
11405        let line = old_lines[i];
11406        if line.is_empty() {
11407            continue;
11408        }
11409        if func_context_line_matches(line, funcname_matcher) {
11410            if let Some(matcher) = funcname_matcher {
11411                if let Some(matched) = matcher.match_line(line) {
11412                    return Some(truncate(&matched));
11413                }
11414                continue;
11415            }
11416            return Some(truncate(line));
11417        }
11418    }
11419    None
11420}
11421
11422fn last_line_index_without_trailing_newline(text: &str) -> Option<usize> {
11423    LineSide::from_text(text).incomplete_last_line_index()
11424}
11425
11426fn parse_unified_hunk_starts(header: &str) -> Option<(usize, usize)> {
11427    let rest = header.strip_prefix("@@ ")?;
11428    let (old_part, new_part) = rest.split_once(" +")?;
11429    let old_start = parse_unified_range_start(old_part.strip_prefix('-')?)?;
11430    let new_start = parse_unified_range_start(new_part.split(" @@").next()?)?;
11431    Some((old_start, new_start))
11432}
11433
11434fn parse_unified_range_start(spec: &str) -> Option<usize> {
11435    let spec = spec.trim();
11436    if let Some((start, _)) = spec.split_once(',') {
11437        start.parse().ok()
11438    } else {
11439        spec.parse().ok()
11440    }
11441}
11442
11443/// Tracks hunk line numbers while streaming `\ No newline at end of file` markers.
11444struct UnifiedDiffNoNewlineAnnotator {
11445    old_last: Option<usize>,
11446    new_last: Option<usize>,
11447    old_line: usize,
11448    new_line: usize,
11449    passthrough: bool,
11450}
11451
11452impl UnifiedDiffNoNewlineAnnotator {
11453    fn new(old: &str, new: &str) -> Self {
11454        let old_last = last_line_index_without_trailing_newline(old);
11455        let new_last = last_line_index_without_trailing_newline(new);
11456        Self {
11457            old_last,
11458            new_last,
11459            old_line: 1,
11460            new_line: 1,
11461            passthrough: old_last.is_none() && new_last.is_none(),
11462        }
11463    }
11464
11465    fn write_hunk<W: std::io::Write>(&mut self, out: &mut W, patch: &str) -> Result<()> {
11466        if self.passthrough {
11467            out.write_all(patch.as_bytes())?;
11468            return Ok(());
11469        }
11470        let mut lines = patch.lines().peekable();
11471        while let Some(line) = lines.next() {
11472            self.write_annotated_line(out, line, &mut lines)?;
11473        }
11474        Ok(())
11475    }
11476
11477    fn write_annotated_line<W: std::io::Write>(
11478        &mut self,
11479        out: &mut W,
11480        line: &str,
11481        lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
11482    ) -> Result<()> {
11483        if let Some((o, n)) = line
11484            .strip_prefix("@@ ")
11485            .and_then(|_| parse_unified_hunk_starts(line))
11486        {
11487            self.old_line = o;
11488            self.new_line = n;
11489            writeln!(out, "{line}")?;
11490            return Ok(());
11491        }
11492        if line.starts_with("--- ")
11493            || line.starts_with("+++ ")
11494            || line.starts_with("diff --git ")
11495            || line.starts_with("diff --cc ")
11496            || line.starts_with("diff --combined ")
11497        {
11498            writeln!(out, "{line}")?;
11499            return Ok(());
11500        }
11501        if line.starts_with('-') {
11502            writeln!(out, "{line}")?;
11503            let already_marked = lines
11504                .peek()
11505                .is_some_and(|next| *next == "\\ No newline at end of file");
11506            if !already_marked && self.old_last == Some(self.old_line.saturating_sub(1)) {
11507                writeln!(out, "\\ No newline at end of file")?;
11508            }
11509            self.old_line += 1;
11510            return Ok(());
11511        }
11512        if line.starts_with('+') {
11513            writeln!(out, "{line}")?;
11514            let already_marked = lines
11515                .peek()
11516                .is_some_and(|next| *next == "\\ No newline at end of file");
11517            if !already_marked && self.new_last == Some(self.new_line.saturating_sub(1)) {
11518                writeln!(out, "\\ No newline at end of file")?;
11519            }
11520            self.new_line += 1;
11521            return Ok(());
11522        }
11523        if line.starts_with(' ') {
11524            writeln!(out, "{line}")?;
11525            let already_marked = lines
11526                .peek()
11527                .is_some_and(|next| *next == "\\ No newline at end of file");
11528            if !already_marked {
11529                let old_idx = self.old_line.saturating_sub(1);
11530                let new_idx = self.new_line.saturating_sub(1);
11531                if self.old_last == Some(old_idx) || self.new_last == Some(new_idx) {
11532                    writeln!(out, "\\ No newline at end of file")?;
11533                }
11534            }
11535            self.old_line += 1;
11536            self.new_line += 1;
11537            return Ok(());
11538        }
11539        writeln!(out, "{line}")?;
11540        Ok(())
11541    }
11542}
11543
11544/// Insert Git `\ No newline at end of file` after `-`/`+` hunk lines when that side of the
11545/// file lacks a trailing newline on the last line.
11546#[must_use]
11547pub fn annotate_unified_diff_no_newline_at_eof(patch: &str, old: &str, new: &str) -> String {
11548    let mut annotator = UnifiedDiffNoNewlineAnnotator::new(old, new);
11549    if annotator.passthrough {
11550        return patch.to_string();
11551    }
11552    let mut out = Vec::with_capacity(patch.len() + 64);
11553    let mut lines = patch.lines().peekable();
11554    while let Some(line) = lines.next() {
11555        if annotator
11556            .write_annotated_line(&mut out, line, &mut lines)
11557            .is_err()
11558        {
11559            return patch.to_string();
11560        }
11561    }
11562    String::from_utf8(out).unwrap_or_else(|_| patch.to_string())
11563}
11564
11565/// Normalise one line like Git's `-b` / `--ignore-space-change`.
11566#[must_use]
11567pub fn normalize_ignore_space_change_line(line: &str) -> String {
11568    let mut result = String::with_capacity(line.len());
11569    let mut in_space = false;
11570    for c in line.chars() {
11571        if c.is_whitespace() {
11572            if !in_space {
11573                result.push(' ');
11574                in_space = true;
11575            }
11576        } else {
11577            result.push(c);
11578            in_space = false;
11579        }
11580    }
11581    while result.ends_with(' ') {
11582        result.pop();
11583    }
11584    result
11585}
11586
11587/// Normalise text like Git's `-b` / `--ignore-space-change`: on each line, collapse runs of
11588/// whitespace to a single ASCII space and trim trailing spaces.
11589///
11590/// Line breaks are preserved by splitting on [`str::lines`] and rejoining with `\n` (same approach
11591/// as the porcelain `diff` whitespace handling in `grit`).
11592#[must_use]
11593pub fn normalize_ignore_space_change(content: &str) -> String {
11594    content
11595        .lines()
11596        .map(normalize_ignore_space_change_line)
11597        .collect::<Vec<_>>()
11598        .join("\n")
11599}
11600
11601/// Count insertions and deletions using the given line-diff algorithm.
11602///
11603/// Uses the same compacted line alignment as unified patches and [`crate::diff::line_change_counts`].
11604#[must_use]
11605pub fn count_changes_with_algorithm(
11606    old_content: &str,
11607    new_content: &str,
11608    algorithm: similar::Algorithm,
11609    use_git_histogram: bool,
11610) -> (usize, usize) {
11611    use crate::diff::options::{DiffAlgorithm, DiffOptions};
11612    let diff_algorithm = if use_git_histogram {
11613        DiffAlgorithm::Histogram
11614    } else {
11615        match algorithm {
11616            similar::Algorithm::Myers => DiffAlgorithm::Myers,
11617            similar::Algorithm::Lcs => DiffAlgorithm::Minimal,
11618            similar::Algorithm::Patience => DiffAlgorithm::Patience,
11619            _ => DiffAlgorithm::Myers,
11620        }
11621    };
11622    count_line_changes_with_options(
11623        old_content,
11624        new_content,
11625        &DiffOptions {
11626            algorithm: diff_algorithm,
11627            ..DiffOptions::default()
11628        },
11629    )
11630}
11631
11632/// Count insertions and deletions using Git's histogram line-diff engine.
11633#[must_use]
11634pub fn count_changes_git_histogram(old_content: &str, new_content: &str) -> (usize, usize) {
11635    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, true)
11636}
11637
11638/// Line count for diffstat/`--numstat`, matching Git's `count_lines()` in `diff.c`.
11639///
11640/// Counts newline-terminated lines; a final line without trailing newline still counts as one line.
11641/// An empty buffer yields `0`.
11642#[must_use]
11643pub fn count_git_lines(data: &[u8]) -> usize {
11644    if data.is_empty() {
11645        return 0;
11646    }
11647    let mut count = 0usize;
11648    let mut nl_just_seen = false;
11649    for &ch in data {
11650        if ch == b'\n' {
11651            count += 1;
11652            nl_just_seen = true;
11653        } else {
11654            nl_just_seen = false;
11655        }
11656    }
11657    if !nl_just_seen {
11658        count += 1;
11659    }
11660    count
11661}
11662
11663/// Internal maximum diff score used by Git rename/break heuristics (`MAX_SCORE` in `diffcore.h`).
11664pub const GIT_DIFF_MAX_SCORE: u64 = 60_000;
11665const DIFF_MAX_SCORE: u64 = GIT_DIFF_MAX_SCORE;
11666const DIFF_MINIMUM_BREAK_SIZE: usize = 400;
11667const DIFF_DEFAULT_BREAK_SCORE: u64 = 30_000;
11668/// Default break threshold (`DEFAULT_BREAK_SCORE` in `diffcore.h`), internal 0–[`GIT_DIFF_MAX_SCORE`] scale.
11669pub const GIT_DIFF_DEFAULT_BREAK_SCORE: u64 = DIFF_DEFAULT_BREAK_SCORE;
11670const DIFF_HASHBASE: u32 = 107_927;
11671
11672#[derive(Clone, Copy, Default)]
11673struct SpanSlot {
11674    hashval: u32,
11675    cnt: u32,
11676}
11677
11678struct SpanHashTop {
11679    alloc_log2: u8,
11680    free_slots: i32,
11681    data: Vec<SpanSlot>,
11682}
11683
11684impl SpanHashTop {
11685    fn new(initial_log2: u8) -> Self {
11686        let cap = 1usize << initial_log2;
11687        Self {
11688            alloc_log2: initial_log2,
11689            free_slots: initial_free(initial_log2),
11690            data: vec![SpanSlot::default(); cap],
11691        }
11692    }
11693
11694    fn len(&self) -> usize {
11695        1usize << self.alloc_log2
11696    }
11697
11698    fn add_span(&mut self, hashval: u32, cnt: u32) {
11699        loop {
11700            let lim = self.len();
11701            let mut bucket = (hashval as usize) & (lim - 1);
11702            loop {
11703                let h = &mut self.data[bucket];
11704                if h.cnt == 0 {
11705                    h.hashval = hashval;
11706                    h.cnt = cnt;
11707                    self.free_slots -= 1;
11708                    if self.free_slots < 0 {
11709                        self.rehash();
11710                        break;
11711                    }
11712                    return;
11713                }
11714                if h.hashval == hashval {
11715                    h.cnt = h.cnt.saturating_add(cnt);
11716                    return;
11717                }
11718                bucket += 1;
11719                if bucket >= lim {
11720                    bucket = 0;
11721                }
11722            }
11723        }
11724    }
11725
11726    fn rehash(&mut self) {
11727        let old = std::mem::take(&mut self.data);
11728        let old_log = self.alloc_log2;
11729        self.alloc_log2 = old_log.saturating_add(1);
11730        let new_len = 1usize << self.alloc_log2;
11731        self.free_slots = initial_free(self.alloc_log2);
11732        self.data = vec![SpanSlot::default(); new_len];
11733        let old_sz = 1usize << old_log;
11734        for o in old.iter().take(old_sz) {
11735            let o = *o;
11736            if o.cnt == 0 {
11737                continue;
11738            }
11739            self.add_span_after_rehash(o.hashval, o.cnt);
11740        }
11741    }
11742
11743    fn add_span_after_rehash(&mut self, hashval: u32, cnt: u32) {
11744        loop {
11745            let lim = self.len();
11746            let mut bucket = (hashval as usize) & (lim - 1);
11747            loop {
11748                let h = &mut self.data[bucket];
11749                if h.cnt == 0 {
11750                    h.hashval = hashval;
11751                    h.cnt = cnt;
11752                    self.free_slots -= 1;
11753                    if self.free_slots < 0 {
11754                        self.rehash();
11755                        break;
11756                    }
11757                    return;
11758                }
11759                if h.hashval == hashval {
11760                    h.cnt = h.cnt.saturating_add(cnt);
11761                    return;
11762                }
11763                bucket += 1;
11764                if bucket >= lim {
11765                    bucket = 0;
11766                }
11767            }
11768        }
11769    }
11770
11771    fn sort_by_hashval(&mut self) {
11772        let sz = self.len();
11773        self.data[..sz].sort_by(|a, b| {
11774            if a.cnt == 0 {
11775                return std::cmp::Ordering::Greater;
11776            }
11777            if b.cnt == 0 {
11778                return std::cmp::Ordering::Less;
11779            }
11780            a.hashval.cmp(&b.hashval)
11781        });
11782    }
11783}
11784
11785fn initial_free(sz_log2: u8) -> i32 {
11786    let sz = sz_log2 as i32;
11787    ((1i32 << sz_log2) * (sz - 3) / sz).max(0)
11788}
11789
11790fn hash_blob_spans(buf: &[u8], is_text: bool) -> SpanHashTop {
11791    let mut hash = SpanHashTop::new(9);
11792    let mut n = 0u32;
11793    let mut accum1: u32 = 0;
11794    let mut accum2: u32 = 0;
11795    let mut i = 0usize;
11796    while i < buf.len() {
11797        let c = buf[i] as u32;
11798        let old_1 = accum1;
11799        i += 1;
11800
11801        if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
11802            continue;
11803        }
11804
11805        accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
11806        accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
11807        accum1 = accum1.wrapping_add(c);
11808        n += 1;
11809        if n < 64 && c != b'\n' as u32 {
11810            continue;
11811        }
11812        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
11813        hash.add_span(hashval, n);
11814        n = 0;
11815        accum1 = 0;
11816        accum2 = 0;
11817    }
11818    if n > 0 {
11819        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
11820        hash.add_span(hashval, n);
11821    }
11822    hash.sort_by_hashval();
11823    hash
11824}
11825
11826/// Approximate copied vs added material between two blobs (Git `diffcore_count_changes`).
11827///
11828/// Returns `(copied_bytes_from_src, literal_added_bytes_in_dst)` matching Git's
11829/// `diffcore_count_changes` semantics (used for `--dirstat=changes` damage).
11830#[must_use]
11831pub fn diffcore_count_changes(old: &[u8], new: &[u8]) -> (u64, u64) {
11832    crate::trace::trace_span!("grit.diff.blob");
11833    let src_is_text = !crate::merge_file::is_binary(old);
11834    let dst_is_text = !crate::merge_file::is_binary(new);
11835    let src_count = hash_blob_spans(old, src_is_text);
11836    let dst_count = hash_blob_spans(new, dst_is_text);
11837    let mut sc: u64 = 0;
11838    let mut la: u64 = 0;
11839    let mut si = 0usize;
11840    let mut di = 0usize;
11841    let src_len = src_count.len();
11842    let dst_len = dst_count.len();
11843    loop {
11844        if si >= src_len || src_count.data[si].cnt == 0 {
11845            break;
11846        }
11847        let s_hash = src_count.data[si].hashval;
11848        let s_cnt = u64::from(src_count.data[si].cnt);
11849        while di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval < s_hash {
11850            la += u64::from(dst_count.data[di].cnt);
11851            di += 1;
11852        }
11853        let mut dst_cnt = 0u64;
11854        if di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval == s_hash {
11855            dst_cnt = u64::from(dst_count.data[di].cnt);
11856            di += 1;
11857        }
11858        if s_cnt < dst_cnt {
11859            la += dst_cnt - s_cnt;
11860            sc += s_cnt;
11861        } else {
11862            sc += dst_cnt;
11863        }
11864        si += 1;
11865    }
11866    while di < dst_len && dst_count.data[di].cnt != 0 {
11867        la += u64::from(dst_count.data[di].cnt);
11868        di += 1;
11869    }
11870    (sc, la)
11871}
11872
11873/// Whether an in-place blob edit should be split into delete+create for rename/copy (`should_break`
11874/// in `diffcore-break.c`). `break_score` is on the internal 0–[`GIT_DIFF_MAX_SCORE`] scale (default
11875/// `DIFF_DEFAULT_BREAK_SCORE`).
11876#[must_use]
11877pub fn should_break_rewrite_pair(old: &[u8], new: &[u8], break_score: u64) -> bool {
11878    should_break_rewrite_inner(old, new, break_score)
11879}
11880
11881/// Drop entries that do not match `filter` (Git `--diff-filter`, after rename detection).
11882#[must_use]
11883pub fn apply_diff_filter(
11884    entries: Vec<DiffEntry>,
11885    filter: Option<super::filter::DiffFilter>,
11886) -> Vec<DiffEntry> {
11887    match filter {
11888        Some(f) => f.filter_entries(entries),
11889        None => entries,
11890    }
11891}
11892
11893/// Parse `-M` / `--find-renames` threshold into Git's 0–100 similarity percent.
11894#[must_use]
11895pub fn parse_diff_rename_threshold_percent(arg: &str) -> Option<u8> {
11896    let token = if arg.is_empty() { "50" } else { arg };
11897    let internal = parse_diff_rename_score_token(token)?;
11898    Some((internal.saturating_mul(100) / GIT_DIFF_MAX_SCORE).min(100) as u8)
11899}
11900
11901/// Parse a single Git `parse_rename_score` token (`50`, `50%`, decimal forms) into internal
11902/// 0–[`GIT_DIFF_MAX_SCORE`] units.
11903pub fn parse_diff_rename_score_token(arg: &str) -> Option<u64> {
11904    let mut num: u64 = 0;
11905    let mut scale: u64 = 1;
11906    let mut dot = false;
11907    let mut saw_digit = false;
11908    for ch in arg.chars() {
11909        if !dot && ch == '.' {
11910            scale = 1;
11911            dot = true;
11912            continue;
11913        }
11914        if ch == '%' {
11915            scale = if dot { scale.saturating_mul(100) } else { 100 };
11916            break;
11917        }
11918        if ch.is_ascii_digit() {
11919            saw_digit = true;
11920            if scale < 100_000 {
11921                scale = scale.saturating_mul(10);
11922                num = num.saturating_mul(10) + u64::from(ch as u8 - b'0');
11923            }
11924        } else {
11925            break;
11926        }
11927    }
11928    if !saw_digit {
11929        return None;
11930    }
11931    Some(if num >= scale {
11932        GIT_DIFF_MAX_SCORE
11933    } else {
11934        GIT_DIFF_MAX_SCORE * num / scale
11935    })
11936}
11937
11938/// Git `merge_score` from `diffcore-break.c` when a pair is considered broken: how much of the
11939/// source blob was removed (0–`DIFF_MAX_SCORE` scale). Used for `dissimilarity index` metadata.
11940#[must_use]
11941pub fn rewrite_merge_score(old: &[u8], new: &[u8]) -> Option<u64> {
11942    if old.is_empty() {
11943        return None;
11944    }
11945    let max_size = old.len().max(new.len());
11946    if max_size < DIFF_MINIMUM_BREAK_SIZE {
11947        return None;
11948    }
11949    let (src_copied, _) = diffcore_count_changes(old, new);
11950    let src_copied = src_copied.min(old.len() as u64);
11951    let src_removed = (old.len() as u64).saturating_sub(src_copied);
11952    Some(src_removed * DIFF_MAX_SCORE / old.len() as u64)
11953}
11954
11955/// Percentage shown in `dissimilarity index N%` for a rewrite (`similarity_index` in Git's diff.c).
11956#[must_use]
11957pub fn rewrite_dissimilarity_index_percent(old: &[u8], new: &[u8]) -> Option<u32> {
11958    let score = rewrite_merge_score(old, new)?;
11959    Some((score * 100 / DIFF_MAX_SCORE).min(100) as u32)
11960}
11961
11962fn should_break_rewrite_inner(src: &[u8], dst: &[u8], break_score: u64) -> bool {
11963    if src.is_empty() {
11964        return false;
11965    }
11966    let max_size = src.len().max(dst.len());
11967    if max_size < DIFF_MINIMUM_BREAK_SIZE {
11968        return false;
11969    }
11970    let (src_copied, literal_added) = diffcore_count_changes(src, dst);
11971    let src_copied = src_copied.min(src.len() as u64);
11972    let mut literal_added = literal_added;
11973    let dst_len = dst.len() as u64;
11974    if src_copied < dst_len && literal_added + src_copied > dst_len {
11975        literal_added = dst_len.saturating_sub(src_copied);
11976    }
11977    let src_removed = (src.len() as u64).saturating_sub(src_copied);
11978    let merge_score = src_removed * DIFF_MAX_SCORE / src.len() as u64;
11979    if merge_score > break_score {
11980        return true;
11981    }
11982    let delta_size = src_removed.saturating_add(literal_added);
11983    if delta_size * DIFF_MAX_SCORE / (max_size as u64) < break_score {
11984        return false;
11985    }
11986    let s = src.len() as u64;
11987    if (s * break_score < src_removed * DIFF_MAX_SCORE)
11988        && (literal_added * 20 < src_removed)
11989        && (literal_added * 20 < src_copied)
11990    {
11991        return false;
11992    }
11993    true
11994}
11995
11996// ── Helpers ─────────────────────────────────────────────────────────
11997
11998/// Flatten a tree object recursively into a sorted list of (path, mode, oid).
11999struct FlatEntry {
12000    path: String,
12001    mode: u32,
12002    oid: ObjectId,
12003}
12004
12005fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
12006    flatten_tree_with_index(odb, tree_oid, prefix, None)
12007}
12008
12009fn sparse_index_skips_tree_descent(index: &Index, dir_path: &str, tree_oid: &ObjectId) -> bool {
12010    let mut storage = crate::repo_relpath::storage_bytes(dir_path).into_owned();
12011    if !storage.ends_with(b"/") {
12012        storage.push(b'/');
12013    }
12014    index.find_entry_pos(&storage, 0).is_some_and(|pos| {
12015        let entry = &index.entries[pos];
12016        entry.is_sparse_directory_placeholder() && entry.oid == *tree_oid
12017    })
12018}
12019
12020/// Tree OID at `path` (slash-separated, no trailing slash) under `root`, if every component is a tree.
12021fn subtree_oid_at_path(odb: &Odb, root: &ObjectId, path: &str) -> Result<Option<ObjectId>> {
12022    let mut oid = *root;
12023    for component in path.split('/').filter(|c| !c.is_empty()) {
12024        let entries = read_tree(odb, &oid)?;
12025        let Some(entry) = entries
12026            .iter()
12027            .find(|e| e.name.as_slice() == component.as_bytes())
12028        else {
12029            return Ok(None);
12030        };
12031        if !is_tree_mode(entry.mode) {
12032            return Ok(None);
12033        }
12034        oid = entry.oid;
12035    }
12036    Ok(Some(oid))
12037}
12038
12039/// Report every blob in an index subtree as staged additions (HEAD has no tree at this path).
12040fn emit_flattened_subtree_as_added(
12041    odb: &Odb,
12042    subtree: &ObjectId,
12043    prefix: &str,
12044    ignore_submodules: bool,
12045    result: &mut Vec<DiffEntry>,
12046) -> Result<()> {
12047    for entry in flatten_tree(odb, subtree, prefix)? {
12048        if ignore_submodules && entry.mode == 0o160000 {
12049            continue;
12050        }
12051        result.push(DiffEntry {
12052            status: DiffStatus::Added,
12053            old_path: None,
12054            new_path: Some(entry.path),
12055            old_mode: FileMode::ABSENT,
12056            new_mode: FileMode::new(entry.mode),
12057            old_oid: zero_oid(),
12058            new_oid: entry.oid,
12059            score: None,
12060        });
12061    }
12062    Ok(())
12063}
12064
12065/// Compare two flattened subtrees and append staged diff entries.
12066fn diff_flattened_subtrees(
12067    head: &[FlatEntry],
12068    index: &[FlatEntry],
12069    ignore_submodules: bool,
12070    result: &mut Vec<DiffEntry>,
12071    stage0_paths: &mut std::collections::BTreeSet<String>,
12072    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
12073) {
12074    let mut head_map: std::collections::BTreeMap<&str, &FlatEntry> = head
12075        .iter()
12076        .map(|entry| (entry.path.as_str(), entry))
12077        .collect();
12078    for entry in index {
12079        stage0_paths.insert(entry.path.clone());
12080        tree_map.remove(entry.path.as_str());
12081        match head_map.remove(entry.path.as_str()) {
12082            Some(te) => {
12083                if te.oid != entry.oid || te.mode != entry.mode {
12084                    result.push(DiffEntry {
12085                        status: DiffStatus::for_content_change(te.mode, entry.mode),
12086                        old_path: Some(entry.path.clone()),
12087                        new_path: Some(entry.path.clone()),
12088                        old_mode: FileMode::new(te.mode),
12089                        new_mode: FileMode::new(entry.mode),
12090                        old_oid: te.oid,
12091                        new_oid: entry.oid,
12092                        score: None,
12093                    });
12094                }
12095            }
12096            None => {
12097                result.push(DiffEntry {
12098                    status: DiffStatus::Added,
12099                    old_path: None,
12100                    new_path: Some(entry.path.clone()),
12101                    old_mode: FileMode::ABSENT,
12102                    new_mode: FileMode::new(entry.mode),
12103                    old_oid: zero_oid(),
12104                    new_oid: entry.oid,
12105                    score: None,
12106                });
12107            }
12108        }
12109    }
12110    for (path, te) in head_map {
12111        if ignore_submodules && te.mode == 0o160000 {
12112            continue;
12113        }
12114        tree_map.remove(path);
12115        result.push(DiffEntry {
12116            status: DiffStatus::Deleted,
12117            old_path: Some(path.to_owned()),
12118            new_path: None,
12119            old_mode: FileMode::new(te.mode),
12120            new_mode: FileMode::ABSENT,
12121            old_oid: te.oid,
12122            new_oid: zero_oid(),
12123            score: None,
12124        });
12125    }
12126}
12127
12128fn flatten_tree_with_index(
12129    odb: &Odb,
12130    tree_oid: &ObjectId,
12131    prefix: &str,
12132    index: Option<&Index>,
12133) -> Result<Vec<FlatEntry>> {
12134    let entries = read_tree(odb, tree_oid)?;
12135    let mut result = Vec::new();
12136
12137    for entry in entries {
12138        let name_str = String::from_utf8_lossy(&entry.name);
12139        let path = format_path(prefix, &name_str);
12140        if is_tree_mode(entry.mode) {
12141            if index.is_some_and(|idx| sparse_index_skips_tree_descent(idx, &path, &entry.oid)) {
12142                continue;
12143            }
12144            let nested = flatten_tree_with_index(odb, &entry.oid, &path, index)?;
12145            result.extend(nested);
12146        } else {
12147            result.push(FlatEntry {
12148                path,
12149                mode: entry.mode,
12150                oid: entry.oid,
12151            });
12152        }
12153    }
12154
12155    Ok(result)
12156}
12157
12158/// Paths present in `HEAD`'s tree with mode and blob/commit OID (for status porcelain v2).
12159pub fn head_path_states(
12160    odb: &Odb,
12161    head_tree: Option<&ObjectId>,
12162) -> Result<std::collections::BTreeMap<String, (u32, ObjectId)>> {
12163    let mut m = std::collections::BTreeMap::new();
12164    let Some(t) = head_tree else {
12165        return Ok(m);
12166    };
12167    for fe in flatten_tree(odb, t, "")? {
12168        m.insert(fe.path, (fe.mode, fe.oid));
12169    }
12170    Ok(m)
12171}
12172
12173/// Whether a mode represents a tree (directory).
12174fn is_tree_mode(mode: u32) -> bool {
12175    crate::index::is_tree_mode(mode)
12176}
12177
12178/// Git orders diff lists by raw path bytes, which differs from the stored form of
12179/// non-UTF-8 paths (see [`crate::repo_relpath::storage_bytes`]).
12180fn path_order(a: &DiffEntry, b: &DiffEntry) -> std::cmp::Ordering {
12181    use crate::repo_relpath::storage_bytes;
12182    storage_bytes(a.path()).cmp(&storage_bytes(b.path()))
12183}
12184
12185fn entries_sorted_by_path(entries: &[DiffEntry]) -> bool {
12186    entries.is_sorted_by(|a, b| path_order(a, b).is_le())
12187}
12188
12189fn entries_strictly_sorted_by_path(entries: &[DiffEntry]) -> bool {
12190    entries.is_sorted_by(|a, b| path_order(a, b).is_lt())
12191}
12192
12193/// Stable-sort diff entries by [`DiffEntry::path`] in raw byte order, Git's output order
12194/// for diff lists (non-UTF-8 paths compare by their decoded bytes).
12195///
12196/// Tree diffs and rename detection already return entries in this order, so the common
12197/// case is a single comparison pass with no allocation (a stable sort would otherwise
12198/// allocate scratch space proportional to the list).
12199///
12200/// # Parameters
12201///
12202/// - `entries`: the list to order in place; entries with equal paths keep their order.
12203pub fn sort_entries_by_path(entries: &mut [DiffEntry]) {
12204    if entries_sorted_by_path(entries) {
12205        return;
12206    }
12207    entries.sort_by(path_order);
12208}
12209
12210/// Build a path with an optional prefix.
12211fn format_path(prefix: &str, name: &str) -> String {
12212    if prefix.is_empty() {
12213        name.to_owned()
12214    } else {
12215        let mut path = String::with_capacity(prefix.len() + 1 + name.len());
12216        path.push_str(prefix);
12217        path.push('/');
12218        path.push_str(name);
12219        path
12220    }
12221}
12222
12223/// Format a numeric mode as a zero-padded octal string.
12224pub fn format_mode(mode: u32) -> String {
12225    format!("{mode:06o}")
12226}
12227
12228/// Read the HEAD commit OID from a submodule checkout directory.
12229///
12230/// Returns `None` if the path is missing, not a submodule checkout, or has no resolvable HEAD.
12231#[must_use]
12232pub fn read_submodule_head_for_checkout(sub_dir: &Path) -> Option<ObjectId> {
12233    read_submodule_head(sub_dir)
12234}
12235
12236/// First line of a commit's message for `git diff --submodule=log` output.
12237///
12238/// Honors `encoding` in the commit object (Latin-1 vs UTF-8) using the same
12239/// rules as Git's submodule summary.
12240#[must_use]
12241pub fn submodule_commit_subject_line(c: &CommitData) -> String {
12242    let enc = c.encoding.as_deref().unwrap_or("UTF-8");
12243    let is_latin1 = enc.eq_ignore_ascii_case("ISO8859-1")
12244        || enc.eq_ignore_ascii_case("ISO-8859-1")
12245        || enc.eq_ignore_ascii_case("LATIN1")
12246        || enc.eq_ignore_ascii_case("ISO-8859-15");
12247    if let Some(raw) = c.raw_message.as_deref() {
12248        let line = raw.split(|b| *b == b'\n').next().unwrap_or(raw);
12249        if is_latin1 {
12250            return line
12251                .iter()
12252                .map(|&b| b as char)
12253                .collect::<String>()
12254                .trim()
12255                .to_owned();
12256        }
12257        return String::from_utf8_lossy(line).trim().to_string();
12258    }
12259    c.message.lines().next().unwrap_or("").trim().to_owned()
12260}
12261
12262/// True when a gitlink directory has no initialized submodule (no embedded `.git`), so the
12263/// recorded index OID is treated as the worktree reference (Git `ce_compare_gitlink` when
12264/// `resolve_gitlink_ref` fails).
12265fn uninitialized_gitlink_worktree_matches_index(sub_dir: &Path) -> bool {
12266    submodule_embedded_git_dir(sub_dir).is_none()
12267}
12268
12269fn read_submodule_head(sub_dir: &Path) -> Option<ObjectId> {
12270    read_submodule_head_oid(sub_dir)
12271}
12272
12273/// Resolve the embedded git directory for a submodule work tree (`sub_dir/.git`).
12274#[must_use]
12275pub fn submodule_embedded_git_dir(sub_dir: &Path) -> Option<PathBuf> {
12276    let gitfile = sub_dir.join(".git");
12277    if gitfile.is_file() {
12278        let content = fs::read_to_string(&gitfile).ok()?;
12279        let gitdir = content
12280            .lines()
12281            .find_map(|l| l.strip_prefix("gitdir: "))?
12282            .trim();
12283        Some(if Path::new(gitdir).is_absolute() {
12284            PathBuf::from(gitdir)
12285        } else {
12286            sub_dir.join(gitdir)
12287        })
12288    } else if gitfile.is_dir() {
12289        Some(gitfile)
12290    } else {
12291        None
12292    }
12293}
12294
12295/// Whether `abs` is a nested Git checkout (embedded `.git`) not already indexed as a gitlink.
12296///
12297/// Matches Git's `is_nonbare_repository_dir` / `is_git_directory`: the embedded git dir must look
12298/// like a real repository (valid `HEAD` plus `objects/`, or `objects/` and `refs/`), not a bare
12299/// `.git` directory placeholder.
12300#[must_use]
12301pub(crate) fn is_unregistered_embedded_git_repo(index: &Index, rel: &str, abs: &Path) -> bool {
12302    if rel.is_empty() {
12303        return false;
12304    }
12305    if index
12306        .entries
12307        .iter()
12308        .any(|e| e.path.as_slice() == rel.as_bytes() && e.mode == crate::index::MODE_GITLINK)
12309    {
12310        return false;
12311    }
12312    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
12313        return false;
12314    };
12315    crate::submodule_gitdir::is_valid_git_dir(&git_dir)
12316}
12317
12318/// Whether staging should record a gitlink, refuse, or treat the path as a normal directory.
12319pub(crate) enum EmbeddedGitRepoStage {
12320    /// Path is not an unregistered embedded repository.
12321    NotEmbedded,
12322    /// HEAD resolves to a commit that can be stored as mode `160000`.
12323    Gitlink(crate::objects::ObjectId),
12324}
12325
12326/// Classify a worktree path for embedded-repository staging.
12327///
12328/// Unlike [`is_unregistered_embedded_git_repo`], registered gitlinks (including unmerged
12329/// conflict stages) are still checked so an unborn nested HEAD cannot collapse conflicts.
12330///
12331/// Returns [`EmbeddedGitRepoStage::NotEmbedded`] when the path is not a nested checkout.
12332/// Returns [`EmbeddedGitRepoStage::Gitlink`] when HEAD resolves. Otherwise returns
12333/// [`crate::error::Error::EmbeddedRepoWithoutCommit`].
12334pub(crate) fn classify_embedded_git_repo_for_stage(
12335    _index: &Index,
12336    rel: &str,
12337    abs: &Path,
12338) -> crate::error::Result<EmbeddedGitRepoStage> {
12339    if rel.is_empty() {
12340        return Ok(EmbeddedGitRepoStage::NotEmbedded);
12341    }
12342    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
12343        return Ok(EmbeddedGitRepoStage::NotEmbedded);
12344    };
12345    if !crate::submodule_gitdir::is_valid_git_dir(&git_dir) {
12346        return Ok(EmbeddedGitRepoStage::NotEmbedded);
12347    }
12348    let path = rel.trim_end_matches('/').to_owned();
12349    match read_submodule_head_oid_for_stage(abs) {
12350        Some(oid) => Ok(EmbeddedGitRepoStage::Gitlink(oid)),
12351        None => Err(crate::error::Error::EmbeddedRepoWithoutCommit { path }),
12352    }
12353}
12354
12355/// Walk upward from `sub_dir` to find the nearest containing Git work tree.
12356fn find_superproject_git(sub_dir: &Path) -> Option<(PathBuf, PathBuf)> {
12357    let mut cur = sub_dir.parent()?;
12358    loop {
12359        let git_path = cur.join(".git");
12360        if git_path.exists() {
12361            let gd = if git_path.is_file() {
12362                let content = fs::read_to_string(&git_path).ok()?;
12363                let line = content
12364                    .lines()
12365                    .find_map(|l| l.strip_prefix("gitdir: "))?
12366                    .trim();
12367                if Path::new(line).is_absolute() {
12368                    PathBuf::from(line)
12369                } else {
12370                    cur.join(line)
12371                }
12372            } else {
12373                git_path
12374            };
12375            return Some((cur.to_path_buf(), gd));
12376        }
12377        cur = cur.parent()?;
12378    }
12379}
12380
12381fn submodule_git_dir_for_head_read(sub_dir: &Path) -> Option<PathBuf> {
12382    let mut git_dir = submodule_embedded_git_dir(sub_dir)?;
12383    if let Some((super_wt, super_git_dir)) = find_superproject_git(sub_dir) {
12384        let rel = sub_dir.strip_prefix(&super_wt).ok()?;
12385        let rel_str = rel.to_string_lossy().replace('\\', "/");
12386        let local_mod = super_git_dir
12387            .join("modules")
12388            .join(rel_str.trim_start_matches('/'));
12389        if local_mod.join("HEAD").exists() {
12390            let sg = super_git_dir
12391                .canonicalize_simplified()
12392                .unwrap_or(super_git_dir);
12393            let cur = git_dir
12394                .canonicalize_simplified()
12395                .unwrap_or_else(|_| git_dir.clone());
12396            if !cur.starts_with(&sg) {
12397                git_dir = local_mod;
12398            }
12399        }
12400    }
12401    Some(git_dir)
12402}
12403
12404fn read_submodule_head_oid_from_git_dir(
12405    git_dir: &Path,
12406    allow_branch_fallback: bool,
12407) -> Option<ObjectId> {
12408    let head_content = fs::read_to_string(git_dir.join("HEAD")).ok()?;
12409    let head_trimmed = head_content.trim();
12410    if head_trimmed.starts_with("ref: ") {
12411        match crate::refs::resolve_ref(git_dir, "HEAD") {
12412            Ok(oid) => Some(oid),
12413            Err(_) if allow_branch_fallback => {
12414                for branch in ["main", "master"] {
12415                    let p = git_dir.join("refs/heads").join(branch);
12416                    if let Ok(s) = fs::read_to_string(&p) {
12417                        if let Ok(o) = ObjectId::from_hex(s.trim()) {
12418                            return Some(o);
12419                        }
12420                    }
12421                }
12422                None
12423            }
12424            Err(_) => None,
12425        }
12426    } else {
12427        ObjectId::from_hex(head_trimmed).ok()
12428    }
12429}
12430
12431/// Read the HEAD commit OID from a submodule working tree directory.
12432///
12433/// Handles both embedded `.git` directories and `gitdir:` gitfiles pointing at
12434/// `.git/modules/...` (or other locations). Returns `None` if the path is not
12435/// a checkout or has no resolvable HEAD.
12436pub fn read_submodule_head_oid(sub_dir: &Path) -> Option<ObjectId> {
12437    let git_dir = submodule_git_dir_for_head_read(sub_dir)?;
12438    read_submodule_head_oid_from_git_dir(&git_dir, true)
12439}
12440
12441/// Like [`read_submodule_head_oid`], but requires `HEAD` itself to resolve (no `main`/`master`
12442/// fallback). Used when staging embedded repositories so a broken symref matches Git's refusal.
12443pub(crate) fn read_submodule_head_oid_for_stage(sub_dir: &Path) -> Option<ObjectId> {
12444    let git_dir = submodule_git_dir_for_head_read(sub_dir)?;
12445    read_submodule_head_oid_from_git_dir(&git_dir, false)
12446}
12447
12448/// True when a populated submodule checkout is *broken*: its `HEAD` resolves to a commit OID, but
12449/// that commit object cannot be read from the submodule's own object database.
12450///
12451/// This mirrors Git's `is_submodule_modified`, which shells out to `git status --porcelain=2`
12452/// inside the submodule; when the submodule's object store is corrupt (e.g. `rm -r .git/objects`),
12453/// that inner status fails and Git aborts the surrounding `status`/`diff`/`fetch`. We detect the
12454/// same condition in-process so the superproject operation can return a fatal error rather than
12455/// silently treating the submodule as clean (t5526 "fetching submodule into a broken repository").
12456///
12457/// Returns `false` when the submodule is not checked out, has no embedded git dir, or has an
12458/// unresolvable HEAD (those are handled separately by the unpopulated/placeholder logic).
12459#[must_use]
12460pub fn submodule_head_object_broken(sub_dir: &Path) -> bool {
12461    let Some(sub_git_dir) = submodule_embedded_git_dir(sub_dir) else {
12462        return false;
12463    };
12464    let Some(head_oid) = read_submodule_head_oid(sub_dir) else {
12465        return false;
12466    };
12467    let odb = Odb::new(&sub_git_dir.join("objects"));
12468    match odb.read(&head_oid) {
12469        // HEAD object present but not a commit would be a different kind of corruption; only the
12470        // missing-object case is the broken-repo scenario Git guards against here.
12471        Ok(obj) => parse_commit(&obj.data).is_err(),
12472        Err(_) => true,
12473    }
12474}
12475
12476/// Submodule dirty bits aligned with Git's `DIRTY_SUBMODULE_*` / porcelain v2 `S???` token.
12477#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12478pub struct SubmodulePorcelainFlags {
12479    /// Submodule checkout HEAD differs from the gitlink OID recorded in the parent index.
12480    pub new_commits: bool,
12481    /// The submodule has its own staged or unstaged changes (`DIRTY_SUBMODULE_MODIFIED`).
12482    pub modified: bool,
12483    /// The submodule work tree contains paths not in its index (`DIRTY_SUBMODULE_UNTRACKED`).
12484    pub untracked: bool,
12485}
12486
12487/// Inspect a checked-out submodule at `rel_path` (relative to `super_worktree`) and return
12488/// flags used for `git status --porcelain=v2` submodule tokens.
12489///
12490/// `recorded_oid` is the gitlink OID stored in the **parent** index (stage 0). When the
12491/// submodule is not checked out or cannot be opened, returns [`Default::default()`].
12492pub fn submodule_porcelain_flags(
12493    super_worktree: &Path,
12494    rel_path: &str,
12495    recorded_oid: ObjectId,
12496) -> SubmodulePorcelainFlags {
12497    let sub_dir = super_worktree.join(rel_path);
12498    let Some(sub_git_dir) = submodule_embedded_git_dir(&sub_dir) else {
12499        return SubmodulePorcelainFlags::default();
12500    };
12501    let Some(sub_head) = read_submodule_head_oid(&sub_dir) else {
12502        return SubmodulePorcelainFlags::default();
12503    };
12504
12505    let new_commits = sub_head != recorded_oid;
12506
12507    let index_path = sub_git_dir.join("index");
12508    let mut sub_index = match crate::index::Index::load(&index_path) {
12509        Ok(ix) => ix,
12510        Err(_) => {
12511            return SubmodulePorcelainFlags {
12512                new_commits,
12513                ..Default::default()
12514            }
12515        }
12516    };
12517
12518    let tracked: std::collections::BTreeSet<String> = sub_index
12519        .entries
12520        .iter()
12521        .filter(|e| e.stage() == 0)
12522        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
12523        .collect();
12524    let untracked = submodule_dir_has_untracked_inner(&sub_dir, &sub_dir, &tracked, &sub_index);
12525
12526    let objects_dir = sub_git_dir.join("objects");
12527    let odb = Odb::new(&objects_dir);
12528
12529    let sub_head_tree = (|| -> Option<ObjectId> {
12530        let h = fs::read_to_string(sub_git_dir.join("HEAD")).ok()?;
12531        let h_str = h.trim();
12532        let commit_oid = if let Some(r) = h_str.strip_prefix("ref: ") {
12533            let oid_hex = fs::read_to_string(sub_git_dir.join(r)).ok()?;
12534            ObjectId::from_hex(oid_hex.trim()).ok()?
12535        } else {
12536            ObjectId::from_hex(h_str).ok()?
12537        };
12538        let obj = odb.read(&commit_oid).ok()?;
12539        let commit = parse_commit(&obj.data).ok()?;
12540        Some(commit.tree)
12541    })();
12542
12543    let staged_dirty = sub_head_tree
12544        .as_ref()
12545        .map(|t| diff_index_to_tree(&odb, &sub_index, Some(t), false).map(|v| !v.is_empty()))
12546        .unwrap_or(Ok(false));
12547    let staged_dirty = staged_dirty.unwrap_or(false);
12548
12549    let unstaged_dirty = diff_index_to_worktree(&odb, &mut sub_index, &sub_dir, false, true)
12550        .map(|v| !v.is_empty())
12551        .unwrap_or(false);
12552
12553    let mut modified = staged_dirty || unstaged_dirty;
12554
12555    // Nested submodule has its own index: OR `modified` from immediate gitlink children so a
12556    // dirty nested checkout (e.g. staged `file` under `sub1/sub2`) marks the parent gitlink as
12557    // modified in the superproject (t7506). Do **not** OR `untracked` — untracked-only inside a
12558    // nested submodule must stay `S..U` on the parent, not `S.U` / `S.M.`.
12559    for e in &sub_index.entries {
12560        if e.stage() != 0 || e.mode != 0o160000 {
12561            continue;
12562        }
12563        let child = String::from_utf8_lossy(&e.path).into_owned();
12564        let full_rel = if rel_path.is_empty() {
12565            child
12566        } else {
12567            format!("{rel_path}/{child}")
12568        };
12569        let nested = submodule_porcelain_flags(super_worktree, &full_rel, e.oid);
12570        modified |= nested.modified;
12571    }
12572
12573    SubmodulePorcelainFlags {
12574        new_commits,
12575        modified,
12576        untracked,
12577    }
12578}
12579
12580fn submodule_dir_has_untracked_inner(
12581    dir: &Path,
12582    root: &Path,
12583    tracked: &std::collections::BTreeSet<String>,
12584    owning_index: &Index,
12585) -> bool {
12586    let entries = match fs::read_dir(dir) {
12587        Ok(e) => e,
12588        Err(_) => return false,
12589    };
12590    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
12591    sorted.sort_by_key(|e| e.file_name());
12592
12593    for entry in sorted {
12594        let name = entry.file_name().to_string_lossy().to_string();
12595        if name == ".git" {
12596            continue;
12597        }
12598        let path = entry.path();
12599        let rel = path
12600            .strip_prefix(root)
12601            .map(|p| p.to_string_lossy().to_string())
12602            .unwrap_or_else(|_| name.clone());
12603
12604        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
12605        if is_dir {
12606            let is_gitlink = owning_index
12607                .get(rel.as_bytes(), 0)
12608                .is_some_and(|e| e.mode == 0o160000);
12609            if is_gitlink {
12610                let Some(nested_git) = submodule_embedded_git_dir(&path) else {
12611                    continue;
12612                };
12613                let nested_index_path = nested_git.join("index");
12614                let Ok(nested_ix) = crate::index::Index::load(&nested_index_path) else {
12615                    continue;
12616                };
12617                let nested_tracked: std::collections::BTreeSet<String> = nested_ix
12618                    .entries
12619                    .iter()
12620                    .filter(|e| e.stage() == 0)
12621                    .map(|e| String::from_utf8_lossy(&e.path).into_owned())
12622                    .collect();
12623                if submodule_dir_has_untracked_inner(&path, &path, &nested_tracked, &nested_ix) {
12624                    return true;
12625                }
12626            } else if submodule_dir_has_untracked_inner(&path, root, tracked, owning_index) {
12627                return true;
12628            }
12629        } else if !tracked.contains(&rel) {
12630            return true;
12631        }
12632    }
12633    false
12634}
12635
12636#[cfg(test)]
12637mod line_compare_key_tests {
12638    use super::*;
12639    use crate::diff::unified_emit::line_diff_token;
12640    use crate::diff::WhitespaceMode;
12641
12642    #[test]
12643    fn borrowed_keys_match_owned_line_tokens() {
12644        let texts = [
12645            "",
12646            "\n",
12647            "a",
12648            "a\n",
12649            "a\nb",
12650            "a\r\nb\r\n",
12651            "a\r\nb\r",
12652            "\n\n\nlast",
12653            "x\n\r\n\ry\n",
12654        ];
12655        for text in texts {
12656            let side = LineSide::from_text(text);
12657            let mut normalized = Vec::new();
12658            let keys = line_compare_keys(text, &side, WhitespaceMode::default(), &mut normalized);
12659            let expected: Vec<String> = side.lines.iter().map(line_diff_token).collect();
12660            assert_eq!(keys, expected, "text {text:?}");
12661            let source = text.as_bytes().as_ptr_range();
12662            assert!(
12663                keys.iter()
12664                    .all(|key| source.contains(&key.as_ptr()) || key.is_empty()),
12665                "text {text:?}"
12666            );
12667        }
12668    }
12669
12670    #[test]
12671    fn whitespace_modes_compare_normalized_bodies() {
12672        let text = "a  b\nab \n";
12673        let side = LineSide::from_text(text);
12674        let ws = WhitespaceMode {
12675            ignore_all_space: true,
12676            ..WhitespaceMode::default()
12677        };
12678        let mut normalized = Vec::new();
12679        let keys = line_compare_keys(text, &side, ws, &mut normalized);
12680        assert_eq!(keys, vec!["ab", "ab"]);
12681    }
12682
12683    #[test]
12684    fn xdiff_marks_only_edited_lines_in_large_file() {
12685        let old: String = (0..5_000).map(|i| format!("line{i}\n")).collect();
12686        let new: String = (0..5_000)
12687            .map(|i| {
12688                if i % 100 == 0 {
12689                    format!("LINE{i}\n")
12690                } else {
12691                    format!("line{i}\n")
12692                }
12693            })
12694            .collect();
12695        let body =
12696            git_xdiff_unified_body_raw(&old, &new, 3, 0, true, false, WhitespaceMode::default());
12697        assert_eq!(body.matches("\n@@ ").count() + 1, 50);
12698        assert_eq!(body.lines().filter(|l| l.starts_with('-')).count(), 50);
12699        assert_eq!(body.lines().filter(|l| l.starts_with('+')).count(), 50);
12700        assert!(body.starts_with("@@ -1,4 +1,4 @@\n-line0\n+LINE0\n line1\n"));
12701    }
12702}
12703
12704#[cfg(test)]
12705mod entry_order_tests {
12706    use super::*;
12707
12708    fn entry(status: DiffStatus, path: &str, score: Option<u32>) -> DiffEntry {
12709        let mode = |present: bool| {
12710            if present {
12711                FileMode::REGULAR
12712            } else {
12713                FileMode::ABSENT
12714            }
12715        };
12716        DiffEntry {
12717            status,
12718            old_path: (status != DiffStatus::Added).then(|| path.to_owned()),
12719            new_path: (status != DiffStatus::Deleted).then(|| path.to_owned()),
12720            old_mode: mode(status != DiffStatus::Added),
12721            new_mode: mode(status != DiffStatus::Deleted),
12722            old_oid: ObjectId::zero(),
12723            new_oid: ObjectId::zero(),
12724            score,
12725        }
12726    }
12727
12728    #[test]
12729    fn sort_entries_by_path_orders_by_path_and_keeps_equal_paths_in_place() {
12730        let mut entries = vec![
12731            entry(DiffStatus::Modified, "b", None),
12732            entry(DiffStatus::Deleted, "a/x", Some(1)),
12733            entry(DiffStatus::Added, "a/x", Some(2)),
12734            entry(DiffStatus::Added, "a", None),
12735        ];
12736        sort_entries_by_path(&mut entries);
12737        let order: Vec<_> = entries.iter().map(|e| (e.path(), e.score)).collect();
12738        assert_eq!(
12739            order,
12740            [("a", None), ("a/x", Some(1)), ("a/x", Some(2)), ("b", None)]
12741        );
12742        let sorted = entries.clone();
12743        sort_entries_by_path(&mut entries);
12744        assert_eq!(entries, sorted);
12745        sort_entries_by_path(&mut []);
12746    }
12747
12748    #[test]
12749    fn sort_entries_by_path_compares_non_utf8_paths_by_raw_bytes() {
12750        let raw = crate::repo_relpath::format_path_stored("", b"raw-\xff");
12751        assert!(raw.starts_with("grit+pathhex:"));
12752        let mut entries = vec![
12753            entry(DiffStatus::Modified, &raw, None),
12754            entry(DiffStatus::Modified, "node/leaf", None),
12755            entry(DiffStatus::Modified, "zeta", None),
12756        ];
12757        sort_entries_by_path(&mut entries);
12758        let paths: Vec<_> = entries.iter().map(DiffEntry::path).collect();
12759        assert_eq!(paths, ["node/leaf", raw.as_str(), "zeta"]);
12760    }
12761
12762    #[test]
12763    fn rename_pass_returns_unpairable_lists_in_path_order() {
12764        let config = ConfigSet::new();
12765        let policy = ThreadPolicy::default();
12766        let odb = Odb::new(Path::new("/nonexistent-objects"));
12767        let run = |entries: Vec<DiffEntry>| {
12768            detect_renames_with_policy(&odb, None, entries, 50, &config, &policy, None)
12769                .expect("no blob is read without both adds and deletes")
12770        };
12771        let sorted = vec![
12772            entry(DiffStatus::Deleted, "a", None),
12773            entry(DiffStatus::Modified, "b", None),
12774            entry(DiffStatus::Deleted, "c", None),
12775        ];
12776        assert_eq!(run(sorted.clone()), sorted);
12777        let unsorted = vec![
12778            entry(DiffStatus::Added, "z", None),
12779            entry(DiffStatus::Modified, "m", None),
12780            entry(DiffStatus::Added, "a", None),
12781        ];
12782        let paths: Vec<_> = run(unsorted).iter().map(|e| e.path().to_owned()).collect();
12783        assert_eq!(paths, ["a", "m", "z"]);
12784        // A duplicate path takes the regrouping path: modifications before additions.
12785        let duplicate = vec![
12786            entry(DiffStatus::Added, "p", None),
12787            entry(DiffStatus::Modified, "p", None),
12788        ];
12789        let statuses: Vec<_> = run(duplicate).iter().map(|e| e.status).collect();
12790        assert_eq!(statuses, [DiffStatus::Modified, DiffStatus::Added]);
12791    }
12792}
12793
12794#[cfg(test)]
12795mod diff_index_to_tree_cache_tree_tests {
12796    use std::rc::Rc;
12797    use std::sync::atomic::{AtomicUsize, Ordering};
12798
12799    use super::*;
12800    use crate::index::{Index, IndexEntry, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
12801    use crate::objects::ObjectKind;
12802    use crate::write_tree::{build_cache_tree_from_index, write_tree_from_index};
12803    use tempfile::TempDir;
12804
12805    struct TreeReadCounter {
12806        counter: Rc<AtomicUsize>,
12807    }
12808
12809    impl TreeReadCounter {
12810        fn start() -> Self {
12811            let counter = Rc::new(AtomicUsize::new(0));
12812            set_diff_tree_read_hook_for_tests(Some(counter.clone()));
12813            Self { counter }
12814        }
12815
12816        fn get(&self) -> usize {
12817            self.counter.load(Ordering::Relaxed)
12818        }
12819    }
12820
12821    impl Drop for TreeReadCounter {
12822        fn drop(&mut self) {
12823            set_diff_tree_read_hook_for_tests(None);
12824        }
12825    }
12826
12827    fn diff_index_to_tree_flatten_reference(
12828        odb: &Odb,
12829        index: &Index,
12830        head_tree: &ObjectId,
12831        ignore_submodules: bool,
12832    ) -> Result<Vec<DiffEntry>> {
12833        let mut flat_index = index.clone();
12834        flat_index.clear_cache_tree();
12835        diff_index_to_tree(odb, &flat_index, Some(head_tree), ignore_submodules)
12836    }
12837
12838    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
12839        IndexEntry {
12840            ctime_sec: 0,
12841            ctime_nsec: 0,
12842            mtime_sec: 0,
12843            mtime_nsec: 0,
12844            dev: 0,
12845            ino: 0,
12846            mode,
12847            uid: 0,
12848            gid: 0,
12849            size: 0,
12850            oid,
12851            flags: path.len().min(0xfff) as u16,
12852            flags_extended: None,
12853            path: path.as_bytes().to_vec(),
12854            base_index_pos: 0,
12855        }
12856    }
12857
12858    fn repo_with_tree(depth: usize, width: usize) -> (TempDir, Odb, ObjectId, Index) {
12859        let temp = TempDir::new().expect("tempdir");
12860        let odb = Odb::new(temp.path());
12861        let mut index = Index::new();
12862        for d in 0..depth {
12863            for w in 0..width {
12864                let path = format!("unchanged/d{d}/f{w}.txt");
12865                let oid = odb
12866                    .write(ObjectKind::Blob, format!("body-{d}-{w}").as_bytes())
12867                    .expect("blob");
12868                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
12869            }
12870        }
12871        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
12872        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
12873        index.set_cache_tree(cache);
12874        index.cache_tree_root = Some(head_tree);
12875        (temp, odb, head_tree, index)
12876    }
12877
12878    #[test]
12879    fn diff_index_to_tree_cache_tree_fast_path_matches_slow() {
12880        let (_temp, odb, head_tree, mut index) = repo_with_tree(4, 5);
12881        assert!(index.cache_tree.as_ref().is_some_and(|c| c.is_valid()));
12882
12883        let mut state: u64 = 0xDEAD_BEEF;
12884        for _ in 0..24 {
12885            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
12886            let d = (state % 4) as usize;
12887            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
12888            let w = (state % 5) as usize;
12889            let path = format!("unchanged/d{d}/f{w}.txt");
12890            index.invalidate_cache_tree_for_path(path.as_bytes());
12891            assert!(
12892                index.cache_tree_root.is_none(),
12893                "partial invalidation clears cache_tree_root like Index::add"
12894            );
12895            assert!(
12896                index.cache_tree.as_ref().is_some_and(|r| !r.is_valid()),
12897                "root cache node must be invalid after partial invalidation"
12898            );
12899            if let Some(e) = index.get_mut(path.as_bytes(), 0) {
12900                let new_oid = odb
12901                    .write(ObjectKind::Blob, format!("new-{d}-{w}").as_bytes())
12902                    .expect("blob");
12903                e.oid = new_oid;
12904            }
12905            let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12906            let flat = diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false)
12907                .expect("flat");
12908            assert_eq!(fast, flat, "mismatch after mutating {path}");
12909        }
12910    }
12911
12912    #[test]
12913    fn diff_index_to_tree_skips_unchanged_subtrees() {
12914        let (_temp, odb, head_tree, index) = repo_with_tree(5, 6);
12915        let reads = TreeReadCounter::start();
12916        diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("cached diff");
12917        let cached_reads = reads.get();
12918
12919        let mut no_cache = index.clone();
12920        no_cache.clear_cache_tree();
12921        let reads = TreeReadCounter::start();
12922        diff_index_to_tree(&odb, &no_cache, Some(&head_tree), false).expect("flat diff");
12923        let flat_reads = reads.get();
12924
12925        assert!(
12926            cached_reads < flat_reads,
12927            "expected fewer tree reads with cache-tree (cached={cached_reads}, flat={flat_reads})"
12928        );
12929        assert_eq!(
12930            cached_reads, 1,
12931            "unchanged repo should only read the root tree"
12932        );
12933    }
12934
12935    #[test]
12936    fn diff_index_to_tree_partial_invalidation_skips_sibling_subtree() {
12937        let (_temp, odb, head_tree, mut index) = repo_with_tree(5, 6);
12938        let path = "unchanged/d1/f2.txt";
12939        index.invalidate_cache_tree_for_path(path.as_bytes());
12940        let new_oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
12941        index.get_mut(path.as_bytes(), 0).expect("entry").oid = new_oid;
12942
12943        let flat =
12944            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
12945        let reads = TreeReadCounter::start();
12946        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12947        let cached_reads = reads.get();
12948        assert_eq!(fast, flat);
12949
12950        let reads = TreeReadCounter::start();
12951        diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
12952        let flat_reads = reads.get();
12953
12954        assert!(
12955            cached_reads < flat_reads,
12956            "partial cache-tree should skip sibling dirs (cached={cached_reads}, flat={flat_reads})"
12957        );
12958    }
12959
12960    fn cache_leaf(name: &str) -> CacheTreeNode {
12961        CacheTreeNode::valid(name.as_bytes().to_vec(), 1, ObjectId::zero(), Vec::new())
12962    }
12963
12964    #[test]
12965    fn cache_children_lookup_matches_linear_scan_on_wide_unsorted_nodes() {
12966        let mut children: Vec<CacheTreeNode> = (0..CACHE_CHILD_LINEAR_SCAN_MAX * 3)
12967            .rev()
12968            .map(|i| cache_leaf(&format!("d{i:03}")))
12969            .collect();
12970        let mut first_dup = cache_leaf("d007");
12971        first_dup.entry_count = 7;
12972        children.insert(3, first_dup);
12973        let root = CacheTreeNode::valid(Vec::new(), 0, ObjectId::zero(), children);
12974
12975        let lookup = CacheChildren::new(Some(&root));
12976        assert!(
12977            !lookup.sorted.is_empty(),
12978            "wide nodes must use the sorted lookup"
12979        );
12980        for name in ["d000", "d007", "d020", "d047", "zzz", "d", ""] {
12981            let linear = root.children.iter().find(|c| c.name == name.as_bytes());
12982            let fast = lookup.get(name.as_bytes());
12983            assert_eq!(
12984                fast.map(|c| (&c.name, c.entry_count)),
12985                linear.map(|c| (&c.name, c.entry_count)),
12986                "lookup of {name:?}"
12987            );
12988        }
12989        assert_eq!(lookup.get(b"d007").map(|c| c.entry_count), Some(7));
12990
12991        let narrow = CacheTreeNode::valid(
12992            Vec::new(),
12993            0,
12994            ObjectId::zero(),
12995            vec![cache_leaf("b"), cache_leaf("a")],
12996        );
12997        let lookup = CacheChildren::new(Some(&narrow));
12998        assert!(lookup.sorted.is_empty());
12999        assert_eq!(lookup.get(b"a").map(|c| c.name.as_slice()), Some(&b"a"[..]));
13000        assert!(lookup.get(b"c").is_none());
13001        assert!(CacheChildren::new(None).get(b"a").is_none());
13002    }
13003
13004    #[test]
13005    fn index_relative_in_dir_requires_a_component_boundary() {
13006        assert_eq!(index_relative_in_dir(b"a/b", b""), Some(&b"a/b"[..]));
13007        assert_eq!(index_relative_in_dir(b"a/b", b"a"), Some(&b"b"[..]));
13008        assert_eq!(index_relative_in_dir(b"a", b"a"), Some(&b""[..]));
13009        assert_eq!(index_relative_in_dir(b"ab/c", b"a"), None);
13010        assert_eq!(index_relative_in_dir(b"a.c", b"a"), None);
13011        assert_eq!(index_relative_in_dir(b"b", b"a"), None);
13012        assert_eq!(join_index_path(b"", b"x"), b"x".to_vec());
13013        assert_eq!(join_index_path(b"a/b", b"x"), b"a/b/x".to_vec());
13014    }
13015
13016    /// Wide root (more children than the linear-scan cutoff), cache-tree children stored in
13017    /// reverse order, a few changed paths, plus additions and deletions at both levels.
13018    #[test]
13019    fn diff_index_to_tree_wide_unsorted_cache_tree_matches_flatten() {
13020        let temp = TempDir::new().expect("tempdir");
13021        let odb = Odb::new(temp.path());
13022        let mut index = Index::new();
13023        let width = CACHE_CHILD_LINEAR_SCAN_MAX * 4;
13024        for d in 0..width {
13025            for f in 0..2 {
13026                let path = format!("dir{d:03}/sub/f{f}.txt");
13027                let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
13028                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
13029            }
13030        }
13031        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
13032        let mut cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
13033        cache.children.reverse();
13034        index.set_cache_tree(cache);
13035        index.cache_tree_root = Some(head_tree);
13036
13037        let changed = [
13038            "dir003/sub/f1.txt",
13039            "dir031/sub/f0.txt",
13040            "dir063/sub/f1.txt",
13041        ];
13042        for path in changed {
13043            index.invalidate_cache_tree_for_path(path.as_bytes());
13044            let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
13045            index.get_mut(path.as_bytes(), 0).expect("entry").oid = oid;
13046        }
13047        for path in ["dir010/sub/new.txt", "dir999/new.txt", "top.txt"] {
13048            index.invalidate_cache_tree_for_path(path.as_bytes());
13049            let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
13050            index.add_or_replace(index_entry(path, MODE_REGULAR, oid));
13051        }
13052        for path in [
13053            "dir020/sub/f0.txt",
13054            "dir020/sub/f1.txt",
13055            "dir021/sub/f0.txt",
13056        ] {
13057            index.invalidate_cache_tree_for_path(path.as_bytes());
13058            index.remove(path.as_bytes());
13059        }
13060
13061        let flat =
13062            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
13063        let reads = TreeReadCounter::start();
13064        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
13065        let cached_reads = reads.get();
13066        assert_eq!(fast, flat);
13067        assert_eq!(fast.len(), changed.len() + 3 + 3);
13068        assert!(
13069            cached_reads <= 1 + 2 * 6,
13070            "unchanged siblings must be skipped through the cache-tree (reads={cached_reads})"
13071        );
13072    }
13073
13074    #[test]
13075    fn diff_index_to_tree_cache_tree_handles_non_utf8_directory_names() {
13076        let temp = TempDir::new().expect("tempdir");
13077        let odb = Odb::new(temp.path());
13078        let mut index = Index::new();
13079        let raw_dirs: [&[u8]; 3] = [b"caf\xe9", b"caf\xef\xbf\xbd", b"plain"];
13080        for dir in raw_dirs {
13081            for f in [&b"a.txt"[..], b"b.txt"] {
13082                let path = [dir, b"/", f].concat();
13083                let oid = odb.write(ObjectKind::Blob, &path).expect("blob");
13084                let mut entry = index_entry("x", MODE_REGULAR, oid);
13085                entry.flags = path.len().min(0xfff) as u16;
13086                entry.path = path;
13087                index.add_or_replace(entry);
13088            }
13089        }
13090        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
13091        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
13092        index.set_cache_tree(cache);
13093        index.cache_tree_root = Some(head_tree);
13094
13095        let changed: &[u8] = b"caf\xe9/b.txt";
13096        index.invalidate_cache_tree_for_path(changed);
13097        let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
13098        index.get_mut(changed, 0).expect("entry").oid = oid;
13099
13100        // Directory prefixes are compared as raw bytes, so `caf\xe9` and a directory literally
13101        // named `caf\u{fffd}` stay distinct even though they print the same.
13102        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
13103        assert_eq!(fast.len(), 1, "{fast:?}");
13104        assert_eq!(fast[0].status, DiffStatus::Modified);
13105        assert_eq!(
13106            fast[0].old_oid,
13107            odb.write(ObjectKind::Blob, changed).expect("blob")
13108        );
13109        assert_eq!(fast[0].new_oid, oid);
13110    }
13111
13112    #[test]
13113    fn diff_index_to_tree_reports_mode_gitlink_and_intent_to_add() {
13114        let temp = TempDir::new().expect("tempdir");
13115        let odb = Odb::new(temp.path());
13116        let blob = odb.write(ObjectKind::Blob, b"x").expect("blob");
13117        let mut index = Index::new();
13118        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, blob));
13119        let head_tree = write_tree_from_index(&odb, &index, "").expect("tree");
13120        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
13121        index.set_cache_tree(cache);
13122
13123        let mode_entry = index_entry("tracked.txt", MODE_EXECUTABLE, blob);
13124        index.add_or_replace(mode_entry);
13125        index.invalidate_cache_tree_for_path(b"tracked.txt");
13126
13127        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
13128        assert_eq!(diffs.len(), 1);
13129        assert_eq!(diffs[0].status, DiffStatus::Modified);
13130        assert_eq!(diffs[0].old_mode, FileMode::new(MODE_REGULAR));
13131        assert_eq!(diffs[0].new_mode, FileMode::new(MODE_EXECUTABLE));
13132
13133        // Intent-to-add is omitted from staged diff.
13134        let mut ita = index_entry("new.txt", MODE_REGULAR, blob);
13135        ita.set_intent_to_add(true);
13136        index.add_or_replace(ita);
13137        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
13138        assert!(
13139            diffs.iter().all(|d| d.path() != "new.txt"),
13140            "intent-to-add must not appear in staged diff"
13141        );
13142
13143        // Gitlink with ignore_submodules skips diff entry but still matches tree.
13144        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13145            .parse()
13146            .expect("oid");
13147        let mut sub_index = Index::new();
13148        sub_index.add_or_replace(index_entry("sub", MODE_GITLINK, gitlink));
13149        let sub_tree = write_tree_from_index(&odb, &sub_index, "").expect("tree");
13150        let sub_cache = build_cache_tree_from_index(&odb, &sub_index).expect("cache");
13151        sub_index.set_cache_tree(sub_cache);
13152        let diffs =
13153            diff_index_to_tree(&odb, &sub_index, Some(&sub_tree), true).expect("gitlink diff");
13154        assert!(
13155            diffs.is_empty(),
13156            "ignored gitlink should not diff: {diffs:?}"
13157        );
13158    }
13159
13160    #[test]
13161    fn ignore_submodules_skips_gitlink_in_added_directory_cache_walk() {
13162        let temp = TempDir::new().expect("tempdir");
13163        let odb = Odb::new(temp.path());
13164        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13165            .parse()
13166            .expect("oid");
13167        let head_tree = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
13168        let mut index = Index::new();
13169        index.add_or_replace(index_entry("newdir/sub", MODE_GITLINK, gitlink));
13170        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
13171        index.set_cache_tree(cache);
13172        index.invalidate_cache_tree_for_path(b"newdir/sub");
13173
13174        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
13175        let flat =
13176            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
13177        assert_eq!(fast, flat);
13178        assert!(
13179            fast.is_empty(),
13180            "nested gitlinks in added dirs must be ignored: {fast:?}"
13181        );
13182    }
13183
13184    #[test]
13185    fn ignore_submodules_skips_gitlink_in_deleted_directory_cache_walk() {
13186        let temp = TempDir::new().expect("tempdir");
13187        let odb = Odb::new(temp.path());
13188        let blob = odb.write(ObjectKind::Blob, b"keep").expect("blob");
13189        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13190            .parse()
13191            .expect("oid");
13192        let mut index = Index::new();
13193        index.add_or_replace(index_entry("keep.txt", MODE_REGULAR, blob));
13194        index.add_or_replace(index_entry("olddir/sub", MODE_GITLINK, gitlink));
13195        let head_tree = write_tree_from_index(&odb, &index, "").expect("head");
13196        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
13197        index.set_cache_tree(cache);
13198        index
13199            .entries
13200            .retain(|e| !String::from_utf8_lossy(&e.path).starts_with("olddir/"));
13201        index.invalidate_cache_tree_for_path(b"olddir/sub");
13202
13203        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
13204        let flat =
13205            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
13206        assert_eq!(fast, flat);
13207        assert!(
13208            fast.is_empty(),
13209            "nested gitlinks in deleted dirs must be ignored: {fast:?}"
13210        );
13211    }
13212
13213    #[test]
13214    fn diff_trees_skips_identical_subtree_oids() {
13215        let temp = TempDir::new().expect("tempdir");
13216        let odb = Odb::new(temp.path());
13217        let leaf = odb.write(ObjectKind::Blob, b"leaf").expect("blob");
13218        let mut deep = Index::new();
13219        for i in 0..20 {
13220            deep.add_or_replace(index_entry(&format!("deep/n{i}.txt"), MODE_REGULAR, leaf));
13221        }
13222        let tree = write_tree_from_index(&odb, &deep, "").expect("tree");
13223        let reads = TreeReadCounter::start();
13224        let entries = diff_trees(&odb, Some(&tree), Some(&tree), "").expect("diff");
13225        assert!(entries.is_empty());
13226        assert_eq!(
13227            reads.get(),
13228            0,
13229            "identical tree oids should short-circuit without reading tree objects"
13230        );
13231    }
13232
13233    #[test]
13234    fn diff_tree_path_change_count_matches_diff_trees() {
13235        let temp = TempDir::new().expect("tempdir");
13236        let odb = Odb::new(temp.path());
13237        let a = odb.write(ObjectKind::Blob, b"a").expect("blob");
13238        let b = odb.write(ObjectKind::Blob, b"b").expect("blob");
13239        let mut left = Index::new();
13240        left.add_or_replace(index_entry("f.txt", MODE_REGULAR, a));
13241        let mut right = Index::new();
13242        right.add_or_replace(index_entry("f.txt", MODE_REGULAR, b));
13243        right.add_or_replace(index_entry("g.txt", MODE_REGULAR, a));
13244        let old_tree = write_tree_from_index(&odb, &left, "").expect("old");
13245        let new_tree = write_tree_from_index(&odb, &right, "").expect("new");
13246        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
13247        let count =
13248            diff_tree_path_change_count(&odb, Some(&old_tree), Some(&new_tree), "").expect("count");
13249        assert_eq!(count, entries.len());
13250        assert_eq!(count, 2);
13251    }
13252
13253    #[test]
13254    fn diff_trees_matches_whether_trees_were_cached_parsed_or_raw() {
13255        let temp = TempDir::new().expect("tempdir");
13256        let a = Odb::new(temp.path())
13257            .write(ObjectKind::Blob, b"a")
13258            .expect("blob");
13259        let b = Odb::new(temp.path())
13260            .write(ObjectKind::Blob, b"b")
13261            .expect("blob");
13262        let mut left = Index::new();
13263        let mut right = Index::new();
13264        for i in 0..50 {
13265            left.add_or_replace(index_entry(&format!("same{i:02}.txt"), MODE_REGULAR, a));
13266            right.add_or_replace(index_entry(&format!("same{i:02}.txt"), MODE_REGULAR, a));
13267        }
13268        left.add_or_replace(index_entry("dir/changed.txt", MODE_REGULAR, a));
13269        left.add_or_replace(index_entry("dir/kept.txt", MODE_REGULAR, a));
13270        left.add_or_replace(index_entry("gone/x.txt", MODE_REGULAR, a));
13271        left.add_or_replace(index_entry("mode.sh", MODE_REGULAR, a));
13272        left.add_or_replace(index_entry("swap", MODE_REGULAR, a));
13273        right.add_or_replace(index_entry("dir/changed.txt", MODE_REGULAR, b));
13274        right.add_or_replace(index_entry("dir/kept.txt", MODE_REGULAR, a));
13275        right.add_or_replace(index_entry("mode.sh", MODE_EXECUTABLE, a));
13276        right.add_or_replace(index_entry("new/y.txt", MODE_REGULAR, b));
13277        right.add_or_replace(index_entry("swap/inner.txt", MODE_REGULAR, b));
13278        let setup = Odb::new(temp.path());
13279        let old_tree = write_tree_from_index(&setup, &left, "").expect("old");
13280        let new_tree = write_tree_from_index(&setup, &right, "").expect("new");
13281
13282        let raw = diff_trees(&Odb::new(temp.path()), Some(&old_tree), Some(&new_tree), "")
13283            .expect("raw diff");
13284        let changed: Vec<(DiffStatus, &str)> = raw.iter().map(|e| (e.status, e.path())).collect();
13285        assert_eq!(
13286            changed,
13287            vec![
13288                (DiffStatus::Modified, "dir/changed.txt"),
13289                (DiffStatus::Deleted, "gone/x.txt"),
13290                (DiffStatus::Modified, "mode.sh"),
13291                (DiffStatus::Added, "new/y.txt"),
13292                (DiffStatus::Deleted, "swap"),
13293                (DiffStatus::Added, "swap/inner.txt"),
13294            ]
13295        );
13296
13297        let parsed = Odb::new(temp.path());
13298        parsed.cached_tree_entries(&old_tree).expect("parse old");
13299        parsed.cached_tree_entries(&new_tree).expect("parse new");
13300        assert_eq!(
13301            diff_trees(&parsed, Some(&old_tree), Some(&new_tree), "").expect("parsed diff"),
13302            raw
13303        );
13304
13305        let mixed = Odb::new(temp.path());
13306        mixed.cached_tree_entries(&new_tree).expect("parse new");
13307        assert_eq!(
13308            diff_trees(&mixed, Some(&old_tree), Some(&new_tree), "").expect("mixed diff"),
13309            raw
13310        );
13311        let reparsed = mixed
13312            .cached_tree_entries(&old_tree)
13313            .expect("parse cached raw");
13314        assert_eq!(
13315            reparsed.as_slice(),
13316            crate::objects::parse_tree(&mixed.read(&old_tree).expect("read").data)
13317                .expect("parse")
13318                .as_slice(),
13319            "a tree cached raw by a diff parses to the same entries"
13320        );
13321    }
13322
13323    #[test]
13324    fn diff_trees_reports_a_corrupt_tree() {
13325        let temp = TempDir::new().expect("tempdir");
13326        let odb = Odb::new(temp.path());
13327        let good = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
13328        let corrupt = odb
13329            .write(ObjectKind::Tree, b"100644 name-without-oid")
13330            .expect("tree");
13331        let err = diff_trees(&odb, Some(&good), Some(&corrupt), "").expect_err("corrupt");
13332        assert!(matches!(err, Error::CorruptObject(_)), "{err:?}");
13333    }
13334}
13335
13336#[cfg(test)]
13337mod changed_flags_shared_tests {
13338    use super::git_xdiff;
13339    use crate::line_interner::LineInterner;
13340
13341    /// Cutting identical leading and trailing records out and passing their occurrence counts
13342    /// gives the same changed records as diffing the whole files.
13343    #[test]
13344    fn shared_counts_stand_in_for_records_left_out() {
13345        // `}` is frequent only outside the cut, which makes xdiff discard the middle `}` between
13346        // unique lines instead of matching it.
13347        let mut old: Vec<String> = Vec::new();
13348        let mut new: Vec<String> = Vec::new();
13349        for i in 0..60 {
13350            for side in [&mut old, &mut new] {
13351                side.push(format!("fn f{i}() {{\n"));
13352                side.push("}\n".to_owned());
13353            }
13354        }
13355        old.extend(
13356            [
13357                "u1\n", "u2\n", "u3\n", "u4\n", "}\n", "u5\n", "u6\n", "u7\n",
13358            ]
13359            .map(str::to_owned),
13360        );
13361        new.extend(
13362            [
13363                "v1\n", "v2\n", "v3\n", "v4\n", "}\n", "v5\n", "v6\n", "v7\n",
13364            ]
13365            .map(str::to_owned),
13366        );
13367        for i in 0..40 {
13368            for side in [&mut old, &mut new] {
13369                side.push(format!("fn g{i}() {{\n"));
13370                side.push("}\n".to_owned());
13371            }
13372        }
13373        let old: Vec<&str> = old.iter().map(String::as_str).collect();
13374        let new: Vec<&str> = new.iter().map(String::as_str).collect();
13375        let (head, tail) = (120, 80);
13376
13377        let mut interner = LineInterner::with_capacity(old.len() + new.len());
13378        let ids1 = interner.intern_all(&old);
13379        let ids2 = interner.intern_all(&new);
13380        let (full1, full2) = git_xdiff::changed_flags(&ids1, &ids2, false);
13381
13382        let mut cut = LineInterner::default();
13383        let cut1 = cut.intern_all(&old[head..old.len() - tail]);
13384        let cut2 = cut.intern_all(&new[head..new.len() - tail]);
13385        let mut shared = vec![0u32; cut.len()];
13386        for line in old[..head].iter().chain(&old[old.len() - tail..]) {
13387            if let Some(id) = cut.get(line) {
13388                shared[id as usize] += 1;
13389            }
13390        }
13391        let (part1, part2) =
13392            git_xdiff::changed_flags_with_shared(&cut1, &cut2, &shared, head + tail, false);
13393        assert_eq!(part1, full1[head..old.len() - tail]);
13394        assert_eq!(part2, full2[head..new.len() - tail]);
13395        assert!(full1[..head]
13396            .iter()
13397            .chain(&full1[old.len() - tail..])
13398            .all(|c| !c));
13399        // Like `git diff`, the whole middle is one change: the `}` among unique lines is
13400        // discarded because it is frequent in the whole file.
13401        assert!(full1[head..old.len() - tail].iter().all(|&c| c));
13402        let (alone1, _) = git_xdiff::changed_flags(&cut1, &cut2, false);
13403        assert!(
13404            !alone1[4],
13405            "counted alone, the cut keeps the `}}` as a match"
13406        );
13407    }
13408}
13409
13410#[cfg(test)]
13411mod gitlink_tree_worktree_tests {
13412    use std::fs;
13413
13414    use crate::index::{Index, IndexEntry, MODE_GITLINK};
13415    use crate::objects::{ObjectId, ObjectKind};
13416    use crate::repo::init_repository;
13417
13418    use super::diff_tree_to_worktree;
13419
13420    fn encode_tree_gitlink(name: &str, oid: &ObjectId) -> Vec<u8> {
13421        let mut data = format!("160000 {name}\0").into_bytes();
13422        data.extend_from_slice(oid.as_bytes());
13423        data
13424    }
13425
13426    #[test]
13427    fn unpopulated_gitlink_matching_index_produces_no_diff_entry() {
13428        let dir = tempfile::tempdir().expect("tempdir");
13429        let work_tree = dir.path();
13430        fs::create_dir(work_tree.join("sub")).expect("sub placeholder");
13431
13432        let repo = init_repository(
13433            work_tree,
13434            false,
13435            "main",
13436            None,
13437            crate::RefStorageFormat::Files,
13438        )
13439        .expect("init");
13440        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13441            .parse()
13442            .expect("gitlink oid");
13443        let tree_oid = repo
13444            .odb()
13445            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
13446            .expect("write tree");
13447
13448        let path = b"sub".to_vec();
13449        let mut index = Index::new();
13450        index.add_or_replace(IndexEntry {
13451            ctime_sec: 0,
13452            ctime_nsec: 0,
13453            mtime_sec: 0,
13454            mtime_nsec: 0,
13455            dev: 0,
13456            ino: 0,
13457            mode: MODE_GITLINK,
13458            uid: 0,
13459            gid: 0,
13460            size: 0,
13461            oid: gitlink,
13462            flags: (path.len().min(0xfff)) as u16,
13463            flags_extended: None,
13464            path,
13465            base_index_pos: 0,
13466        });
13467
13468        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
13469            .expect("diff_tree_to_worktree");
13470        assert!(
13471            entries.is_empty(),
13472            "clean uninitialized gitlink should not appear in tree→worktree diff: {entries:?}"
13473        );
13474    }
13475
13476    #[test]
13477    fn unpopulated_gitlink_with_stray_file_produces_no_diff_entry() {
13478        let dir = tempfile::tempdir().expect("tempdir");
13479        let work_tree = dir.path();
13480        let sub = work_tree.join("sub");
13481        fs::create_dir(&sub).expect("sub placeholder");
13482        fs::write(sub.join("x.txt"), b"stray\n").expect("stray file");
13483
13484        let repo = init_repository(
13485            work_tree,
13486            false,
13487            "main",
13488            None,
13489            crate::RefStorageFormat::Files,
13490        )
13491        .expect("init");
13492        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13493            .parse()
13494            .expect("gitlink oid");
13495        let tree_oid = repo
13496            .odb()
13497            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
13498            .expect("write tree");
13499
13500        let path = b"sub".to_vec();
13501        let mut index = Index::new();
13502        index.add_or_replace(IndexEntry {
13503            ctime_sec: 0,
13504            ctime_nsec: 0,
13505            mtime_sec: 0,
13506            mtime_nsec: 0,
13507            dev: 0,
13508            ino: 0,
13509            mode: MODE_GITLINK,
13510            uid: 0,
13511            gid: 0,
13512            size: 0,
13513            oid: gitlink,
13514            flags: (path.len().min(0xfff)) as u16,
13515            flags_extended: None,
13516            path,
13517            base_index_pos: 0,
13518        });
13519
13520        let tree_entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
13521            .expect("diff_tree_to_worktree");
13522        assert!(
13523            tree_entries.is_empty(),
13524            "uninitialized gitlink with stray files should not diff: {tree_entries:?}"
13525        );
13526
13527        let wt_entries =
13528            super::diff_index_to_worktree(&repo.odb(), &mut index.clone(), work_tree, false, true)
13529                .expect("diff_index_to_worktree");
13530        assert!(
13531            wt_entries.is_empty(),
13532            "uninitialized gitlink with stray files should not diff: {wt_entries:?}"
13533        );
13534
13535        let ie = index.entries()[0].clone();
13536        assert!(
13537            !super::worktree_differs_from_index_entry(
13538                repo.odb(),
13539                repo.git_dir(),
13540                work_tree,
13541                &ie,
13542                false,
13543                None,
13544                None,
13545            )
13546            .expect("differs"),
13547            "stray files in uninitialized submodule should not differ from index"
13548        );
13549    }
13550
13551    #[cfg_attr(
13552        not(feature = "full-tests"),
13553        ignore = "starts system git or is heavy; run with --features full-tests"
13554    )]
13555    #[test]
13556    fn untracked_only_in_checked_out_submodule_produces_no_diff_entry() {
13557        use std::process::Command;
13558
13559        let dir = tempfile::tempdir().expect("tempdir");
13560        let work_tree = dir.path();
13561        fs::create_dir(work_tree.join("sub")).expect("sub dir");
13562
13563        let repo = init_repository(
13564            work_tree,
13565            false,
13566            "main",
13567            None,
13568            crate::RefStorageFormat::Files,
13569        )
13570        .expect("init");
13571        let sub = work_tree.join("sub");
13572        for args in [
13573            &["init", "-q"][..],
13574            &["config", "user.name", "T"][..],
13575            &["config", "user.email", "t@e.com"][..],
13576        ] {
13577            let status = Command::new("git")
13578                .current_dir(&sub)
13579                .args(args)
13580                .status()
13581                .expect("git");
13582            assert!(status.success(), "git {args:?} failed");
13583        }
13584        fs::write(sub.join("tracked.txt"), b"v1\n").expect("write");
13585        for args in [&["add", "tracked.txt"][..], &["commit", "-qm", "sub"][..]] {
13586            let status = Command::new("git")
13587                .current_dir(&sub)
13588                .args(args)
13589                .status()
13590                .expect("git");
13591            assert!(status.success());
13592        }
13593        let head_out = Command::new("git")
13594            .current_dir(&sub)
13595            .args(["rev-parse", "HEAD"])
13596            .output()
13597            .expect("rev-parse");
13598        let gitlink = std::str::from_utf8(&head_out.stdout)
13599            .expect("utf8")
13600            .trim()
13601            .parse::<ObjectId>()
13602            .expect("oid");
13603
13604        let tree_oid = repo
13605            .odb()
13606            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
13607            .expect("tree");
13608        let path = b"sub".to_vec();
13609        let mut index = Index::new();
13610        index.add_or_replace(IndexEntry {
13611            ctime_sec: 0,
13612            ctime_nsec: 0,
13613            mtime_sec: 0,
13614            mtime_nsec: 0,
13615            dev: 0,
13616            ino: 0,
13617            mode: MODE_GITLINK,
13618            uid: 0,
13619            gid: 0,
13620            size: 0,
13621            oid: gitlink,
13622            flags: (path.len().min(0xfff)) as u16,
13623            flags_extended: None,
13624            path,
13625            base_index_pos: 0,
13626        });
13627
13628        fs::write(sub.join("untracked.txt"), b"extra\n").expect("untracked");
13629
13630        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
13631            .expect("diff_tree_to_worktree");
13632        assert!(
13633            entries.is_empty(),
13634            "untracked-only submodule dirt must not appear in default tree→worktree diff: {entries:?}"
13635        );
13636    }
13637}
13638
13639#[cfg(test)]
13640mod racy_index_mtime_diff_tests {
13641    use super::*;
13642    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
13643    use crate::objects::{HashAlgo, ObjectKind};
13644    use filetime::FileTime;
13645    use std::fs;
13646    use std::path::PathBuf;
13647
13648    const INDEX_MTIME: (u32, u32) = (1_700_000_000, 123_456_789);
13649
13650    fn pin_mtime(path: &Path, sec: u32, nsec: u32) {
13651        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
13652            .expect("set mtime");
13653    }
13654
13655    fn minimal_sha256_repo(work_tree: &Path) -> (PathBuf, Odb) {
13656        let git_dir = work_tree.join(".git");
13657        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
13658        fs::write(
13659            git_dir.join("config"),
13660            "[extensions]\n\tobjectformat = sha256\n",
13661        )
13662        .expect("config");
13663        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
13664        (git_dir, odb)
13665    }
13666
13667    #[test]
13668    fn detects_same_size_change_when_entry_mtime_matches_index_mtime() {
13669        let td = tempfile::tempdir().expect("tempdir");
13670        let wt = td.path();
13671        let content_a = b"same length!!";
13672        let content_b = b"same length??";
13673        assert_eq!(content_a.len(), content_b.len());
13674
13675        fs::write(wt.join("f.txt"), content_a).expect("write");
13676        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
13677        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13678        let git_dir = wt.join(".git");
13679        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
13680        let odb = Odb::new(&git_dir.join("objects"));
13681        let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
13682        let entry = entry_from_metadata(&meta, b"f.txt", oid_a, MODE_REGULAR);
13683
13684        let mut index = Index::new();
13685        index.source_mtime = Some(INDEX_MTIME);
13686        index.entries.push(entry);
13687
13688        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13689        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
13690
13691        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
13692        assert_eq!(
13693            diff.len(),
13694            1,
13695            "racy entry must be re-hashed, not stat-trusted"
13696        );
13697        assert_eq!(diff[0].path(), "f.txt");
13698    }
13699
13700    #[test]
13701    #[cfg(unix)]
13702    fn non_utf8_symlink_clean_racy_equal_mtime_reports_no_diff() {
13703        use crate::index::MODE_SYMLINK;
13704        use std::os::unix::ffi::OsStrExt as _;
13705        use std::os::unix::fs::symlink;
13706
13707        let td = tempfile::tempdir().expect("tempdir");
13708        let wt = td.path();
13709        let target = std::ffi::OsStr::from_bytes(b"bad-\xff-target");
13710        let link_path = wt.join("link");
13711        symlink(target, &link_path).expect("symlink");
13712
13713        let ft = FileTime::from_unix_time(i64::from(INDEX_MTIME.0), INDEX_MTIME.1);
13714        filetime::set_symlink_file_times(&link_path, ft, ft).expect("set symlink mtime");
13715        let meta = fs::symlink_metadata(&link_path).expect("stat");
13716        let git_dir = wt.join(".git");
13717        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
13718        let odb = Odb::new(&git_dir.join("objects"));
13719        let oid = odb.hash(ObjectKind::Blob, target.as_bytes()).expect("hash");
13720        let entry = entry_from_metadata(&meta, b"link", oid, MODE_SYMLINK);
13721
13722        let mut index = Index::new();
13723        index.source_mtime = Some(INDEX_MTIME);
13724        index.entries.push(entry);
13725
13726        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
13727        assert!(
13728            diff.is_empty(),
13729            "clean non-UTF-8 symlink must not be reported modified: {diff:?}"
13730        );
13731    }
13732
13733    #[test]
13734    fn sha256_clean_racy_equal_mtime_reports_no_diff() {
13735        let td = tempfile::tempdir().expect("tempdir");
13736        let wt = td.path();
13737        let (_git_dir, odb) = minimal_sha256_repo(wt);
13738        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
13739
13740        let bytes = b"sha256 width!!";
13741        fs::write(wt.join("f.txt"), bytes).expect("write");
13742        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
13743        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13744        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
13745        let entry = entry_from_metadata(&meta, b"f.txt", oid, MODE_REGULAR);
13746
13747        let mut index = Index::new();
13748        index.hash_algo = HashAlgo::Sha256;
13749        index.source_mtime = Some(INDEX_MTIME);
13750        index.entries.push(entry);
13751
13752        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
13753        assert!(
13754            diff.is_empty(),
13755            "clean SHA-256 racy entry must not be reported modified: {diff:?}"
13756        );
13757    }
13758}
13759
13760#[cfg(test)]
13761mod smudge_racily_clean_tests {
13762    use super::*;
13763    use crate::config::ConfigSet;
13764    use crate::crlf::{self, FileAttrs};
13765    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
13766    use crate::objects::{HashAlgo, ObjectKind};
13767    use filetime::FileTime;
13768    use std::fs;
13769    use std::path::PathBuf;
13770
13771    const INDEX_MTIME_PIN: (u32, u32) = (1_700_000_000, 123_456_789);
13772
13773    fn mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
13774        #[cfg(unix)]
13775        {
13776            use std::os::unix::fs::MetadataExt;
13777            (meta.mtime() as u32, meta.mtime_nsec() as u32)
13778        }
13779        #[cfg(not(unix))]
13780        {
13781            use std::time::UNIX_EPOCH;
13782            let mtime = meta
13783                .modified()
13784                .ok()
13785                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
13786                .unwrap_or_default();
13787            (mtime.as_secs() as u32, mtime.subsec_nanos())
13788        }
13789    }
13790
13791    /// Pin mtime then read back the value the index would store (NTFS quantizes subseconds).
13792    fn pin_mtime_and_sample(path: &Path, sec: u32, nsec: u32) -> (u32, u32) {
13793        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
13794            .expect("set mtime");
13795        let meta = fs::symlink_metadata(path).expect("stat file");
13796        mtime_from_metadata(&meta)
13797    }
13798
13799    /// Sample the on-disk index mtime used in racy-git tests (matches filesystem quantization).
13800    fn setup_index_mtime(work_tree: &Path) -> (u32, u32) {
13801        let probe = work_tree.join(".grit-index-mtime-probe");
13802        fs::write(&probe, b"").expect("probe file");
13803        pin_mtime_and_sample(&probe, INDEX_MTIME_PIN.0, INDEX_MTIME_PIN.1)
13804    }
13805
13806    fn minimal_repo(work_tree: &Path, config: &str) -> (PathBuf, Odb) {
13807        let git_dir = work_tree.join(".git");
13808        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
13809        fs::write(git_dir.join("config"), config).expect("config");
13810        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
13811        (git_dir, odb)
13812    }
13813
13814    fn staged_entry_for_file(
13815        odb: &Odb,
13816        work_tree: &Path,
13817        rel: &str,
13818        bytes: &[u8],
13819        mtime: (u32, u32),
13820    ) -> IndexEntry {
13821        let path = work_tree.join(rel);
13822        fs::write(&path, bytes).expect("write file");
13823        pin_mtime_and_sample(&path, mtime.0, mtime.1);
13824        let meta = fs::symlink_metadata(&path).expect("stat file");
13825        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
13826        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
13827    }
13828
13829    fn staged_entry_with_oid(
13830        work_tree: &Path,
13831        rel: &str,
13832        worktree_bytes: &[u8],
13833        oid: crate::objects::ObjectId,
13834        mtime: (u32, u32),
13835    ) -> IndexEntry {
13836        let path = work_tree.join(rel);
13837        fs::write(&path, worktree_bytes).expect("write file");
13838        pin_mtime_and_sample(&path, mtime.0, mtime.1);
13839        let meta = fs::symlink_metadata(&path).expect("stat file");
13840        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
13841    }
13842
13843    #[test]
13844    fn index_stat_cache_trustworthy_rejects_unknown_mtime() {
13845        assert!(!super::index_stat_cache_trustworthy(None));
13846        assert!(!super::index_stat_cache_trustworthy(Some((0, 0))));
13847        assert!(super::index_stat_cache_trustworthy(Some((1, 0))));
13848    }
13849
13850    #[test]
13851    fn index_entry_skips_content_hash_after_stat_match_when_trusted_and_not_racy() {
13852        use crate::index::StatMatchPolicy;
13853        let td = tempfile::tempdir().expect("tempdir");
13854        let wt = td.path();
13855        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = true\n");
13856        let rel = "trusted.txt";
13857        let file_mtime = (1_700_000_000, 0);
13858        let mut entry = staged_entry_for_file(&odb, wt, rel, b"payload\n", file_mtime);
13859        let index_mtime = (file_mtime.0 + 1, 0);
13860        let policy = StatMatchPolicy::from_config(None);
13861        assert!(
13862            super::index_entry_skips_worktree_content_hash_after_stat_match(
13863                &entry,
13864                Some(index_mtime),
13865                policy,
13866            )
13867        );
13868        entry.size = 0;
13869        entry.mtime_sec = 0;
13870        entry.mtime_nsec = 0;
13871        assert!(
13872            !super::index_entry_skips_worktree_content_hash_after_stat_match(
13873                &entry,
13874                Some(index_mtime),
13875                policy,
13876            )
13877        );
13878        let _ = git_dir;
13879    }
13880
13881    #[cfg(unix)]
13882    #[test]
13883    fn symlink_dir_cache_detects_symlinked_ancestors_across_threads() {
13884        let td = tempfile::tempdir().expect("tempdir");
13885        let wt = td.path();
13886        fs::create_dir_all(wt.join("real/deep")).expect("mkdir");
13887        fs::create_dir_all(wt.join("target/deep")).expect("mkdir");
13888        std::os::unix::fs::symlink(wt.join("target"), wt.join("linked")).expect("symlink");
13889        std::thread::scope(|scope| {
13890            for _ in 0..4 {
13891                scope.spawn(|| {
13892                    let mut cache = super::SymlinkDirCache::default();
13893                    assert!(!cache.has_symlink_in_path_bytes(wt, b"top.txt", false, false));
13894                    assert!(!cache.has_symlink_in_path_bytes(wt, b"linked", false, false));
13895                    assert!(!cache.has_symlink_in_path_bytes(wt, b"real/deep/f", false, false));
13896                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/f", false, false));
13897                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/deep/f", false, false));
13898                    assert!(!cache.has_symlink_in_path_bytes(wt, b"\xff\xfe", false, false));
13899                });
13900            }
13901        });
13902    }
13903
13904    #[cfg(unix)]
13905    #[test]
13906    fn symlink_dir_cache_accepts_a_work_tree_reached_through_a_symlink() {
13907        let td = tempfile::tempdir().expect("tempdir");
13908        let real = td.path().join("real");
13909        fs::create_dir_all(real.join("a/b/c")).expect("mkdir");
13910        fs::create_dir_all(real.join("t/b/c")).expect("mkdir");
13911        std::os::unix::fs::symlink(real.join("t"), real.join("l")).expect("symlink");
13912        let wt = td.path().join("alias");
13913        std::os::unix::fs::symlink(&real, &wt).expect("symlink");
13914        let mut cache = super::SymlinkDirCache::default();
13915        assert!(!cache.has_symlink_in_path_bytes(&wt, b"a/b/c/f", false, false));
13916        assert!(cache.has_symlink_in_path_bytes(&wt, b"l/b/c/f", false, false));
13917        assert!(!cache.has_symlink_in_path_bytes(&real, b"a/b/c/f", false, false));
13918        assert!(cache.has_symlink_in_path_bytes(&real, b"l/b/c/f", false, false));
13919    }
13920
13921    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
13922    #[test]
13923    fn real_dir_chain_confirms_only_symlink_free_directories() {
13924        let td = tempfile::tempdir().expect("tempdir");
13925        let wt = td.path();
13926        fs::create_dir_all(wt.join("a/b/c")).expect("mkdir");
13927        fs::create_dir_all(wt.join("t/c")).expect("mkdir");
13928        fs::write(wt.join("a/file"), b"f").expect("write");
13929        std::os::unix::fs::symlink(wt.join("t"), wt.join("a/l")).expect("symlink");
13930        let mut chain = crate::worktree_scan::RealDirChain::open(wt);
13931        assert_eq!(chain.work_tree(), wt);
13932        assert!(chain.confirms(b"a"));
13933        assert!(chain.confirms(b"a/b/c"));
13934        assert!(!chain.confirms(b"a/l"));
13935        assert!(!chain.confirms(b"a/l/c"));
13936        assert!(!chain.confirms(b"a/file"));
13937        assert!(!chain.confirms(b"a/missing/c"));
13938        assert!(!chain.confirms(b""));
13939        assert!(!chain.confirms(b"a/\0b"));
13940        assert!(chain.confirms(b"a/b"));
13941    }
13942
13943    #[test]
13944    fn symlink_dir_cache_probes_each_new_directory_once_in_index_order() {
13945        let td = tempfile::tempdir().expect("tempdir");
13946        let wt = td.path();
13947        let depth = 12;
13948        let chains = 20;
13949        let mut paths = Vec::new();
13950        for chain in 0..chains {
13951            let dir: Vec<String> = (0..depth)
13952                .map(|level| format!("c{chain:02}l{level:02}"))
13953                .collect();
13954            let dir = format!("shared/{}", dir.join("/"));
13955            fs::create_dir_all(wt.join(&dir)).expect("mkdir");
13956            paths.extend((0..3).map(|file| format!("{dir}/f{file}")));
13957        }
13958        paths.sort();
13959        let mut cache = super::SymlinkDirCache::default();
13960        for rel in &paths {
13961            assert!(!cache.has_symlink_in_path_bytes(wt, rel.as_bytes(), false, false));
13962        }
13963        // Each chain's directories are new; `shared` and sibling files are already known.
13964        let unique_dirs = 1 + chains * depth;
13965        assert!(
13966            cache.fs_queries <= unique_dirs,
13967            "{} filesystem queries for {unique_dirs} directories",
13968            cache.fs_queries
13969        );
13970        #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
13971        assert!(
13972            cache.fs_queries <= chains + 1,
13973            "one chain walk per new directory: {} queries for {chains} chains",
13974            cache.fs_queries
13975        );
13976    }
13977
13978    #[test]
13979    fn common_dir_prefix_len_stops_at_whole_components() {
13980        use super::{common_dir_prefix_len, path_within_dir};
13981        assert_eq!(common_dir_prefix_len(b"a/b/c", b"a/b/c"), 5);
13982        assert_eq!(common_dir_prefix_len(b"a/b/c", b"a/b"), 3);
13983        assert_eq!(common_dir_prefix_len(b"a/b", b"a/b/c"), 3);
13984        assert_eq!(common_dir_prefix_len(b"a/bc/d", b"a/b"), 1);
13985        assert_eq!(common_dir_prefix_len(b"a/b", b"a/bc/d"), 1);
13986        assert_eq!(common_dir_prefix_len(b"ab", b"a"), 0);
13987        assert_eq!(common_dir_prefix_len(b"a/b", b""), 0);
13988        assert!(path_within_dir(b"a/b", b"a/b"));
13989        assert!(path_within_dir(b"a/b/c", b"a/b"));
13990        assert!(!path_within_dir(b"a/bc", b"a/b"));
13991        assert!(!path_within_dir(b"a", b"a/b"));
13992    }
13993
13994    /// Answer the ancestor-symlink question without any caching, one probe per ancestor.
13995    #[cfg(unix)]
13996    fn naive_has_symlink_ancestor(wt: &Path, rel: &str) -> bool {
13997        let Some((dir, _)) = rel.rsplit_once('/') else {
13998            return false;
13999        };
14000        let mut prefix = wt.to_path_buf();
14001        dir.split('/').any(|component| {
14002            prefix.push(component);
14003            fs::symlink_metadata(&prefix).is_ok_and(|m| m.file_type().is_symlink())
14004        })
14005    }
14006
14007    #[cfg(unix)]
14008    #[test]
14009    fn symlink_dir_cache_matches_uncached_probe_in_any_query_order() {
14010        let td = tempfile::tempdir().expect("tempdir");
14011        let wt = td.path();
14012        fs::create_dir_all(wt.join("a/b/c/d")).expect("mkdir");
14013        fs::create_dir_all(wt.join("a/bb/c")).expect("mkdir");
14014        fs::create_dir_all(wt.join("a/b2")).expect("mkdir");
14015        fs::create_dir_all(wt.join("t/c/d")).expect("mkdir");
14016        fs::create_dir_all(wt.join("x/y")).expect("mkdir");
14017        fs::write(wt.join("a/file"), b"f").expect("write");
14018        fs::write(wt.join("x/y/plain"), b"f").expect("write");
14019        std::os::unix::fs::symlink(wt.join("t"), wt.join("a/b/link")).expect("symlink");
14020        std::os::unix::fs::symlink(wt.join("t"), wt.join("x/l")).expect("symlink");
14021        std::os::unix::fs::symlink(wt.join("t"), wt.join("top")).expect("symlink");
14022
14023        let paths = [
14024            "a/b/c/d/f",
14025            "a/b/c/f",
14026            "a/b/f",
14027            "a/b/link/f",
14028            "a/b/link/c/d/f",
14029            "a/b/linker/f",
14030            "a/b2/f",
14031            "a/bb/c/f",
14032            "a/file/under/f",
14033            "a/missing/deeper/f",
14034            "a/missing/f",
14035            "a/f",
14036            "f",
14037            "top/c/f",
14038            "top/f",
14039            "topper/f",
14040            "x/l/c/d/f",
14041            "x/y/f",
14042            "x/y/plain/f",
14043        ];
14044        let mut orders: Vec<Vec<&str>> = vec![paths.to_vec()];
14045        orders.push(paths.iter().rev().copied().collect());
14046        orders.push(
14047            paths
14048                .iter()
14049                .step_by(2)
14050                .chain(paths.iter().skip(1).step_by(2))
14051                .copied()
14052                .collect(),
14053        );
14054        orders.push(paths.iter().flat_map(|p| [*p, *p]).collect());
14055        for order in orders {
14056            let mut cache = super::SymlinkDirCache::default();
14057            for rel in order {
14058                assert_eq!(
14059                    cache.has_symlink_in_path_bytes(wt, rel.as_bytes(), false, false),
14060                    naive_has_symlink_ancestor(wt, rel),
14061                    "ancestor symlink answer for {rel}"
14062                );
14063            }
14064        }
14065    }
14066
14067    #[test]
14068    fn content_hash_skip_trusts_empty_blob_but_not_smudged_size() {
14069        use crate::index::StatMatchPolicy;
14070        let td = tempfile::tempdir().expect("tempdir");
14071        let wt = td.path();
14072        let (_git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
14073        let file_mtime = (1_700_000_000, 0);
14074        let index_mtime = Some((file_mtime.0 + 1, 0));
14075        let policy = StatMatchPolicy::from_config(None);
14076
14077        let empty = staged_entry_for_file(&odb, wt, "empty.txt", b"", file_mtime);
14078        assert_eq!(empty.size, 0);
14079        assert!(
14080            super::index_entry_skips_worktree_content_hash_after_stat_match(
14081                &empty,
14082                index_mtime,
14083                policy,
14084            )
14085        );
14086
14087        let smudged_oid = odb.hash(ObjectKind::Blob, b"abc\n").expect("hash");
14088        let smudged = staged_entry_with_oid(wt, "smudged.txt", b"", smudged_oid, file_mtime);
14089        assert_eq!(smudged.size, 0);
14090        assert!(
14091            !super::index_entry_skips_worktree_content_hash_after_stat_match(
14092                &smudged,
14093                index_mtime,
14094                policy,
14095            )
14096        );
14097    }
14098
14099    #[test]
14100    fn status_diff_reports_smudged_entry_whose_file_became_empty() {
14101        let td = tempfile::tempdir().expect("tempdir");
14102        let wt = td.path();
14103        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
14104        let oid = odb.write(ObjectKind::Blob, b"abc\n").expect("write blob");
14105        let file_mtime = (1_700_000_000, 0);
14106        let mut index = Index::new();
14107        index.entries.push(staged_entry_with_oid(
14108            wt,
14109            "smudged.txt",
14110            b"",
14111            oid,
14112            file_mtime,
14113        ));
14114        let (entries, _) = super::diff_index_to_worktree_with_options(
14115            &odb,
14116            &mut index,
14117            wt,
14118            super::DiffIndexToWorktreeOptions {
14119                repository_git_dir: Some(git_dir),
14120                index_mtime: Some((file_mtime.0 + 1, 0)),
14121                for_status: true,
14122                stat_parallel_threads: Some(1),
14123                ..Default::default()
14124            },
14125        )
14126        .expect("diff");
14127        assert_eq!(entries.len(), 1, "{entries:?}");
14128        assert_eq!(entries[0].status, DiffStatus::Modified);
14129    }
14130
14131    #[test]
14132    fn status_diff_verifies_content_when_index_stat_cache_unset() {
14133        let td = tempfile::tempdir().expect("tempdir");
14134        let wt = td.path();
14135        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14136        let rel = "unset-stat.txt";
14137        let bytes = b"same bytes\n";
14138        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
14139        fs::write(wt.join(rel), bytes).expect("write");
14140        let mut index = Index::new();
14141        let mut entry = staged_entry_for_file(&odb, wt, rel, bytes, (100, 0));
14142        entry.size = 0;
14143        entry.mtime_sec = 0;
14144        entry.mtime_nsec = 0;
14145        entry.oid = oid;
14146        index.entries.push(entry);
14147        let (entries, _) = super::diff_index_to_worktree_with_options(
14148            &odb,
14149            &mut index,
14150            wt,
14151            super::DiffIndexToWorktreeOptions {
14152                repository_git_dir: Some(git_dir),
14153                for_status: true,
14154                ..Default::default()
14155            },
14156        )
14157        .expect("diff");
14158        assert!(
14159            entries.is_empty(),
14160            "unset stat must not size-mismatch a matching worktree: {entries:?}"
14161        );
14162    }
14163
14164    #[cfg_attr(
14165        not(feature = "full-tests"),
14166        ignore = "starts system git or is heavy; run with --features full-tests"
14167    )]
14168    #[test]
14169    fn status_reports_modified_when_ctime_differs_but_mtime_sec_and_size_match_non_racy() {
14170        let td = tempfile::tempdir().expect("tempdir");
14171        let wt = td.path();
14172        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14173        let rel = "tracked.txt";
14174        let original = b"original\n";
14175        let modified = b"modified\n";
14176        assert_eq!(original.len(), modified.len());
14177
14178        let entry_mtime = (1_700_000_000u32, 0u32);
14179        let index_file_mtime = (1_700_000_100u32, 0u32);
14180
14181        std::process::Command::new("git")
14182            .args(["-C", &wt.to_string_lossy(), "init", "-q"])
14183            .status()
14184            .expect("git init");
14185        std::process::Command::new("git")
14186            .args([
14187                "-C",
14188                &wt.to_string_lossy(),
14189                "config",
14190                "user.email",
14191                "t@example.com",
14192            ])
14193            .status()
14194            .expect("git config email");
14195        std::process::Command::new("git")
14196            .args(["-C", &wt.to_string_lossy(), "config", "user.name", "T"])
14197            .status()
14198            .expect("git config name");
14199
14200        fs::write(wt.join(rel), original).expect("write original");
14201        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
14202        std::process::Command::new("git")
14203            .args(["-C", &wt.to_string_lossy(), "add", rel])
14204            .status()
14205            .expect("git add");
14206        std::process::Command::new("git")
14207            .args([
14208                "-C",
14209                &wt.to_string_lossy(),
14210                "commit",
14211                "-qm",
14212                "seed",
14213                "--no-gpg-sign",
14214            ])
14215            .status()
14216            .expect("git commit");
14217
14218        let mut index = Index::new();
14219        index.source_mtime = Some(index_file_mtime);
14220        let staged = staged_entry_for_file(&odb, wt, rel, original, entry_mtime);
14221        let oid_original = staged.oid;
14222        index.entries.push(staged);
14223
14224        fs::write(wt.join(rel), modified).expect("modify worktree");
14225        let git_dirty = std::process::Command::new("git")
14226            .args([
14227                "-C",
14228                &wt.to_string_lossy(),
14229                "--no-optional-locks",
14230                "status",
14231                "--short",
14232            ])
14233            .output()
14234            .expect("git status");
14235        assert!(
14236            git_dirty.status.success(),
14237            "git status failed: {}",
14238            String::from_utf8_lossy(&git_dirty.stderr)
14239        );
14240        assert!(
14241            String::from_utf8_lossy(&git_dirty.stdout).contains("tracked.txt"),
14242            "system git must see modification before mtime pin: {}",
14243            String::from_utf8_lossy(&git_dirty.stdout)
14244        );
14245
14246        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
14247
14248        let meta = fs::symlink_metadata(wt.join(rel)).expect("stat");
14249        let ie = &mut index.entries[0];
14250        assert_eq!(ie.oid, oid_original);
14251        if stat_matches(ie, &meta) {
14252            ie.ctime_sec = ie.ctime_sec.wrapping_sub(3600);
14253        }
14254        assert!(
14255            !stat_matches(ie, &meta),
14256            "ctime/inode change must fail full stat match"
14257        );
14258        let stat_policy = crate::index::StatMatchPolicy::default();
14259        assert!(
14260            !entry_is_racy_with_policy(ie, Some(index_file_mtime), stat_policy),
14261            "fixture must be non-racy"
14262        );
14263
14264        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
14265            &odb,
14266            &mut index,
14267            wt,
14268            super::DiffIndexToWorktreeOptions {
14269                index_mtime: Some(index_file_mtime),
14270                repository_git_dir: Some(git_dir),
14271                refresh_index_stat_in_pass: true,
14272                for_status: true,
14273                ..Default::default()
14274            },
14275        )
14276        .expect("diff");
14277        assert_eq!(
14278            entries.len(),
14279            1,
14280            "must report modified, not refresh stat clean: {entries:?}, index_changed={index_changed}"
14281        );
14282        assert_eq!(entries[0].path(), rel);
14283    }
14284
14285    #[test]
14286    fn diff_name_status_skips_big_worktree_read_on_size_mismatch() {
14287        let td = tempfile::tempdir().expect("tempdir");
14288        let wt = td.path();
14289        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tbigFileThreshold = 4k\n");
14290        let rel = "big.bin";
14291        let indexed = vec![b'x'; 100];
14292        let mut index = Index::new();
14293        index
14294            .entries
14295            .push(staged_entry_for_file(&odb, wt, rel, &indexed, (100, 0)));
14296        fs::write(wt.join(rel), vec![b'y'; 5000]).expect("rewrite bigger");
14297        let metrics = odb.hot_path_metrics();
14298        metrics.reset_blob_content_reads();
14299        metrics.set_blob_counting(true);
14300        let (entries, _) = super::diff_index_to_worktree_with_options(
14301            &odb,
14302            &mut index,
14303            wt,
14304            super::DiffIndexToWorktreeOptions {
14305                repository_git_dir: Some(git_dir),
14306                for_status: false,
14307                ..Default::default()
14308            },
14309        )
14310        .expect("diff");
14311        metrics.set_blob_counting(false);
14312        assert_eq!(entries.len(), 1);
14313        assert_eq!(entries[0].status, super::DiffStatus::Modified);
14314        assert_eq!(entries[0].new_oid, super::zero_oid());
14315        assert_eq!(
14316            metrics.blob_content_reads(),
14317            0,
14318            "large size-only drift must not hash or read the worktree"
14319        );
14320    }
14321
14322    #[test]
14323    fn status_diff_skips_hash_when_worktree_size_differs() {
14324        let td = tempfile::tempdir().expect("tempdir");
14325        let wt = td.path();
14326        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14327        let rel = "size-change.txt";
14328        let original = b"short\n";
14329        let mut index = Index::new();
14330        index
14331            .entries
14332            .push(staged_entry_for_file(&odb, wt, rel, original, (100, 0)));
14333        fs::write(wt.join(rel), b"longer content\n").expect("rewrite");
14334        let metrics = odb.hot_path_metrics();
14335        metrics.reset_blob_content_reads();
14336        metrics.set_blob_counting(true);
14337        let (entries, _) = super::diff_index_to_worktree_with_options(
14338            &odb,
14339            &mut index,
14340            wt,
14341            super::DiffIndexToWorktreeOptions {
14342                repository_git_dir: Some(git_dir),
14343                for_status: true,
14344                ..Default::default()
14345            },
14346        )
14347        .expect("diff");
14348        metrics.set_blob_counting(false);
14349        assert_eq!(entries.len(), 1);
14350        assert_eq!(entries[0].status, super::DiffStatus::Modified);
14351        assert_eq!(entries[0].path(), rel);
14352        assert_eq!(entries[0].old_oid, index.entries[0].oid);
14353        assert_eq!(entries[0].new_oid, super::zero_oid());
14354        assert_eq!(
14355            metrics.blob_content_reads(),
14356            0,
14357            "identity conversion: a size change alone proves the file modified"
14358        );
14359    }
14360
14361    #[test]
14362    fn status_diff_hashes_size_change_when_eol_conversion_restores_blob() {
14363        for refresh_index_stat_in_pass in [false, true] {
14364            let td = tempfile::tempdir().expect("tempdir");
14365            let wt = td.path();
14366            let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14367            fs::write(wt.join(".gitattributes"), "*.txt text eol=crlf\n").expect("attrs");
14368            let rel = "n.txt";
14369            let blob = odb
14370                .write(ObjectKind::Blob, b"one\ntwo\n")
14371                .expect("write blob");
14372            let mut index = Index::new();
14373            index.entries.push(staged_entry_with_oid(
14374                wt,
14375                rel,
14376                b"one\r\ntwo\r\n",
14377                blob,
14378                (100, 0),
14379            ));
14380            fs::write(wt.join(rel), b"one\ntwo\n").expect("rewrite as LF");
14381            let (entries, index_changed) = super::diff_index_to_worktree_with_options(
14382                &odb,
14383                &mut index,
14384                wt,
14385                super::DiffIndexToWorktreeOptions {
14386                    index_mtime: Some((200, 0)),
14387                    repository_git_dir: Some(git_dir),
14388                    refresh_index_stat_in_pass,
14389                    for_status: true,
14390                    ..Default::default()
14391                },
14392            )
14393            .expect("diff");
14394            assert!(
14395                entries.is_empty(),
14396                "refresh={refresh_index_stat_in_pass}: LF worktree cleans to the indexed blob: \
14397                 {entries:?}"
14398            );
14399            if refresh_index_stat_in_pass {
14400                assert!(index_changed, "verified entry adopts the new stat");
14401                assert_eq!(index.entries[0].size, 8);
14402            }
14403        }
14404    }
14405
14406    #[test]
14407    fn status_diff_reads_size_changed_converted_file_once() {
14408        for refresh_index_stat_in_pass in [false, true] {
14409            let td = tempfile::tempdir().expect("tempdir");
14410            let wt = td.path();
14411            let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14412            fs::write(wt.join(".gitattributes"), "*.txt text eol=crlf\n").expect("attrs");
14413            let rel = "n.txt";
14414            let blob = odb
14415                .write(ObjectKind::Blob, b"one\ntwo\n")
14416                .expect("write blob");
14417            let mut index = Index::new();
14418            index.entries.push(staged_entry_with_oid(
14419                wt,
14420                rel,
14421                b"one\r\ntwo\r\n",
14422                blob,
14423                (100, 0),
14424            ));
14425            fs::write(wt.join(rel), b"one\ntwo\nthree\n").expect("real edit");
14426            let metrics = odb.hot_path_metrics();
14427            metrics.reset_blob_content_reads();
14428            metrics.set_blob_counting(true);
14429            let (entries, _) = super::diff_index_to_worktree_with_options(
14430                &odb,
14431                &mut index,
14432                wt,
14433                super::DiffIndexToWorktreeOptions {
14434                    index_mtime: Some((200, 0)),
14435                    repository_git_dir: Some(git_dir),
14436                    refresh_index_stat_in_pass,
14437                    for_status: true,
14438                    ..Default::default()
14439                },
14440            )
14441            .expect("diff");
14442            metrics.set_blob_counting(false);
14443            assert_eq!(entries.len(), 1, "refresh={refresh_index_stat_in_pass}");
14444            assert_eq!(entries[0].status, super::DiffStatus::Modified);
14445            assert_eq!(entries[0].new_oid, zero_oid());
14446            assert_eq!(
14447                index.entries[0].size, 10,
14448                "a modified file keeps its stale stat"
14449            );
14450            assert_eq!(
14451                metrics.blob_content_reads(),
14452                1,
14453                "refresh={refresh_index_stat_in_pass}: read once, not again after the refresh"
14454            );
14455        }
14456    }
14457
14458    #[test]
14459    fn stat_matches_ignores_subsecond_nsec_by_default() {
14460        let td = tempfile::tempdir().expect("tempdir");
14461        let wt = td.path();
14462        fs::write(wt.join("f.txt"), b"x\n").expect("write");
14463        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
14464        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14465        let mut entry = entry_from_metadata(&meta, b"f.txt", zero_oid(), MODE_REGULAR);
14466        entry.mtime_nsec = entry.mtime_nsec.wrapping_add(1_000_000);
14467        assert!(stat_matches(&entry, &meta));
14468    }
14469
14470    #[test]
14471    fn classify_add_up_to_date_when_stat_matches() {
14472        let td = tempfile::tempdir().expect("tempdir");
14473        let wt = td.path();
14474        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
14475        let bytes = b"hello world\n";
14476        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, (100, 0));
14477        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14478        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14479        let attrs = FileAttrs::default();
14480        let params = WorktreeAddRefreshParams {
14481            odb: &odb,
14482            ie: &entry,
14483            meta: &meta,
14484            abs_path: &wt.join("f.txt"),
14485            rel_path: "f.txt",
14486            conv: &conv,
14487            file_attrs: &attrs,
14488            index_mtime: Some((200, 0)),
14489            staged_mode: MODE_REGULAR,
14490            filter_process: None,
14491        };
14492        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
14493        assert_eq!(refresh, WorktreeAddRefresh::UpToDate);
14494    }
14495
14496    #[test]
14497    fn classify_add_rehashes_when_index_mtime_unknown_and_stat_matches() {
14498        let td = tempfile::tempdir().expect("tempdir");
14499        let wt = td.path();
14500        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
14501        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
14502        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
14503        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
14504        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14505        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14506        let attrs = FileAttrs::default();
14507        let params = WorktreeAddRefreshParams {
14508            odb: &odb,
14509            ie: &entry,
14510            meta: &meta,
14511            abs_path: &wt.join("f.txt"),
14512            rel_path: "f.txt",
14513            conv: &conv,
14514            file_attrs: &attrs,
14515            index_mtime: None,
14516            staged_mode: MODE_REGULAR,
14517            filter_process: None,
14518        };
14519        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
14520        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
14521    }
14522
14523    #[test]
14524    fn classify_add_needs_restaging_when_content_differs() {
14525        let td = tempfile::tempdir().expect("tempdir");
14526        let wt = td.path();
14527        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
14528        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
14529        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
14530        pin_mtime_and_sample(&wt.join("f.txt"), 101, 0);
14531        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14532        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14533        let attrs = FileAttrs::default();
14534        let params = WorktreeAddRefreshParams {
14535            odb: &odb,
14536            ie: &entry,
14537            meta: &meta,
14538            abs_path: &wt.join("f.txt"),
14539            rel_path: "f.txt",
14540            conv: &conv,
14541            file_attrs: &attrs,
14542            index_mtime: Some((200, 0)),
14543            staged_mode: MODE_REGULAR,
14544            filter_process: None,
14545        };
14546        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
14547        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
14548    }
14549
14550    #[test]
14551    fn smudges_racily_clean_modified_entry() {
14552        let td = tempfile::tempdir().expect("tempdir");
14553        let wt = td.path();
14554        let index_mtime = setup_index_mtime(wt);
14555        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14556        let content_a = b"same length!!";
14557        let content_b = b"same length??";
14558        assert_eq!(content_a.len(), content_b.len());
14559
14560        let mut index = Index::new();
14561        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
14562        let stale_oid = entry.oid;
14563
14564        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14565        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14566        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14567        // Stat was refreshed from the worktree but the blob OID is still stale (racy window).
14568        let entry = entry_from_metadata(&meta, b"f.txt", stale_oid, MODE_REGULAR);
14569        index.entries.push(entry.clone());
14570
14571        assert!(stat_matches(&entry, &meta));
14572        assert!(entry_is_racy(&entry, Some(index_mtime)));
14573
14574        let smudged = smudge_racily_clean_entries(
14575            &odb,
14576            &git_dir,
14577            &mut index,
14578            wt,
14579            Some(index_mtime),
14580            None,
14581            None,
14582        );
14583        assert!(smudged);
14584        assert_eq!(index.entries[0].size, 0);
14585
14586        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
14587        assert_eq!(diff.len(), 1);
14588        assert_eq!(diff[0].path(), "f.txt");
14589    }
14590
14591    /// A racy index row plus the git dir and odb it lives in; the worktree no longer matches.
14592    fn racy_stale_entry(wt: &Path) -> (PathBuf, Odb, Index, (u32, u32), ObjectId) {
14593        let index_mtime = setup_index_mtime(wt);
14594        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14595        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
14596        let stale_oid = entry.oid;
14597        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
14598        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14599        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14600        let mut index = Index::new();
14601        index.entries.push(entry_from_metadata(
14602            &meta,
14603            b"f.txt",
14604            stale_oid,
14605            MODE_REGULAR,
14606        ));
14607        (git_dir, odb, index, index_mtime, stale_oid)
14608    }
14609
14610    #[test]
14611    fn content_verified_entry_is_not_reread_and_the_mark_is_consumed() {
14612        let td = tempfile::tempdir().expect("tempdir");
14613        let wt = td.path();
14614        let (git_dir, odb, mut index, index_mtime, stale_oid) = racy_stale_entry(wt);
14615        index.mark_content_verified(b"f.txt", stale_oid);
14616
14617        let smudge = |index: &mut Index| {
14618            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
14619        };
14620        assert!(!smudge(&mut index), "a verified entry is trusted");
14621        assert_ne!(index.entries[0].size, 0);
14622        assert!(index.content_verified.is_empty());
14623        assert!(smudge(&mut index), "without the mark the entry is checked");
14624        assert_eq!(index.entries[0].size, 0);
14625    }
14626
14627    #[test]
14628    fn content_verified_entry_bit_is_trusted_and_consumed() {
14629        let td = tempfile::tempdir().expect("tempdir");
14630        let wt = td.path();
14631        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
14632        index.entries[0].mark_content_verified();
14633
14634        let smudge = |index: &mut Index| {
14635            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
14636        };
14637        assert!(!smudge(&mut index), "a marked entry is trusted");
14638        assert_ne!(index.entries[0].size, 0);
14639        assert_eq!(
14640            index.entries[0].flags_extended, None,
14641            "the mark is consumed"
14642        );
14643        assert!(smudge(&mut index), "without the mark the entry is checked");
14644        assert_eq!(index.entries[0].size, 0);
14645    }
14646
14647    #[test]
14648    fn content_verified_mark_for_another_oid_does_not_skip_the_check() {
14649        let td = tempfile::tempdir().expect("tempdir");
14650        let wt = td.path();
14651        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
14652        let other = odb.hash(ObjectKind::Blob, b"other").expect("hash");
14653        index.mark_content_verified(b"f.txt", other);
14654        assert!(smudge_racily_clean_entries(
14655            &odb,
14656            &git_dir,
14657            &mut index,
14658            wt,
14659            Some(index_mtime),
14660            None,
14661            None,
14662        ));
14663        assert_eq!(index.entries[0].size, 0);
14664    }
14665
14666    #[test]
14667    fn leaves_non_racy_entry_alone() {
14668        let td = tempfile::tempdir().expect("tempdir");
14669        let wt = td.path();
14670        let index_mtime = setup_index_mtime(wt);
14671        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14672        let content_a = b"same length!!";
14673        let content_b = b"same length??";
14674        let entry_mtime = (index_mtime.0 - 10, 0);
14675
14676        let mut index = Index::new();
14677        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, entry_mtime);
14678        index.entries.push(entry.clone());
14679
14680        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14681        pin_mtime_and_sample(&wt.join("f.txt"), entry_mtime.0, entry_mtime.1);
14682
14683        assert!(!entry_is_racy(&entry, Some(index_mtime)));
14684        let smudged = smudge_racily_clean_entries(
14685            &odb,
14686            &git_dir,
14687            &mut index,
14688            wt,
14689            Some(index_mtime),
14690            None,
14691            None,
14692        );
14693        assert!(!smudged);
14694        assert_eq!(index.entries[0].size, entry.size);
14695    }
14696
14697    #[test]
14698    fn leaves_matching_content_alone() {
14699        let td = tempfile::tempdir().expect("tempdir");
14700        let wt = td.path();
14701        let index_mtime = setup_index_mtime(wt);
14702        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14703        let bytes = b"unchanged!!!!";
14704
14705        let mut index = Index::new();
14706        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
14707        index.entries.push(entry.clone());
14708
14709        assert!(entry_is_racy(&entry, Some(index_mtime)));
14710        let smudged = smudge_racily_clean_entries(
14711            &odb,
14712            &git_dir,
14713            &mut index,
14714            wt,
14715            Some(index_mtime),
14716            None,
14717            None,
14718        );
14719        assert!(!smudged);
14720        assert_eq!(index.entries[0].size, entry.size);
14721    }
14722
14723    #[test]
14724    fn status_diff_skips_refresh_rewrite_for_racy_clean_matching_stat() {
14725        let td = tempfile::tempdir().expect("tempdir");
14726        let wt = td.path();
14727        let index_mtime = setup_index_mtime(wt);
14728        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14729        let bytes = b"racy clean payload\n";
14730
14731        let mut index = Index::new();
14732        index.source_mtime = Some(index_mtime);
14733        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
14734        index.entries.push(entry.clone());
14735        assert!(entry_is_racy(&entry, Some(index_mtime)));
14736
14737        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
14738            &odb,
14739            &mut index,
14740            wt,
14741            super::DiffIndexToWorktreeOptions {
14742                repository_git_dir: Some(git_dir),
14743                index_mtime: Some(index_mtime),
14744                refresh_index_stat_in_pass: true,
14745                for_status: true,
14746                ..Default::default()
14747            },
14748        )
14749        .expect("status diff");
14750        assert!(entries.is_empty(), "unexpected unstaged: {entries:?}");
14751        assert!(
14752            !index_changed,
14753            "matching stat on a racy entry must skip refresh without rewriting the index"
14754        );
14755    }
14756
14757    #[test]
14758    fn leaves_matching_sha256_racy_content_alone() {
14759        let td = tempfile::tempdir().expect("tempdir");
14760        let wt = td.path();
14761        let index_mtime = setup_index_mtime(wt);
14762        let (git_dir, odb) = minimal_repo(wt, "[extensions]\n\tobjectformat = sha256\n");
14763        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
14764
14765        let bytes = b"sha256 width!!";
14766        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
14767        let mut index = Index::new();
14768        let entry = staged_entry_with_oid(wt, "f.txt", bytes, oid, index_mtime);
14769        index.entries.push(entry.clone());
14770
14771        assert!(entry_is_racy(&entry, Some(index_mtime)));
14772        let smudged = smudge_racily_clean_entries(
14773            &odb,
14774            &git_dir,
14775            &mut index,
14776            wt,
14777            Some(index_mtime),
14778            None,
14779            None,
14780        );
14781        assert!(!smudged, "matching SHA-256 blob must not be smudged");
14782        assert_eq!(index.entries[0].size, entry.size);
14783    }
14784
14785    #[test]
14786    fn materialize_big_binary_under_autocrlf_streams_into_pack() {
14787        use std::io::Write;
14788
14789        let td = tempfile::tempdir().expect("tempdir");
14790        let wt = td.path();
14791        let (git_dir, odb) =
14792            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 64k\n");
14793        let path = wt.join("big.bin");
14794        {
14795            let mut f = fs::File::create(&path).expect("create");
14796            f.write_all(&[0u8; 9000]).expect("nul prefix");
14797            let chunk = vec![1u8; 256 * 1024];
14798            for _ in 0..20 {
14799                f.write_all(&chunk).expect("chunk");
14800            }
14801        }
14802        let meta = fs::metadata(&path).expect("stat");
14803        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14804        conv.autocrlf = crlf::AutoCrlf::True;
14805        let attrs = FileAttrs::default();
14806        let oid =
14807            materialize_worktree_blob(&odb, &path, &meta, &conv, &attrs, "big.bin", None, None)
14808                .expect("materialize");
14809        assert!(
14810            !odb.test_loose_object_path(&oid).exists(),
14811            "big binary under autocrlf should pack, not write loose"
14812        );
14813        let pack_dir = git_dir.join("objects/pack");
14814        let names: Vec<_> = fs::read_dir(&pack_dir)
14815            .expect("pack dir")
14816            .map(|e| e.expect("entry").file_name())
14817            .collect();
14818        assert!(
14819            names.iter().any(|n| n.to_string_lossy().ends_with(".pack")),
14820            "{names:?}"
14821        );
14822        assert!(odb.read(&oid).expect("read blob").data.len() > 9000);
14823    }
14824
14825    #[test]
14826    fn indexed_binary_under_autocrlf_rehash_streams() {
14827        use std::io::Write;
14828
14829        let td = tempfile::tempdir().expect("tempdir");
14830        let wt = td.path();
14831        let (_git_dir, odb) =
14832            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 1m\n");
14833        let path = wt.join("big.bin");
14834        {
14835            let mut f = fs::File::create(&path).expect("create");
14836            f.write_all(&[0u8; 9000]).expect("nul prefix");
14837            let chunk = vec![1u8; 512 * 1024];
14838            for _ in 0..3 {
14839                f.write_all(&chunk).expect("chunk");
14840            }
14841        }
14842        let meta = fs::symlink_metadata(&path).expect("stat");
14843        let oid = hash_worktree_file(
14844            &odb,
14845            &path,
14846            &meta,
14847            &crlf::ConversionConfig::from_config(&ConfigSet::new()),
14848            &FileAttrs::default(),
14849            "big.bin",
14850            None,
14851            None,
14852        )
14853        .expect("hash");
14854        let entry = entry_from_metadata(&meta, b"big.bin", oid, MODE_REGULAR);
14855        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14856        conv.autocrlf = crlf::AutoCrlf::True;
14857        let attrs = FileAttrs::default();
14858        let content = read_regular_worktree_blob(
14859            &odb,
14860            &path,
14861            &conv,
14862            &attrs,
14863            "big.bin",
14864            Some(&entry),
14865            None,
14866            1024 * 1024,
14867        )
14868        .expect("read blob");
14869        assert!(
14870            matches!(content, WorktreeBlobContent::Stream { .. }),
14871            "indexed binary re-hash must stream, not buffer"
14872        );
14873        assert_eq!(content.hash(&odb).expect("hash"), entry.oid);
14874    }
14875
14876    #[test]
14877    fn leaves_matching_crlf_worktree_content_alone() {
14878        let td = tempfile::tempdir().expect("tempdir");
14879        let wt = td.path();
14880        let index_mtime = setup_index_mtime(wt);
14881        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tautocrlf = true\n");
14882        let git_form = b"line one\nline two\n";
14883        let worktree_form = b"line one\r\nline two\r\n";
14884        let oid = odb.hash(ObjectKind::Blob, git_form).expect("hash");
14885
14886        let mut index = Index::new();
14887        let entry = staged_entry_with_oid(wt, "f.txt", worktree_form, oid, index_mtime);
14888        index.entries.push(entry.clone());
14889
14890        assert!(entry_is_racy(&entry, Some(index_mtime)));
14891        let smudged = smudge_racily_clean_entries(
14892            &odb,
14893            &git_dir,
14894            &mut index,
14895            wt,
14896            Some(index_mtime),
14897            None,
14898            None,
14899        );
14900        assert!(
14901            !smudged,
14902            "CRLF worktree matching clean index OID must not be smudged"
14903        );
14904        assert_eq!(index.entries[0].size, entry.size);
14905    }
14906
14907    #[test]
14908    fn skips_skip_worktree_and_intent_to_add() {
14909        let td = tempfile::tempdir().expect("tempdir");
14910        let wt = td.path();
14911        let index_mtime = setup_index_mtime(wt);
14912        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14913        let content_a = b"same length!!";
14914        let content_b = b"same length??";
14915
14916        let mut index = Index::new();
14917        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
14918        entry.set_skip_worktree(true);
14919        index.entries.push(entry);
14920        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14921        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14922        assert!(!smudge_racily_clean_entries(
14923            &odb,
14924            &git_dir,
14925            &mut index,
14926            wt,
14927            Some(index_mtime),
14928            None,
14929            None,
14930        ));
14931        assert_ne!(index.entries[0].size, 0);
14932
14933        let mut index = Index::new();
14934        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
14935        entry.set_intent_to_add(true);
14936        index.entries.push(entry);
14937        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14938        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14939        assert!(!smudge_racily_clean_entries(
14940            &odb,
14941            &git_dir,
14942            &mut index,
14943            wt,
14944            Some(index_mtime),
14945            None,
14946            None,
14947        ));
14948        assert_ne!(index.entries[0].size, 0);
14949    }
14950
14951    #[test]
14952    fn smudges_nothing_without_prior_index_mtime() {
14953        let td = tempfile::tempdir().expect("tempdir");
14954        let wt = td.path();
14955        let index_mtime = setup_index_mtime(wt);
14956        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14957        let mut index = Index::new();
14958        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
14959        index.entries.push(entry);
14960
14961        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
14962        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14963
14964        let smudged = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, None, None);
14965        assert!(!smudged);
14966        assert_ne!(index.entries[0].size, 0);
14967    }
14968}
14969
14970#[cfg(all(test, unix))]
14971mod read_worktree_bytes_for_mode_tests {
14972    use super::*;
14973    use crate::index::MODE_SYMLINK;
14974    use std::fs;
14975    use std::os::unix::fs::symlink;
14976
14977    #[test]
14978    fn symlink_mode_reads_link_target_not_followed_file() {
14979        let td = tempfile::tempdir().expect("tempdir");
14980        let wt = td.path();
14981        fs::write(wt.join("payload.txt"), b"secret\n").expect("write");
14982        symlink("payload.txt", wt.join("link")).expect("symlink");
14983        let bytes =
14984            read_worktree_bytes_for_mode(&wt.join("link"), MODE_SYMLINK).expect("read link");
14985        assert_eq!(bytes, b"payload.txt");
14986    }
14987
14988    #[test]
14989    fn symlink_mode_reads_plain_file_holding_the_target() {
14990        let td = tempfile::tempdir().expect("tempdir");
14991        let path = td.path().join("link");
14992        fs::write(&path, b"payload.txt").expect("write");
14993        let bytes = read_worktree_bytes_for_mode(&path, MODE_SYMLINK).expect("read plain link");
14994        assert_eq!(bytes, b"payload.txt");
14995    }
14996
14997    #[test]
14998    fn regular_mode_reads_file_contents() {
14999        let td = tempfile::tempdir().expect("tempdir");
15000        let path = td.path().join("f.txt");
15001        fs::write(&path, b"hello\n").expect("write");
15002        let bytes = read_worktree_bytes_for_mode(&path, crate::index::MODE_REGULAR).expect("read");
15003        assert_eq!(bytes, b"hello\n");
15004    }
15005
15006    #[test]
15007    fn annotate_marks_context_line_when_eof_line_is_unchanged() {
15008        let old = "a\nb\nc";
15009        let new = "a\nx\nc";
15010        let raw = super::unified_diff_histogram_hunks_only(old, new, 3, 0, false);
15011        let out = annotate_unified_diff_no_newline_at_eof(&raw, old, new);
15012        assert!(
15013            out.contains(" c\n\\ No newline at end of file"),
15014            "histogram hunk should mark trailing context line:\n{out}"
15015        );
15016    }
15017
15018    #[test]
15019    fn annotate_does_not_treat_removed_dash_lines_as_file_headers() {
15020        use super::unified_diff_with_prefix_and_funcname_and_algorithm;
15021        use similar::Algorithm;
15022
15023        let old = "--old";
15024        let new = "--new";
15025        let raw = unified_diff_with_prefix_and_funcname_and_algorithm(
15026            old,
15027            new,
15028            "a/x",
15029            "b/x",
15030            3,
15031            0,
15032            "",
15033            "",
15034            None,
15035            Algorithm::Myers,
15036            false,
15037            false,
15038            false,
15039            false,
15040        );
15041        assert_eq!(
15042            raw.matches("\\ No newline at end of file").count(),
15043            2,
15044            "expected two markers like git, got:\n{raw}"
15045        );
15046    }
15047}
15048
15049#[cfg(test)]
15050mod annotate_no_newline_tests {
15051    use super::annotate_unified_diff_no_newline_at_eof;
15052
15053    #[test]
15054    fn annotate_marks_both_sides_without_trailing_newline() {
15055        let raw = "@@ -1 +1 @@\n-foo\n+f o o\n";
15056        let out = annotate_unified_diff_no_newline_at_eof(raw, "foo", "f o o");
15057        assert!(out.contains("\\ No newline at end of file"));
15058        assert_eq!(out.matches("\\ No newline at end of file").count(), 2);
15059    }
15060}
15061
15062#[cfg(test)]
15063mod blob_diff_algorithm_smoke {
15064    use super::{
15065        anchored_unified_diff, count_changes_with_algorithm, unified_diff_histogram_hunks_only,
15066        unified_diff_with_prefix_and_funcname_and_algorithm, unified_hunk_body_for_diff_options,
15067    };
15068    use similar::Algorithm;
15069
15070    const OLD: &str = "alpha\nbeta\ngamma\n";
15071    const NEW: &str = "alpha\nBETA\ngamma\ndelta\n";
15072
15073    #[test]
15074    fn seed15_histogram_u0_body_matches_git() {
15075        use crate::diff::options::{DiffAlgorithm, DiffOptions};
15076        use std::collections::hash_map::DefaultHasher;
15077        use std::hash::{Hash, Hasher};
15078        let seed = 15u64;
15079        let mut hasher = DefaultHasher::new();
15080        seed.hash(&mut hasher);
15081        let h = hasher.finish();
15082        let line_count = 4 + (h as usize % 12);
15083        let mut old = String::new();
15084        let mut new = String::new();
15085        for i in 0..line_count {
15086            let token = (h.wrapping_add(i as u64 * 131) % 7) as u8 + b'a';
15087            let ch = token as char;
15088            old.push(ch);
15089            if (h >> (i % 8)) & 1 == 1 {
15090                new.push(((token as u8).wrapping_add(1)) as char);
15091            } else {
15092                new.push(ch);
15093            }
15094            old.push('\n');
15095            new.push('\n');
15096        }
15097        let opts = DiffOptions {
15098            context_lines: 0,
15099            algorithm: DiffAlgorithm::Histogram,
15100            indent_heuristic: false,
15101            ..DiffOptions::default()
15102        };
15103        let body = unified_hunk_body_for_diff_options(&old, &new, &opts);
15104        assert_eq!(
15105            body.matches("@@ ").count(),
15106            2,
15107            "git 2.55 splits u0 histogram hunks at seed 15: {body}"
15108        );
15109    }
15110
15111    #[test]
15112    fn t4071_histogram_changed_flags_git_myers_completes() {
15113        let old: Vec<&str> = "x\nx\nx\nx\n".lines().collect();
15114        let new: Vec<&str> = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n".lines().collect();
15115        let (c1, c2) = super::git_xdiff::histogram_changed_flags(&old, &new);
15116        assert_eq!(c1.len(), 4);
15117        assert_eq!(c2.len(), 11);
15118        let ops = super::changed_flags_to_ops(&c1, &c2, old.len(), new.len());
15119        assert!(!ops.is_empty());
15120    }
15121
15122    #[test]
15123    fn t4071_histogram_compacted_ops_completes() {
15124        use crate::diff::WhitespaceMode;
15125        let old = "x\nx\nx\nx\n";
15126        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
15127        let ops = super::histogram_compacted_line_ops(old, new, false, WhitespaceMode::default());
15128        assert!(!ops.is_empty(), "expected diff ops");
15129    }
15130
15131    #[test]
15132    fn t4071_histogram_u0_body_completes() {
15133        use crate::diff::options::{DiffAlgorithm, DiffOptions};
15134        let old = "x\nx\nx\nx\n";
15135        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
15136        let opts = DiffOptions {
15137            context_lines: 0,
15138            algorithm: DiffAlgorithm::Histogram,
15139            indent_heuristic: false,
15140            ..DiffOptions::default()
15141        };
15142        let body = unified_hunk_body_for_diff_options(old, new, &opts);
15143        assert!(body.contains("@@"), "expected hunk header: {body}");
15144    }
15145
15146    #[test]
15147    fn t4071_minimal_u0_matches_git_hunk_headers() {
15148        use crate::diff::options::{DiffAlgorithm, DiffOptions};
15149        let old = "x\nx\nx\nx\n";
15150        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
15151        let opts = DiffOptions {
15152            context_lines: 0,
15153            algorithm: DiffAlgorithm::Minimal,
15154            indent_heuristic: false,
15155            ..DiffOptions::default()
15156        };
15157        let body = unified_hunk_body_for_diff_options(old, new, &opts);
15158        assert_eq!(
15159            body.matches("@@ ").count(),
15160            2,
15161            "git 2.55+ splits u0 minimal hunks at repeated anchors: {body}"
15162        );
15163    }
15164
15165    #[test]
15166    fn hunks_do_not_slide_onto_final_line_without_newline() {
15167        use crate::diff::options::{DiffAlgorithm, DiffOptions};
15168
15169        let old = "{\n  \"a\": 1\n}\n";
15170        let new = "{\n  \"a\": 2\n}\n{\n  \"b\": 2\n}";
15171        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";
15172        for algorithm in [
15173            DiffAlgorithm::Myers,
15174            DiffAlgorithm::Minimal,
15175            DiffAlgorithm::Patience,
15176            DiffAlgorithm::Histogram,
15177        ] {
15178            for indent_heuristic in [true, false] {
15179                let options = DiffOptions {
15180                    algorithm,
15181                    indent_heuristic,
15182                    ..DiffOptions::default()
15183                };
15184                assert_eq!(
15185                    unified_hunk_body_for_diff_options(old, new, &options),
15186                    expected,
15187                    "{algorithm:?}, indent heuristic {indent_heuristic}"
15188                );
15189            }
15190        }
15191    }
15192
15193    #[test]
15194    fn myers_patience_histogram_minimal_emit_unified_hunks() {
15195        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
15196            let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
15197                OLD,
15198                NEW,
15199                "a/old.txt",
15200                "b/new.txt",
15201                3,
15202                0,
15203                "a/",
15204                "b/",
15205                None,
15206                algo,
15207                false,
15208                false,
15209                true,
15210                false,
15211            );
15212            assert!(patch.contains("@@"), "algo {algo:?} should emit hunks");
15213            assert!(patch.contains("-beta") || patch.contains("-BETA"));
15214        }
15215    }
15216
15217    #[test]
15218    fn git_histogram_printer_and_anchored_diff_smoke() {
15219        let hist = unified_diff_histogram_hunks_only(OLD, NEW, 3, 0, false);
15220        assert!(!hist.is_empty());
15221        let anchors = vec!["beta".to_string()];
15222        let anchored = anchored_unified_diff(
15223            OLD,
15224            NEW,
15225            "old",
15226            "new",
15227            3,
15228            &anchors,
15229            Algorithm::Myers,
15230            false,
15231            true,
15232            false,
15233        );
15234        assert!(anchored.contains("@@"));
15235    }
15236
15237    #[test]
15238    fn count_changes_git_histogram_vs_similar() {
15239        let hist = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, true);
15240        let myers = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, false);
15241        assert!(hist.0 + hist.1 > 0);
15242        assert!(myers.0 + myers.1 > 0);
15243    }
15244}
15245
15246#[cfg(test)]
15247mod line_coverage_tests {
15248    use super::*;
15249    use crate::config::ConfigSet;
15250    use crate::index::StatMatchPolicy;
15251    use crate::index::{Index, IndexEntry, MODE_REGULAR};
15252    use crate::objects::ObjectKind;
15253    use crate::parallel::ThreadPolicy;
15254    use crate::write_tree::write_tree_from_index;
15255    use similar::Algorithm;
15256    use tempfile::TempDir;
15257
15258    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
15259        IndexEntry {
15260            ctime_sec: 0,
15261            ctime_nsec: 0,
15262            mtime_sec: 0,
15263            mtime_nsec: 0,
15264            dev: 0,
15265            ino: 0,
15266            mode,
15267            uid: 0,
15268            gid: 0,
15269            size: 0,
15270            oid,
15271            flags: path.len().min(0xfff) as u16,
15272            flags_extended: None,
15273            path: path.as_bytes().to_vec(),
15274            base_index_pos: 0,
15275        }
15276    }
15277
15278    fn write_tree(odb: &Odb, entries: &[(&str, ObjectId)]) -> ObjectId {
15279        let mut body = Vec::new();
15280        for (name, oid) in entries {
15281            body.extend_from_slice(format!("100644 {name}\0").as_bytes());
15282            body.extend_from_slice(oid.as_bytes());
15283        }
15284        odb.write(ObjectKind::Tree, &body).expect("tree")
15285    }
15286
15287    fn pair_of_trees_with_rename() -> (TempDir, Odb, ObjectId, ObjectId) {
15288        let temp = TempDir::new().expect("tempdir");
15289        let odb = Odb::new(temp.path());
15290        let old_blob = odb
15291            .write(ObjectKind::Blob, b"line one\nline two\n")
15292            .expect("old");
15293        let new_blob = odb
15294            .write(ObjectKind::Blob, b"line one\nline TWO\nline three\n")
15295            .expect("new");
15296        let old_tree = write_tree(&odb, &[("old.txt", old_blob)]);
15297        let new_tree = write_tree(&odb, &[("new.txt", new_blob), ("kept.txt", old_blob)]);
15298        (temp, odb, old_tree, new_tree)
15299    }
15300
15301    #[test]
15302    fn detect_copies_reports_source_modes_without_tree_entries() {
15303        let temp = TempDir::new().expect("tempdir");
15304        let odb = Odb::new(temp.path());
15305        let content = odb
15306            .write(ObjectKind::Blob, b"shared content\n")
15307            .expect("blob");
15308        let changed = odb.write(ObjectKind::Blob, b"changed\n").expect("blob");
15309        let old_tree = write_tree(&odb, &[("gone.txt", content), ("mod.txt", content)]);
15310        let new_tree = write_tree(
15311            &odb,
15312            &[
15313                ("copy.txt", content),
15314                ("mod.txt", changed),
15315                ("moved.txt", content),
15316            ],
15317        );
15318        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
15319        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
15320        let pairs: Vec<_> = result
15321            .iter()
15322            .filter(|e| matches!(e.status, DiffStatus::Renamed | DiffStatus::Copied))
15323            .map(|e| (e.old_mode, e.new_mode))
15324            .collect();
15325        assert_eq!(pairs.len(), 2, "{result:?}");
15326        assert!(
15327            pairs
15328                .iter()
15329                .all(|p| *p == (FileMode::REGULAR, FileMode::REGULAR)),
15330            "{pairs:?}"
15331        );
15332    }
15333
15334    #[test]
15335    fn detect_copies_keeps_modifications_as_modifications() {
15336        // `git diff -C` reports `b` as modified even though its new content equals the
15337        // old content of the also-modified `a`: only created paths become copies.
15338        let temp = TempDir::new().expect("tempdir");
15339        let odb = Odb::new(temp.path());
15340        let one = odb.write(ObjectKind::Blob, b"1\n").expect("blob");
15341        let two = odb.write(ObjectKind::Blob, b"2\n").expect("blob");
15342        let old_tree = write_tree(&odb, &[("a", one), ("b", two)]);
15343        let new_tree = write_tree(&odb, &[("a", two), ("b", one)]);
15344        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
15345        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
15346        let statuses: Vec<_> = result.iter().map(|e| (e.path(), e.status)).collect();
15347        assert_eq!(
15348            statuses,
15349            [("a", DiffStatus::Modified), ("b", DiffStatus::Modified)]
15350        );
15351    }
15352
15353    #[test]
15354    fn diff_helper_and_format_sweep() {
15355        let cfg = ConfigSet::new();
15356        assert!(resolve_indent_heuristic(&cfg, false, false));
15357        assert!(!resolve_indent_heuristic(&cfg, false, true));
15358
15359        let old = "alpha beta\ngamma\n";
15360        let new = "alpha BETA\ngamma delta\n";
15361        let _ = normalize_ignore_space_change(old);
15362        let _ = normalize_ignore_space_change_line("- alpha  beta ");
15363        let _ = count_git_lines(old.as_bytes());
15364        let _ = diffcore_count_changes(old.as_bytes(), new.as_bytes());
15365        assert!(parse_diff_rename_score_token("50%").is_some());
15366        let _ = rewrite_merge_score(b"a", b"b");
15367        let _ = rewrite_dissimilarity_index_percent(b"abc", b"abd");
15368        assert_eq!(format_mode(0o100644), "100644");
15369
15370        let old_lines = ["a", "b", "c"];
15371        let new_lines = ["a", "b", "d"];
15372        let compact = diff_slice_ops_compacted(&old_lines, &new_lines, Algorithm::Myers, false);
15373        assert!(!compact.is_empty());
15374    }
15375
15376    #[test]
15377    fn tree_diff_rename_copy_postprocess_matrix() -> Result<()> {
15378        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
15379        let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
15380        assert!(!raw.is_empty());
15381        let with_renames = diff_trees_with_renames(&odb, Some(&old_tree), Some(&new_tree), "", 50)?;
15382        assert!(!with_renames.is_empty());
15383        let config = ConfigSet::new();
15384        let policy = ThreadPolicy::default();
15385        let detected =
15386            detect_renames_with_policy(&odb, None, raw.clone(), 50, &config, &policy, None)?;
15387        let source_flat = flatten_tree_entries(&odb, &old_tree)?;
15388        let _ = detect_copies_with_policy(
15389            &odb,
15390            None,
15391            detected.clone(),
15392            50,
15393            true,
15394            &source_flat,
15395            &config,
15396            &policy,
15397        )?;
15398        let opts = DiffPostprocessOptions {
15399            break_score: Some(50),
15400            rename_threshold: 50,
15401            copy_threshold: Some(50),
15402            find_copies_harder: true,
15403            materialize_break_worktree_new_oid: false,
15404        };
15405        let processed = postprocess_diff_entries(
15406            &odb,
15407            raw,
15408            Some(&old_tree),
15409            None,
15410            None,
15411            &config,
15412            &policy,
15413            &opts,
15414        )?;
15415        assert!(!processed.is_empty());
15416        let _ = status_apply_rename_copy_detection(
15417            &odb,
15418            with_renames.clone(),
15419            50,
15420            true,
15421            Some(&old_tree),
15422            &config,
15423            &policy,
15424            None,
15425        )?;
15426        let flat = flatten_tree_entries(&odb, &new_tree)?;
15427        assert!(!flat.is_empty());
15428        let _ = diff_trees_show_tree_entries(&odb, Some(&old_tree), Some(&new_tree), "")?;
15429        Ok(())
15430    }
15431
15432    #[test]
15433    fn unified_diff_flag_matrix() {
15434        let old = "fn main() {\n    println!(\"hi\");\n}\n";
15435        let new = "fn main() {\n    println!(\"bye\");\n}\n";
15436        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
15437            for (ws, ws_all, fc, ig) in [(true, false, false, false), (false, true, true, true)] {
15438                let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
15439                    old,
15440                    new,
15441                    "a/main.rs",
15442                    "b/main.rs",
15443                    3,
15444                    0,
15445                    "a/",
15446                    "b/",
15447                    None,
15448                    algo,
15449                    ws,
15450                    ws_all,
15451                    fc,
15452                    ig,
15453                );
15454                assert!(patch.contains("@@"), "algo {algo:?}");
15455            }
15456        }
15457        let hist = unified_diff_histogram_ignore_with_prefix_and_funcname(
15458            old,
15459            new,
15460            "a/x",
15461            "b/x",
15462            3,
15463            0,
15464            "",
15465            "",
15466            None,
15467            false,
15468            false,
15469            false,
15470            |_old, _new| false,
15471        );
15472        assert!(!hist.is_empty());
15473        let raw = format_raw_abbrev(
15474            &DiffEntry {
15475                status: DiffStatus::Modified,
15476                old_path: Some("x".into()),
15477                new_path: Some("x".into()),
15478                old_mode: FileMode::REGULAR,
15479                new_mode: FileMode::REGULAR,
15480                old_oid: empty_blob_oid(),
15481                new_oid: empty_blob_oid(),
15482                score: Some(80),
15483            },
15484            7,
15485        );
15486        assert!(raw.contains('M') || raw.contains('x'));
15487    }
15488
15489    #[test]
15490    fn index_and_commit_diff_paths() -> Result<()> {
15491        let temp = TempDir::new().expect("tempdir");
15492        let odb = Odb::new(temp.path());
15493        let b1 = odb.write(ObjectKind::Blob, b"v1\n").expect("b1");
15494        let b2 = odb.write(ObjectKind::Blob, b"v2\n").expect("b2");
15495        let b3 = odb.write(ObjectKind::Blob, b"v3\n").expect("b3");
15496        let mut index = Index::new();
15497        index.add_or_replace(index_entry("file.txt", MODE_REGULAR, b2));
15498        let head = write_tree_from_index(&odb, &index, "").expect("tree");
15499        index.entries_mut()[0].oid = b1;
15500        let idx_diff = diff_index_to_tree(&odb, &index, Some(&head), false)?;
15501        assert!(!idx_diff.is_empty());
15502        let commit_body = format!(
15503            "tree {head}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
15504        );
15505        let c1 = odb
15506            .write(ObjectKind::Commit, commit_body.as_bytes())
15507            .expect("c1");
15508        let mut index2 = Index::new();
15509        index2.add_or_replace(index_entry("file.txt", MODE_REGULAR, b3));
15510        let head2 = write_tree_from_index(&odb, &index2, "").expect("head2");
15511        let c2_body = format!(
15512            "tree {head2}\nparent {c1}\nauthor T <t@e.com> 2 +0000\ncommitter T <t@e.com> 2 +0000\n\nm2\n"
15513        );
15514        let c2 = odb
15515            .write(ObjectKind::Commit, c2_body.as_bytes())
15516            .expect("c2");
15517        let commit_diff = diff_commits_with_renames(&odb, &c1, &c2, 50)?;
15518        assert!(!commit_diff.is_empty());
15519        let _ = tree_oid_of_commit(&odb, &c2)?;
15520        let mtime = index_mtime_for_diff(&index, Some((1, 0)));
15521        let _ = entry_is_racy(&index.entries()[0], mtime);
15522        let _ = entry_is_racy_with_policy(&index.entries()[0], mtime, StatMatchPolicy::default());
15523        Ok(())
15524    }
15525
15526    #[test]
15527    fn tree_oid_of_commit_reports_invalid_ref_for_non_commits() {
15528        let temp = TempDir::new().expect("tempdir");
15529        let odb = Odb::new(temp.path());
15530        let tree = odb.write(ObjectKind::Tree, &[]).expect("tree");
15531        let body = format!(
15532            "tree {tree}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
15533        );
15534        let commit = odb
15535            .write(ObjectKind::Commit, body.as_bytes())
15536            .expect("commit");
15537        let blob = odb.write(ObjectKind::Blob, b"x\n").expect("blob");
15538
15539        assert_eq!(tree_oid_of_commit(&odb, &commit).expect("tree"), tree);
15540        assert!(matches!(
15541            tree_oid_of_commit(&odb, &blob),
15542            Err(Error::InvalidRef(_))
15543        ));
15544        assert!(matches!(
15545            tree_oid_of_commit(&odb, &tree),
15546            Err(Error::InvalidRef(_))
15547        ));
15548    }
15549
15550    #[test]
15551    fn worktree_index_diff_and_rename_score() -> Result<()> {
15552        let temp = TempDir::new().expect("tempdir");
15553        let wt = temp.path();
15554        let git_dir = wt.join(".git");
15555        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
15556        let objects_dir = git_dir.join("objects");
15557        let odb = Odb::new(&objects_dir);
15558        let content = b"hello world\n";
15559        std::fs::write(wt.join("tracked.txt"), content).expect("write");
15560        let oid = odb.write(ObjectKind::Blob, content).expect("blob");
15561        let mut index = Index::new();
15562        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, oid));
15563        let head = write_tree_from_index(&odb, &index, "").expect("tree");
15564        std::fs::write(wt.join("tracked.txt"), b"hello WORLD\n").expect("mutate");
15565        let mut index_mut = index.clone();
15566        let diff = diff_index_to_worktree(&odb, &mut index_mut, wt, false, false)?;
15567        assert!(!diff.is_empty());
15568        let _ = diff_tree_to_worktree(&odb, Some(&head), wt, &index)?;
15569        assert_eq!(rename_similarity_score(b"a", b"a\n"), 0);
15570        assert_eq!(rename_similarity_score(b"a\n", b"a"), 0);
15571        let score = rename_similarity_score(b"line one\nline two\n", b"line one\nline TWO\n");
15572        assert!(score >= 50);
15573        let _ = detect_renames(&odb, Some(wt), diff, 50);
15574        Ok(())
15575    }
15576
15577    #[test]
15578    fn extended_diff_helpers_sweep() -> Result<()> {
15579        use crate::crlf::{ConversionConfig, FileAttrs};
15580        let temp = TempDir::new().expect("tempdir");
15581        let wt = temp.path();
15582        let git_dir = wt.join(".git");
15583        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
15584        let objects_dir = git_dir.join("objects");
15585        let odb = Odb::new(&objects_dir);
15586        let b_old = odb.write(ObjectKind::Blob, b"alpha\nbeta\n").expect("old");
15587        let b_new = odb
15588            .write(ObjectKind::Blob, b"alpha\nBETA\ngamma\n")
15589            .expect("new");
15590        let old_tree = write_tree(&odb, &[("a.txt", b_old), ("b.txt", b_old)]);
15591        let new_tree = write_tree(
15592            &odb,
15593            &[("a.txt", b_new), ("b.txt", b_new), ("c.txt", b_new)],
15594        );
15595        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
15596        assert!(entries.len() >= 2);
15597
15598        let cfg = ConfigSet::new();
15599        assert!(indent_heuristic_from_config(&cfg));
15600        let _ = unified_diff_histogram_hunks_only("a\nb\n", "a\nB\n", 3, 0, false);
15601        let _ = unified_diff_histogram_with_prefix_and_funcname_indent(
15602            "x\n", "y\n", "a/f", "b/f", 3, 0, "", "", None, false, false,
15603        );
15604        let _ = count_changes_with_algorithm("a\n", "b\n", Algorithm::Myers, false);
15605        let _ = should_break_rewrite_pair(b"short", b"totally different body", 50);
15606        let _ = head_path_states(&odb, Some(&new_tree))?;
15607        std::fs::write(wt.join("tracked.txt"), b"hello\n").expect("wt");
15608        let abs = wt.join("tracked.txt");
15609        let meta = std::fs::metadata(&abs).expect("meta");
15610        let mut index = Index::new();
15611        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, b_old));
15612        let _ = stat_matches_with_policy(&index.entries()[0], &meta, StatMatchPolicy::default());
15613        let _ = worktree_differs_from_index_entry(
15614            &odb,
15615            &git_dir,
15616            wt,
15617            &index.entries()[0],
15618            false,
15619            None,
15620            None,
15621        )?;
15622        let conv = ConversionConfig::from_config(&cfg);
15623        let attrs = FileAttrs::default();
15624        let params = WorktreeAddRefreshParams {
15625            odb: &odb,
15626            ie: &index.entries()[0],
15627            meta: &meta,
15628            abs_path: &abs,
15629            rel_path: "tracked.txt",
15630            conv: &conv,
15631            file_attrs: &attrs,
15632            index_mtime: None,
15633            staged_mode: MODE_REGULAR,
15634            filter_process: None,
15635        };
15636        let _ = classify_worktree_entry_for_add(&params)?;
15637        let _ = hash_worktree_file(
15638            &odb,
15639            &abs,
15640            &meta,
15641            &conv,
15642            &attrs,
15643            "tracked.txt",
15644            Some(&index.entries()[0]),
15645            None,
15646        )?;
15647        let _ = materialize_worktree_blob(
15648            &odb,
15649            &abs,
15650            &meta,
15651            &conv,
15652            &attrs,
15653            "tracked.txt",
15654            Some(&index.entries()[0]),
15655            None,
15656        )?;
15657        let _ = read_worktree_bytes_for_mode(&abs, MODE_REGULAR);
15658        let _ = mode_from_metadata(&meta);
15659        let _ = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, Some(&cfg), None);
15660        let _ = refresh_index_stat_content_verified(
15661            &odb,
15662            &git_dir,
15663            &mut index,
15664            wt,
15665            None,
15666            Some(&cfg),
15667            None,
15668        )?;
15669        let _ = refresh_index_stat_content_verified_with_rules(
15670            &odb,
15671            &git_dir,
15672            &mut index,
15673            wt,
15674            None,
15675            Some(&cfg),
15676            None,
15677            None,
15678        )?;
15679        let _ = path_checkout_skip_blob_write_when_up_to_date_with_config(
15680            &odb,
15681            &git_dir,
15682            wt,
15683            &index,
15684            &index.entries()[0],
15685            &b_old,
15686            MODE_REGULAR,
15687            "tracked.txt",
15688            None,
15689            None,
15690        )?;
15691        let patch = anchored_unified_diff(
15692            "a\nb\n",
15693            "a\nB\n",
15694            "f",
15695            "f",
15696            3,
15697            &[String::from("b")],
15698            Algorithm::Myers,
15699            false,
15700            true,
15701            false,
15702        );
15703        assert!(patch.contains("@@"));
15704        let _ = annotate_unified_diff_no_newline_at_eof("@@\n+a\n\\ No newline\n", "a", "a\n");
15705        Ok(())
15706    }
15707
15708    #[test]
15709    fn unmerged_only_tree_worktree_diff_and_func_heuristics() -> Result<()> {
15710        use similar::DiffOp;
15711        assert!(inserted_block_starts_with_c_like_function_definition(
15712            "static int foo(int bar) {"
15713        ));
15714        assert!(!inserted_block_starts_with_c_like_function_definition(
15715            "printf(hi);"
15716        ));
15717        let ops = vec![
15718            DiffOp::Equal {
15719                old_index: 0,
15720                new_index: 0,
15721                len: 4,
15722            },
15723            DiffOp::Insert {
15724                old_index: 4,
15725                new_index: 4,
15726                new_len: 2,
15727            },
15728        ];
15729        let new_lines = ["a\n", "b\n", "c\n", "d\n", "void added() {\n", "}\n"];
15730        assert!(append_with_whole_function_added(
15731            &ops, 4, 6, &new_lines, None
15732        ));
15733        let _ = hunk_old_change_end_exclusive(&ops);
15734
15735        let temp = TempDir::new().expect("tempdir");
15736        let wt = temp.path();
15737        let git_dir = wt.join(".git");
15738        let objects_dir = git_dir.join("objects");
15739        std::fs::create_dir_all(&objects_dir).expect("objects");
15740        let odb = Odb::new(&objects_dir);
15741        let tree_blob = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
15742        std::fs::write(wt.join("conflict.txt"), b"worktree\n").expect("wt");
15743        let tree = write_tree(&odb, &[("conflict.txt", tree_blob)]);
15744        let mut index = Index::new();
15745        let mut stage1 = index_entry("conflict.txt", MODE_REGULAR, tree_blob);
15746        stage1.flags = (stage1.flags & 0x0FFF) | (1 << 12);
15747        index.entries_mut().push(stage1);
15748        let cfg = ConfigSet::new();
15749        let diff = diff_tree_to_worktree_with_git_dir(
15750            &odb,
15751            Some(&tree),
15752            wt,
15753            &git_dir,
15754            &index,
15755            Some(&cfg),
15756        )?;
15757        assert!(!diff.is_empty());
15758        Ok(())
15759    }
15760
15761    #[test]
15762    fn high_volume_unified_diff_and_postprocess() -> Result<()> {
15763        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
15764        let config = ConfigSet::new();
15765        let policy = ThreadPolicy::default();
15766        for i in 0..600u32 {
15767            let old = format!("seed {i}\nalpha\n");
15768            let new = format!("seed {i}\nalpha\nvoid fn_{i}(int arg) {{\n  return arg;\n}}\n");
15769            let _ = unified_diff_with_prefix_and_funcname_and_algorithm(
15770                &old,
15771                &new,
15772                "a/f.c",
15773                "b/f.c",
15774                3,
15775                0,
15776                "a/",
15777                "b/",
15778                None,
15779                Algorithm::Myers,
15780                i % 2 == 0,
15781                i % 3 == 0,
15782                i % 5 == 0,
15783                i % 7 == 0,
15784            );
15785            let threshold = 30 + (i % 40);
15786            let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
15787            let opts = DiffPostprocessOptions {
15788                rename_threshold: threshold,
15789                copy_threshold: Some(threshold),
15790                find_copies_harder: i % 2 == 0,
15791                break_score: Some(threshold as u64),
15792                materialize_break_worktree_new_oid: i % 4 == 0,
15793            };
15794            let _ = postprocess_diff_entries(
15795                &odb,
15796                raw,
15797                Some(&old_tree),
15798                None,
15799                None,
15800                &config,
15801                &policy,
15802                &opts,
15803            )?;
15804        }
15805        Ok(())
15806    }
15807}
15808
15809#[cfg(test)]
15810mod refresh_checks_tests {
15811    use super::{RefreshChecks, RefreshContent};
15812    use crate::objects::ObjectId;
15813
15814    #[test]
15815    fn empty_checks_answer_none_for_every_entry() {
15816        let checks = RefreshChecks::default();
15817        assert!(checks.0.is_empty());
15818        assert!(checks.get(0).is_none());
15819        assert!(checks.get(9_999).is_none());
15820    }
15821
15822    #[test]
15823    fn filled_checks_answer_by_entry_position() {
15824        let oid = ObjectId::from_bytes(&[7u8; 20]).unwrap();
15825        let other = ObjectId::from_bytes(&[8u8; 20]).unwrap();
15826        let checks = RefreshChecks(vec![None, Some(RefreshContent::Hashed(oid)), None]);
15827        assert!(checks.get(0).is_none());
15828        assert!(RefreshContent::matches(checks.get(1), &oid));
15829        assert!(RefreshContent::differs(checks.get(1), &other));
15830        assert!(
15831            checks.get(3).is_none(),
15832            "positions past the end have no check"
15833        );
15834    }
15835}