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::collections::HashSet;
27use std::fs;
28#[cfg(unix)]
29use std::os::unix::fs::MetadataExt;
30use std::path::{Path, PathBuf};
31
32use crate::config::ConfigSet;
33use crate::diff_indent_heuristic;
34use crate::diff_rename_parallel::{
35    diff_rename_parallel_options, inexact_matrix_exceeds_limit, parallel_load_optional_blobs,
36    parallel_map_indices, parse_rename_limit,
37};
38use crate::error::{Error, Result};
39use crate::index::{CacheTreeNode, Index, IndexEntry, MODE_SYMLINK};
40use crate::objects::{
41    parse_commit, CommitData, FileMode, ObjectId, ObjectKind, TreeEntry, TreeEntryRef, TreeRefIter,
42};
43use crate::odb::{Odb, SessionTree};
44use crate::parallel::ThreadPolicy;
45use crate::repo::Repository;
46use crate::userdiff::FuncnameMatcher;
47use crate::worktree_rules::WorktreeRules;
48
49use super::rename_lines::RenameLineTable;
50use super::unified_emit::{
51    emit_unified_body_from_edits, line_ending_only_edits, EditSpan, LineEnding, LineSide,
52    UnifiedEmitOptions,
53};
54
55/// Comparison keys for every line of `side`, which must be [`LineSide::from_text`] of `text`.
56///
57/// Without whitespace normalization a key is the raw line including its `\n` (the same bytes as
58/// `line_diff_token`), borrowed from `text`. Under `-w`/`-b`/`--ignore-space-at-eol` the keys
59/// are normalized line bodies, kept in `normalized` so the returned slices can borrow them.
60fn line_compare_keys<'a>(
61    text: &'a str,
62    side: &LineSide<'a>,
63    ws: super::WhitespaceMode,
64    normalized: &'a mut Vec<Cow<'a, str>>,
65) -> Vec<&'a str> {
66    if ws.normalizes_line_compare() {
67        // Git -w/-b compare normalized bodies only; EOF newline is whitespace.
68        normalized.extend(
69            side.lines
70                .iter()
71                .map(|line| ws.normalize_line_for_compare(line.body)),
72        );
73        return normalized.iter().map(Cow::as_ref).collect();
74    }
75    // Each body is a subslice of `text` that stops just before its `\n`, so the key is the body
76    // widened by that one byte.
77    let base = text.as_ptr() as usize;
78    side.lines
79        .iter()
80        .map(|line| {
81            let start = line.body.as_ptr() as usize - base;
82            let end = start + line.body.len() + usize::from(line.ending != LineEnding::Missing);
83            &text[start..end]
84        })
85        .collect()
86}
87
88#[path = "../structured_diff.rs"]
89pub mod structured;
90
91pub(crate) fn imara_unified_hunk_slices(body: &str) -> Vec<&str> {
92    let mut starts: Vec<usize> = Vec::new();
93    if body.starts_with("@@") {
94        starts.push(0);
95    }
96    for (idx, _) in body.match_indices("\n@@ ") {
97        starts.push(idx + 1);
98    }
99    starts.push(body.len());
100    starts.windows(2).map(|w| &body[w[0]..w[1]]).collect()
101}
102
103/// Histogram line alignment (Git `xhistogram.c`), then `xdl_change_compact` and indent heuristic.
104pub(crate) fn histogram_compacted_line_ops(
105    old_content: &str,
106    new_content: &str,
107    indent_heuristic: bool,
108    ws: super::WhitespaceMode,
109) -> Vec<similar::DiffOp> {
110    let before = LineSide::from_text(old_content);
111    let after = LineSide::from_text(new_content);
112    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
113    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
114    let old_len = old_lines.len();
115    let new_len = new_lines.len();
116    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
117    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
118    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
119
120    let (changed1, changed2) = git_xdiff::histogram_changed_flags(&old_compare, &new_compare);
121
122    if !changed1.iter().any(|c| *c) && !changed2.iter().any(|c| *c) {
123        return Vec::new();
124    }
125    let raw = changed_flags_to_ops(&changed1, &changed2, old_len, new_len);
126    diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
127        &raw,
128        diff_indent_heuristic::CompactSide {
129            lines: &old_lines,
130            keys: &old_compare,
131            compare_ids: None,
132        },
133        diff_indent_heuristic::CompactSide {
134            lines: &new_lines,
135            keys: &new_compare,
136            compare_ids: None,
137        },
138        indent_heuristic,
139        ws,
140        true,
141    )
142}
143
144pub(crate) fn histogram_unified_body_raw(
145    old_content: &str,
146    new_content: &str,
147    context_lines: usize,
148    inter_hunk_context: usize,
149    indent_heuristic: bool,
150    ws: super::WhitespaceMode,
151) -> String {
152    let before_side = LineSide::from_text(old_content);
153    let after_side = LineSide::from_text(new_content);
154    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
155    let mut edits = diff_ops_to_edit_spans(&ops);
156    if edits.is_empty() {
157        if old_content != new_content && !ws.normalizes_line_compare() {
158            edits = line_ending_only_edits(&before_side, &after_side);
159        }
160        if edits.is_empty() {
161            return String::new();
162        }
163    }
164    emit_unified_body_from_edits(
165        &before_side,
166        &after_side,
167        &edits,
168        UnifiedEmitOptions {
169            context_lines,
170            inter_hunk_context,
171            function_context: false,
172        },
173        None,
174    )
175}
176
177/// Build the unified-diff body (no `---`/`+++` header) using Git's histogram
178/// algorithm, but applying `--ignore-blank-lines` / `-I` semantics through a
179/// Git-compatible implementation of the xdiff change-record machinery
180/// (`xdl_mark_ignorable_lines` + `xdl_get_hunk` + `xdl_emit_diff`).
181///
182/// `is_ignorable_change` is called with the slices of removed and added lines
183/// for one change record (each item is the line text without its trailing
184/// newline); it returns `true` when the whole change is ignorable (Git's
185/// `xch->ignore`). Ignorable changes that are far from substantive changes are
186/// dropped, and hunk boundaries are computed with Git's `max_ignorable`
187/// distance rules so the line numbers and surviving context match Git exactly.
188///
189/// Returns the body only; an empty string means no substantive change survives.
190/// Optional `--function-context` (`-W`) hunk expansion is applied *after*
191/// ignorable change-record pruning so the boundaries/funcname header match Git.
192#[allow(clippy::too_many_arguments)]
193fn diff_ops_to_ignorable_edit_spans<F>(
194    ops: &[similar::DiffOp],
195    old_lines: &[&str],
196    new_lines: &[&str],
197    is_ignorable_change: F,
198) -> Vec<EditSpan>
199where
200    F: Fn(&[&str], &[&str]) -> bool,
201{
202    use similar::DiffOp;
203    let mut out = Vec::new();
204    for op in ops {
205        match *op {
206            DiffOp::Equal { .. } => {}
207            DiffOp::Delete {
208                old_index,
209                old_len,
210                new_index,
211            } => {
212                let removed = &old_lines[old_index..old_index + old_len];
213                out.push(EditSpan {
214                    before_start: old_index as i64,
215                    before_len: old_len as i64,
216                    after_start: new_index as i64,
217                    after_len: 0,
218                    skip_in_output: is_ignorable_change(removed, &[]),
219                });
220            }
221            DiffOp::Insert {
222                old_index,
223                new_index,
224                new_len,
225            } => {
226                let added = &new_lines[new_index..new_index + new_len];
227                out.push(EditSpan {
228                    before_start: old_index as i64,
229                    before_len: 0,
230                    after_start: new_index as i64,
231                    after_len: new_len as i64,
232                    skip_in_output: is_ignorable_change(&[], added),
233                });
234            }
235            DiffOp::Replace {
236                old_index,
237                old_len,
238                new_index,
239                new_len,
240            } => {
241                let removed = &old_lines[old_index..old_index + old_len];
242                let added = &new_lines[new_index..new_index + new_len];
243                out.push(EditSpan {
244                    before_start: old_index as i64,
245                    before_len: old_len as i64,
246                    after_start: new_index as i64,
247                    after_len: new_len as i64,
248                    skip_in_output: is_ignorable_change(removed, added),
249                });
250            }
251        }
252    }
253    out
254}
255
256#[allow(clippy::too_many_arguments)]
257pub(crate) fn histogram_unified_body_ignore_fc<F>(
258    old_content: &str,
259    new_content: &str,
260    context_lines: usize,
261    inter_hunk_context: usize,
262    function_context: bool,
263    funcname_matcher: Option<&FuncnameMatcher>,
264    indent_heuristic: bool,
265    ws: super::WhitespaceMode,
266    is_ignorable_change: F,
267) -> String
268where
269    F: Fn(&[&str], &[&str]) -> bool,
270{
271    let before = LineSide::from_text(old_content);
272    let after = LineSide::from_text(new_content);
273    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
274    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
275    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
276    if ops.is_empty() {
277        return String::new();
278    }
279    let edits = diff_ops_to_ignorable_edit_spans(&ops, &old_lines, &new_lines, is_ignorable_change);
280    emit_unified_body_from_edits(
281        &before,
282        &after,
283        &edits,
284        UnifiedEmitOptions {
285            context_lines,
286            inter_hunk_context,
287            function_context,
288        },
289        funcname_matcher,
290    )
291}
292
293/// Line bodies of `side`, as the change compaction reads them.
294fn line_bodies<'a>(side: &LineSide<'a>) -> Vec<&'a str> {
295    side.lines.iter().map(|l| l.body).collect()
296}
297
298/// Count insertions and deletions from compacted line diff ops (patch/stat alignment).
299#[must_use]
300fn count_diff_ops(ops: &[similar::DiffOp]) -> (usize, usize) {
301    use similar::DiffOp;
302    let mut insertions = 0usize;
303    let mut deletions = 0usize;
304    for op in ops {
305        match *op {
306            DiffOp::Equal { .. } => {}
307            DiffOp::Delete { old_len, .. } => deletions += old_len,
308            DiffOp::Insert { new_len, .. } => insertions += new_len,
309            DiffOp::Replace {
310                old_len, new_len, ..
311            } => {
312                deletions += old_len;
313                insertions += new_len;
314            }
315        }
316    }
317    (insertions, deletions)
318}
319
320/// Line alignment ops shared by unified patches, `--numstat`, and `-G`.
321pub(crate) fn compacted_line_ops_for_options(
322    old_content: &str,
323    new_content: &str,
324    options: &crate::diff::options::DiffOptions,
325) -> Vec<similar::DiffOp> {
326    use crate::diff::options::DiffAlgorithm;
327
328    let ws = options.whitespace;
329    match options.algorithm {
330        DiffAlgorithm::Histogram => {
331            histogram_compacted_line_ops(old_content, new_content, options.indent_heuristic, ws)
332        }
333        DiffAlgorithm::Patience => {
334            let before = LineSide::from_text(old_content);
335            let after = LineSide::from_text(new_content);
336            if before.is_empty() && after.is_empty() {
337                return Vec::new();
338            }
339            let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
340            let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
341            let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
342            let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
343            let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
344            let (changed1, changed2) =
345                git_xdiff::patience_changed_flags(&old_compare, &new_compare);
346            let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
347            diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
348                &raw,
349                diff_indent_heuristic::CompactSide {
350                    lines: &old_refs,
351                    keys: &old_compare,
352                    compare_ids: None,
353                },
354                diff_indent_heuristic::CompactSide {
355                    lines: &new_refs,
356                    keys: &new_compare,
357                    compare_ids: None,
358                },
359                options.indent_heuristic,
360                ws,
361                false,
362            )
363        }
364        DiffAlgorithm::Myers | DiffAlgorithm::Minimal => {
365            let before = LineSide::from_text(old_content);
366            let after = LineSide::from_text(new_content);
367            if before.is_empty() && after.is_empty() {
368                return Vec::new();
369            }
370            let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
371            let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
372            let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
373            git_xdiff_compacted_line_ops(
374                &old_compare,
375                &new_compare,
376                &before,
377                &after,
378                options.indent_heuristic,
379                options.algorithm == DiffAlgorithm::Minimal,
380            )
381        }
382    }
383}
384
385/// Count changed lines using the same alignment as structured patches and `--numstat`.
386#[must_use]
387pub(crate) fn count_line_changes_with_options(
388    old_content: &str,
389    new_content: &str,
390    options: &crate::diff::options::DiffOptions,
391) -> (usize, usize) {
392    let ws = options.whitespace;
393    let ops = compacted_line_ops_for_options(old_content, new_content, options);
394    let (mut insertions, mut deletions) = count_diff_ops(&ops);
395    if insertions == 0
396        && deletions == 0
397        && old_content != new_content
398        && !ws.normalizes_line_compare()
399    {
400        let before = LineSide::from_text(old_content);
401        let after = LineSide::from_text(new_content);
402        for edit in line_ending_only_edits(&before, &after) {
403            insertions += edit.after_len as usize;
404            deletions += edit.before_len as usize;
405        }
406    }
407    (insertions, deletions)
408}
409
410/// Myers changed flags (via [`git_xdiff::changed_flags_with_shared`]) plus interned compare keys.
411type XdiffTrimmedIds = ((Vec<bool>, Vec<bool>), Vec<u32>, Vec<u32>);
412
413fn xdiff_changed_flags_trimmed_ids(
414    old_compare: &[&str],
415    new_compare: &[&str],
416    need_min: bool,
417) -> XdiffTrimmedIds {
418    use crate::line_interner::LineInterner;
419
420    let nrec1 = old_compare.len();
421    let nrec2 = new_compare.len();
422    if nrec1 == 0 && nrec2 == 0 {
423        return ((Vec::new(), Vec::new()), Vec::new(), Vec::new());
424    }
425    let mut interner = LineInterner::with_capacity(nrec1 + nrec2);
426    let ids1_full = interner.intern_all(old_compare);
427    let ids2_full = interner.intern_all(new_compare);
428
429    let lim = nrec1.min(nrec2);
430    let mut head = 0usize;
431    while head < lim && ids1_full[head] == ids2_full[head] {
432        head += 1;
433    }
434    let mut tail = 0usize;
435    let rem = lim - head;
436    while tail < rem && ids1_full[nrec1 - 1 - tail] == ids2_full[nrec2 - 1 - tail] {
437        tail += 1;
438    }
439    let shared_records = head + tail;
440    let (full1, full2) = if shared_records == 0 {
441        git_xdiff::changed_flags(&ids1_full, &ids2_full, need_min)
442    } else {
443        let cut_ids1 = &ids1_full[head..nrec1 - tail];
444        let cut_ids2 = &ids2_full[head..nrec2 - tail];
445        let nclasses = ids1_full
446            .iter()
447            .chain(&ids2_full)
448            .max()
449            .map_or(0, |&max| max as usize + 1);
450        let mut shared_counts = vec![0u32; nclasses];
451        for &id in &ids1_full[..head] {
452            shared_counts[id as usize] += 1;
453        }
454        for &id in &ids1_full[nrec1 - tail..] {
455            shared_counts[id as usize] += 1;
456        }
457        let (mid1, mid2) = git_xdiff::changed_flags_with_shared(
458            cut_ids1,
459            cut_ids2,
460            &shared_counts,
461            shared_records,
462            need_min,
463        );
464        let mut full1 = vec![false; nrec1];
465        let mut full2 = vec![false; nrec2];
466        if !mid1.is_empty() {
467            full1[head..nrec1 - tail].copy_from_slice(&mid1);
468        }
469        if !mid2.is_empty() {
470            full2[head..nrec2 - tail].copy_from_slice(&mid2);
471        }
472        (full1, full2)
473    };
474    ((full1, full2), ids1_full, ids2_full)
475}
476
477pub(crate) fn git_xdiff_compacted_line_ops(
478    old_compare: &[&str],
479    new_compare: &[&str],
480    before: &LineSide<'_>,
481    after: &LineSide<'_>,
482    indent_heuristic: bool,
483    need_min: bool,
484) -> Vec<similar::DiffOp> {
485    let ((changed1, changed2), old_key_ids, new_key_ids) =
486        xdiff_changed_flags_trimmed_ids(old_compare, new_compare, need_min);
487    let old_lines = line_bodies(before);
488    let new_lines = line_bodies(after);
489    diff_indent_heuristic::apply_change_compact_to_masks(
490        changed1,
491        changed2,
492        diff_indent_heuristic::CompactSide {
493            lines: &old_lines,
494            keys: old_compare,
495            compare_ids: Some(&old_key_ids),
496        },
497        diff_indent_heuristic::CompactSide {
498            lines: &new_lines,
499            keys: new_compare,
500            compare_ids: Some(&new_key_ids),
501        },
502        indent_heuristic,
503    )
504}
505
506fn diff_ops_to_edit_spans(ops: &[similar::DiffOp]) -> Vec<EditSpan> {
507    use similar::DiffOp;
508    let mut out = Vec::new();
509    for op in ops {
510        match *op {
511            DiffOp::Equal { .. } => {}
512            DiffOp::Delete {
513                old_index,
514                old_len,
515                new_index,
516            } => out.push(EditSpan {
517                before_start: old_index as i64,
518                before_len: old_len as i64,
519                after_start: new_index as i64,
520                after_len: 0,
521                skip_in_output: false,
522            }),
523            DiffOp::Insert {
524                old_index,
525                new_index,
526                new_len,
527            } => out.push(EditSpan {
528                before_start: old_index as i64,
529                before_len: 0,
530                after_start: new_index as i64,
531                after_len: new_len as i64,
532                skip_in_output: false,
533            }),
534            DiffOp::Replace {
535                old_index,
536                old_len,
537                new_index,
538                new_len,
539            } => out.push(EditSpan {
540                before_start: old_index as i64,
541                before_len: old_len as i64,
542                after_start: new_index as i64,
543                after_len: new_len as i64,
544                skip_in_output: false,
545            }),
546        }
547    }
548    out
549}
550
551/// Unified hunk body using Git's xdiff engine (no `---` / `+++`).
552fn git_xdiff_unified_body_raw(
553    old_content: &str,
554    new_content: &str,
555    context_lines: usize,
556    inter_hunk_context: usize,
557    indent_heuristic: bool,
558    need_min: bool,
559    ws: super::WhitespaceMode,
560) -> String {
561    git_xdiff_unified_body_raw_with_ignorable(
562        old_content,
563        new_content,
564        context_lines,
565        inter_hunk_context,
566        indent_heuristic,
567        need_min,
568        ws,
569        None::<fn(&[&str], &[&str]) -> bool>,
570    )
571}
572
573#[allow(clippy::too_many_arguments)]
574fn git_xdiff_unified_body_raw_with_ignorable<F>(
575    old_content: &str,
576    new_content: &str,
577    context_lines: usize,
578    inter_hunk_context: usize,
579    indent_heuristic: bool,
580    need_min: bool,
581    ws: super::WhitespaceMode,
582    is_ignorable_change: Option<F>,
583) -> String
584where
585    F: Fn(&[&str], &[&str]) -> bool,
586{
587    let before = LineSide::from_text(old_content);
588    let after = LineSide::from_text(new_content);
589    if before.is_empty() && after.is_empty() {
590        return String::new();
591    }
592    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
593    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
594    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
595    let ops = git_xdiff_compacted_line_ops(
596        &old_compare,
597        &new_compare,
598        &before,
599        &after,
600        indent_heuristic,
601        need_min,
602    );
603    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
604        diff_ops_to_ignorable_edit_spans(
605            &ops,
606            &line_bodies(&before),
607            &line_bodies(&after),
608            is_ignorable,
609        )
610    } else {
611        diff_ops_to_edit_spans(&ops)
612    };
613    if edits.is_empty() {
614        if old_content != new_content && !ws.normalizes_line_compare() {
615            edits = line_ending_only_edits(&before, &after);
616        }
617        if edits.is_empty() {
618            return String::new();
619        }
620    }
621    emit_unified_body_from_edits(
622        &before,
623        &after,
624        &edits,
625        UnifiedEmitOptions {
626            context_lines,
627            inter_hunk_context,
628            function_context: false,
629        },
630        None,
631    )
632}
633
634#[allow(clippy::too_many_arguments)]
635fn git_patience_unified_body_raw_with_ignorable<F>(
636    old_content: &str,
637    new_content: &str,
638    context_lines: usize,
639    inter_hunk_context: usize,
640    indent_heuristic: bool,
641    ws: super::WhitespaceMode,
642    is_ignorable_change: Option<F>,
643) -> String
644where
645    F: Fn(&[&str], &[&str]) -> bool,
646{
647    let before = LineSide::from_text(old_content);
648    let after = LineSide::from_text(new_content);
649    if before.is_empty() && after.is_empty() {
650        return String::new();
651    }
652    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
653    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
654    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
655    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
656    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
657    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
658    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
659    let ops = diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
660        &raw,
661        diff_indent_heuristic::CompactSide {
662            lines: &old_refs,
663            keys: &old_compare,
664            compare_ids: None,
665        },
666        diff_indent_heuristic::CompactSide {
667            lines: &new_refs,
668            keys: &new_compare,
669            compare_ids: None,
670        },
671        indent_heuristic,
672        ws,
673        false,
674    );
675    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
676        diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
677    } else {
678        diff_ops_to_edit_spans(&ops)
679    };
680    if edits.is_empty() {
681        if old_content != new_content && !ws.normalizes_line_compare() {
682            edits = line_ending_only_edits(&before, &after);
683        }
684        if edits.is_empty() {
685            return String::new();
686        }
687    }
688    emit_unified_body_from_edits(
689        &before,
690        &after,
691        &edits,
692        UnifiedEmitOptions {
693            context_lines,
694            inter_hunk_context,
695            function_context: false,
696        },
697        None,
698    )
699}
700
701fn blank_line_ignorable(ws: super::WhitespaceMode) -> impl Fn(&[&str], &[&str]) -> bool + Copy {
702    move |removed: &[&str], added: &[&str]| {
703        ws.ignore_blank_lines
704            && removed.iter().all(|l| crate::ws::ws_blank_line(l))
705            && added.iter().all(|l| crate::ws::ws_blank_line(l))
706    }
707}
708
709/// Unified hunk bodies (`@@` …) for [`DiffOptions`], shared by structured and log-patch output.
710pub(crate) fn unified_hunk_body_for_diff_options(
711    old_content: &str,
712    new_content: &str,
713    options: &crate::diff::options::DiffOptions,
714) -> String {
715    use crate::diff::options::DiffAlgorithm;
716
717    let ws = options.whitespace;
718    if ws.is_active() {
719        let blank_only = blank_line_ignorable(ws);
720        if ws.normalizes_line_compare() {
721            let ignorable = ws.ignore_blank_lines.then_some(blank_only);
722            return match options.algorithm {
723                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
724                    old_content,
725                    new_content,
726                    options.context_lines,
727                    options.inter_hunk_context,
728                    options.indent_heuristic,
729                    false,
730                    ws,
731                    ignorable,
732                ),
733                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
734                    old_content,
735                    new_content,
736                    options.context_lines,
737                    options.inter_hunk_context,
738                    options.indent_heuristic,
739                    true,
740                    ws,
741                    ignorable,
742                ),
743                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
744                    old_content,
745                    new_content,
746                    options.context_lines,
747                    options.inter_hunk_context,
748                    options.indent_heuristic,
749                    ws,
750                    ignorable,
751                ),
752                DiffAlgorithm::Histogram => {
753                    if ws.ignore_blank_lines {
754                        histogram_unified_body_ignore_fc(
755                            old_content,
756                            new_content,
757                            options.context_lines,
758                            options.inter_hunk_context,
759                            false,
760                            None,
761                            options.indent_heuristic,
762                            ws,
763                            blank_only,
764                        )
765                    } else {
766                        histogram_unified_body_raw(
767                            old_content,
768                            new_content,
769                            options.context_lines,
770                            options.inter_hunk_context,
771                            options.indent_heuristic,
772                            ws,
773                        )
774                    }
775                }
776            };
777        }
778        if ws.ignore_blank_lines {
779            return match options.algorithm {
780                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
781                    old_content,
782                    new_content,
783                    options.context_lines,
784                    options.inter_hunk_context,
785                    options.indent_heuristic,
786                    false,
787                    ws,
788                    Some(blank_only),
789                ),
790                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
791                    old_content,
792                    new_content,
793                    options.context_lines,
794                    options.inter_hunk_context,
795                    options.indent_heuristic,
796                    true,
797                    ws,
798                    Some(blank_only),
799                ),
800                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
801                    old_content,
802                    new_content,
803                    options.context_lines,
804                    options.inter_hunk_context,
805                    options.indent_heuristic,
806                    ws,
807                    Some(blank_only),
808                ),
809                DiffAlgorithm::Histogram => histogram_unified_body_ignore_fc(
810                    old_content,
811                    new_content,
812                    options.context_lines,
813                    options.inter_hunk_context,
814                    false,
815                    None,
816                    options.indent_heuristic,
817                    ws,
818                    blank_only,
819                ),
820            };
821        }
822    }
823    match options.algorithm {
824        DiffAlgorithm::Myers => git_xdiff_unified_body_raw(
825            old_content,
826            new_content,
827            options.context_lines,
828            options.inter_hunk_context,
829            options.indent_heuristic,
830            false,
831            ws,
832        ),
833        DiffAlgorithm::Minimal => git_xdiff_unified_body_raw(
834            old_content,
835            new_content,
836            options.context_lines,
837            options.inter_hunk_context,
838            options.indent_heuristic,
839            true,
840            ws,
841        ),
842        DiffAlgorithm::Patience => git_patience_unified_body_raw(
843            old_content,
844            new_content,
845            options.context_lines,
846            options.inter_hunk_context,
847            options.indent_heuristic,
848        ),
849        DiffAlgorithm::Histogram => histogram_unified_body_raw(
850            old_content,
851            new_content,
852            options.context_lines,
853            options.inter_hunk_context,
854            options.indent_heuristic,
855            ws,
856        ),
857    }
858}
859
860/// Unified hunk body using Git's patience diff (no `---` / `+++`).
861fn git_patience_unified_body_raw(
862    old_content: &str,
863    new_content: &str,
864    context_lines: usize,
865    inter_hunk_context: usize,
866    indent_heuristic: bool,
867) -> String {
868    let before = LineSide::from_text(old_content);
869    let after = LineSide::from_text(new_content);
870    if before.is_empty() && after.is_empty() {
871        return String::new();
872    }
873    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
874    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
875    let ws = super::WhitespaceMode::default();
876    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
877    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
878    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
879    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
880    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
881    let ops = diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
882        &raw,
883        diff_indent_heuristic::CompactSide {
884            lines: &old_refs,
885            keys: &old_compare,
886            compare_ids: None,
887        },
888        diff_indent_heuristic::CompactSide {
889            lines: &new_refs,
890            keys: &new_compare,
891            compare_ids: None,
892        },
893        indent_heuristic,
894        ws,
895        false,
896    );
897    let mut edits = diff_ops_to_edit_spans(&ops);
898    if edits.is_empty() {
899        if old_content != new_content {
900            edits = line_ending_only_edits(&before, &after);
901        }
902        if edits.is_empty() {
903            return String::new();
904        }
905    }
906    emit_unified_body_from_edits(
907        &before,
908        &after,
909        &edits,
910        UnifiedEmitOptions {
911            context_lines,
912            inter_hunk_context,
913            function_context: false,
914        },
915        None,
916    )
917}
918
919/// Parse `@@ -old +new @@` into 1-based starts and line counts (Git display form).
920pub(crate) fn unified_hunk_header_counts(
921    header_line: &str,
922) -> Option<(usize, usize, usize, usize)> {
923    let rest = header_line.strip_prefix("@@ ")?;
924    let (old_part, rest2) = rest.split_once(" +")?;
925    let old_tok = old_part.strip_prefix('-')?.split(' ').next()?;
926    let new_tok = rest2.split(" @@").next()?.split(' ').next()?;
927
928    fn parse_side(spec: &str) -> (usize, usize) {
929        if let Some((start, count)) = spec.split_once(',') {
930            (start.parse().unwrap_or(0), count.parse().unwrap_or(0))
931        } else {
932            (spec.parse().unwrap_or(0), 1)
933        }
934    }
935
936    let (os, ol) = parse_side(old_tok);
937    let (ns, nl) = parse_side(new_tok);
938    Some((os, ol, ns, nl))
939}
940
941/// Write `---` / `+++` and unified hunks using [`unified_hunk_body_for_diff_options`].
942#[allow(clippy::too_many_arguments)]
943pub(crate) fn write_unified_diff_from_diff_options<W: std::io::Write>(
944    out: &mut W,
945    old_content: &str,
946    new_content: &str,
947    old_path: &str,
948    new_path: &str,
949    src_prefix: &str,
950    dst_prefix: &str,
951    funcname_matcher: Option<&FuncnameMatcher>,
952    options: &crate::diff::options::DiffOptions,
953    quote_path_fully: bool,
954) -> Result<()> {
955    use crate::quote_path::format_diff_path_with_prefix;
956
957    if old_path == "/dev/null" {
958        out.write_all(b"--- /dev/null\n")?;
959    } else if src_prefix.is_empty() {
960        writeln!(out, "--- {old_path}")?;
961    } else {
962        out.write_all(b"--- ")?;
963        out.write_all(
964            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
965        )?;
966        out.write_all(b"\n")?;
967    }
968    if new_path == "/dev/null" {
969        out.write_all(b"+++ /dev/null\n")?;
970    } else if dst_prefix.is_empty() {
971        writeln!(out, "+++ {new_path}")?;
972    } else {
973        out.write_all(b"+++ ")?;
974        out.write_all(
975            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
976        )?;
977        out.write_all(b"\n")?;
978    }
979
980    let body = unified_hunk_body_for_diff_options(old_content, new_content, options);
981    if body.is_empty() {
982        return Ok(());
983    }
984    let old_lines: Vec<&str> = old_content.lines().collect();
985    for hunk_str in imara_unified_hunk_slices(&body) {
986        if hunk_str.is_empty() {
987            continue;
988        }
989        if let Some(first_newline) = hunk_str.find('\n') {
990            let header_line = &hunk_str[..first_newline];
991            let rest = &hunk_str[first_newline..];
992            if let Some(func_ctx) =
993                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
994            {
995                out.write_all(header_line.as_bytes())?;
996                out.write_all(b" ")?;
997                out.write_all(func_ctx.as_bytes())?;
998                out.write_all(rest.as_bytes())?;
999            } else {
1000                out.write_all(hunk_str.as_bytes())?;
1001            }
1002        } else {
1003            out.write_all(hunk_str.as_bytes())?;
1004        }
1005    }
1006    Ok(())
1007}
1008
1009#[expect(clippy::too_many_arguments)]
1010fn unified_diff_git_myers_with_prefix_and_funcname(
1011    old_content: &str,
1012    new_content: &str,
1013    old_path: &str,
1014    new_path: &str,
1015    context_lines: usize,
1016    inter_hunk_context: usize,
1017    src_prefix: &str,
1018    dst_prefix: &str,
1019    funcname_matcher: Option<&FuncnameMatcher>,
1020    indent_heuristic: bool,
1021    quote_path_fully: bool,
1022) -> String {
1023    use crate::quote_path::format_diff_path_with_prefix;
1024
1025    let body = git_xdiff_unified_body_raw(
1026        old_content,
1027        new_content,
1028        context_lines,
1029        inter_hunk_context,
1030        indent_heuristic,
1031        false,
1032        super::WhitespaceMode::default(),
1033    );
1034
1035    let mut output = String::new();
1036    if old_path == "/dev/null" {
1037        output.push_str("--- /dev/null\n");
1038    } else if src_prefix.is_empty() {
1039        output.push_str(&format!("--- {old_path}\n"));
1040    } else {
1041        output.push_str("--- ");
1042        output.push_str(&format_diff_path_with_prefix(
1043            src_prefix,
1044            old_path,
1045            quote_path_fully,
1046        ));
1047        output.push('\n');
1048    }
1049    if new_path == "/dev/null" {
1050        output.push_str("+++ /dev/null\n");
1051    } else if dst_prefix.is_empty() {
1052        output.push_str(&format!("+++ {new_path}\n"));
1053    } else {
1054        output.push_str("+++ ");
1055        output.push_str(&format_diff_path_with_prefix(
1056            dst_prefix,
1057            new_path,
1058            quote_path_fully,
1059        ));
1060        output.push('\n');
1061    }
1062
1063    let old_lines: Vec<&str> = old_content.lines().collect();
1064    for hunk_str in imara_unified_hunk_slices(&body) {
1065        if hunk_str.is_empty() {
1066            continue;
1067        }
1068        if let Some(first_newline) = hunk_str.find('\n') {
1069            let header_line = &hunk_str[..first_newline];
1070            let rest = &hunk_str[first_newline..];
1071            if let Some(func_ctx) =
1072                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1073            {
1074                output.push_str(header_line);
1075                output.push(' ');
1076                output.push_str(&func_ctx);
1077                output.push_str(rest);
1078            } else {
1079                output.push_str(hunk_str);
1080            }
1081        } else {
1082            output.push_str(hunk_str);
1083        }
1084    }
1085
1086    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1087}
1088
1089#[expect(clippy::too_many_arguments)]
1090fn unified_diff_git_minimal_with_prefix_and_funcname(
1091    old_content: &str,
1092    new_content: &str,
1093    old_path: &str,
1094    new_path: &str,
1095    context_lines: usize,
1096    inter_hunk_context: usize,
1097    src_prefix: &str,
1098    dst_prefix: &str,
1099    funcname_matcher: Option<&FuncnameMatcher>,
1100    indent_heuristic: bool,
1101    quote_path_fully: bool,
1102) -> String {
1103    use crate::quote_path::format_diff_path_with_prefix;
1104
1105    let body = git_xdiff_unified_body_raw(
1106        old_content,
1107        new_content,
1108        context_lines,
1109        inter_hunk_context,
1110        indent_heuristic,
1111        true,
1112        super::WhitespaceMode::default(),
1113    );
1114
1115    let mut output = String::new();
1116    if old_path == "/dev/null" {
1117        output.push_str("--- /dev/null\n");
1118    } else if src_prefix.is_empty() {
1119        output.push_str(&format!("--- {old_path}\n"));
1120    } else {
1121        output.push_str("--- ");
1122        output.push_str(&format_diff_path_with_prefix(
1123            src_prefix,
1124            old_path,
1125            quote_path_fully,
1126        ));
1127        output.push('\n');
1128    }
1129    if new_path == "/dev/null" {
1130        output.push_str("+++ /dev/null\n");
1131    } else if dst_prefix.is_empty() {
1132        output.push_str(&format!("+++ {new_path}\n"));
1133    } else {
1134        output.push_str("+++ ");
1135        output.push_str(&format_diff_path_with_prefix(
1136            dst_prefix,
1137            new_path,
1138            quote_path_fully,
1139        ));
1140        output.push('\n');
1141    }
1142
1143    let old_lines: Vec<&str> = old_content.lines().collect();
1144    for hunk_str in imara_unified_hunk_slices(&body) {
1145        if hunk_str.is_empty() {
1146            continue;
1147        }
1148        if let Some(first_newline) = hunk_str.find('\n') {
1149            let header_line = &hunk_str[..first_newline];
1150            let rest = &hunk_str[first_newline..];
1151            if let Some(func_ctx) =
1152                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1153            {
1154                output.push_str(header_line);
1155                output.push(' ');
1156                output.push_str(&func_ctx);
1157                output.push_str(rest);
1158            } else {
1159                output.push_str(hunk_str);
1160            }
1161        } else {
1162            output.push_str(hunk_str);
1163        }
1164    }
1165
1166    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1167}
1168
1169#[expect(clippy::too_many_arguments)]
1170fn unified_diff_git_patience_with_prefix_and_funcname(
1171    old_content: &str,
1172    new_content: &str,
1173    old_path: &str,
1174    new_path: &str,
1175    context_lines: usize,
1176    inter_hunk_context: usize,
1177    src_prefix: &str,
1178    dst_prefix: &str,
1179    funcname_matcher: Option<&FuncnameMatcher>,
1180    indent_heuristic: bool,
1181    quote_path_fully: bool,
1182) -> String {
1183    use crate::quote_path::format_diff_path_with_prefix;
1184
1185    let body = git_patience_unified_body_raw(
1186        old_content,
1187        new_content,
1188        context_lines,
1189        inter_hunk_context,
1190        indent_heuristic,
1191    );
1192
1193    let mut output = String::new();
1194    if old_path == "/dev/null" {
1195        output.push_str("--- /dev/null\n");
1196    } else if src_prefix.is_empty() {
1197        output.push_str(&format!("--- {old_path}\n"));
1198    } else {
1199        output.push_str("--- ");
1200        output.push_str(&format_diff_path_with_prefix(
1201            src_prefix,
1202            old_path,
1203            quote_path_fully,
1204        ));
1205        output.push('\n');
1206    }
1207    if new_path == "/dev/null" {
1208        output.push_str("+++ /dev/null\n");
1209    } else if dst_prefix.is_empty() {
1210        output.push_str(&format!("+++ {new_path}\n"));
1211    } else {
1212        output.push_str("+++ ");
1213        output.push_str(&format_diff_path_with_prefix(
1214            dst_prefix,
1215            new_path,
1216            quote_path_fully,
1217        ));
1218        output.push('\n');
1219    }
1220
1221    let old_lines: Vec<&str> = old_content.lines().collect();
1222    for hunk_str in imara_unified_hunk_slices(&body) {
1223        if hunk_str.is_empty() {
1224            continue;
1225        }
1226        if let Some(first_newline) = hunk_str.find('\n') {
1227            let header_line = &hunk_str[..first_newline];
1228            let rest = &hunk_str[first_newline..];
1229            if let Some(func_ctx) =
1230                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1231            {
1232                output.push_str(header_line);
1233                output.push(' ');
1234                output.push_str(&func_ctx);
1235                output.push_str(rest);
1236            } else {
1237                output.push_str(hunk_str);
1238            }
1239        } else {
1240            output.push_str(hunk_str);
1241        }
1242    }
1243
1244    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1245}
1246
1247/// Unified diff hunks for Git's histogram algorithm (no `---` / `+++` lines).
1248///
1249/// Used by `--no-index` when whitespace normalization is off so the patch matches upstream Git.
1250#[must_use]
1251pub fn unified_diff_histogram_hunks_only(
1252    old_content: &str,
1253    new_content: &str,
1254    context_lines: usize,
1255    inter_hunk_context: usize,
1256    indent_heuristic: bool,
1257) -> String {
1258    histogram_unified_body_raw(
1259        old_content,
1260        new_content,
1261        context_lines,
1262        inter_hunk_context,
1263        indent_heuristic,
1264        super::WhitespaceMode::default(),
1265    )
1266}
1267
1268#[expect(clippy::too_many_arguments)]
1269fn unified_diff_histogram_with_prefix_and_funcname_indent(
1270    old_content: &str,
1271    new_content: &str,
1272    old_path: &str,
1273    new_path: &str,
1274    context_lines: usize,
1275    inter_hunk_context: usize,
1276    src_prefix: &str,
1277    dst_prefix: &str,
1278    funcname_matcher: Option<&FuncnameMatcher>,
1279    indent_heuristic: bool,
1280    quote_path_fully: bool,
1281) -> String {
1282    use crate::quote_path::format_diff_path_with_prefix;
1283
1284    let body = histogram_unified_body_raw(
1285        old_content,
1286        new_content,
1287        context_lines,
1288        inter_hunk_context,
1289        indent_heuristic,
1290        super::WhitespaceMode::default(),
1291    );
1292
1293    let mut output = String::new();
1294    if old_path == "/dev/null" {
1295        output.push_str("--- /dev/null\n");
1296    } else if src_prefix.is_empty() {
1297        output.push_str(&format!("--- {old_path}\n"));
1298    } else {
1299        output.push_str("--- ");
1300        output.push_str(&format_diff_path_with_prefix(
1301            src_prefix,
1302            old_path,
1303            quote_path_fully,
1304        ));
1305        output.push('\n');
1306    }
1307    if new_path == "/dev/null" {
1308        output.push_str("+++ /dev/null\n");
1309    } else if dst_prefix.is_empty() {
1310        output.push_str(&format!("+++ {new_path}\n"));
1311    } else {
1312        output.push_str("+++ ");
1313        output.push_str(&format_diff_path_with_prefix(
1314            dst_prefix,
1315            new_path,
1316            quote_path_fully,
1317        ));
1318        output.push('\n');
1319    }
1320
1321    let old_lines: Vec<&str> = old_content.lines().collect();
1322    for hunk_str in imara_unified_hunk_slices(&body) {
1323        if hunk_str.is_empty() {
1324            continue;
1325        }
1326        if let Some(first_newline) = hunk_str.find('\n') {
1327            let header_line = &hunk_str[..first_newline];
1328            let rest = &hunk_str[first_newline..];
1329            if let Some(func_ctx) =
1330                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1331            {
1332                output.push_str(header_line);
1333                output.push(' ');
1334                output.push_str(&func_ctx);
1335                output.push_str(rest);
1336            } else {
1337                output.push_str(hunk_str);
1338            }
1339        } else {
1340            output.push_str(hunk_str);
1341        }
1342    }
1343
1344    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1345}
1346
1347/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm,
1348/// applying `--ignore-blank-lines` / `-I` change-record suppression
1349/// (`histogram_unified_body_ignore_fc`) and then attaching function-name hunk
1350/// headers like `unified_diff_histogram_with_prefix_and_funcname_indent`.
1351///
1352/// `is_ignorable_change` is given the removed/added line slices (without
1353/// trailing newlines) of one change record and returns whether the whole change
1354/// is ignorable. Returns an empty string when no substantive change survives so
1355/// the caller can hide the file entry.
1356#[allow(clippy::too_many_arguments)]
1357pub fn unified_diff_histogram_ignore_with_prefix_and_funcname<F>(
1358    old_content: &str,
1359    new_content: &str,
1360    old_path: &str,
1361    new_path: &str,
1362    context_lines: usize,
1363    inter_hunk_context: usize,
1364    src_prefix: &str,
1365    dst_prefix: &str,
1366    funcname_matcher: Option<&FuncnameMatcher>,
1367    quote_path_fully: bool,
1368    function_context: bool,
1369    indent_heuristic: bool,
1370    is_ignorable_change: F,
1371) -> String
1372where
1373    F: Fn(&[&str], &[&str]) -> bool,
1374{
1375    use crate::quote_path::format_diff_path_with_prefix;
1376
1377    let body = histogram_unified_body_ignore_fc(
1378        old_content,
1379        new_content,
1380        context_lines,
1381        inter_hunk_context,
1382        function_context,
1383        funcname_matcher,
1384        indent_heuristic,
1385        super::WhitespaceMode::default(),
1386        is_ignorable_change,
1387    );
1388    if body.is_empty() {
1389        return String::new();
1390    }
1391
1392    let mut output = String::new();
1393    if old_path == "/dev/null" {
1394        output.push_str("--- /dev/null\n");
1395    } else if src_prefix.is_empty() {
1396        output.push_str(&format!("--- {old_path}\n"));
1397    } else {
1398        output.push_str("--- ");
1399        output.push_str(&format_diff_path_with_prefix(
1400            src_prefix,
1401            old_path,
1402            quote_path_fully,
1403        ));
1404        output.push('\n');
1405    }
1406    if new_path == "/dev/null" {
1407        output.push_str("+++ /dev/null\n");
1408    } else if dst_prefix.is_empty() {
1409        output.push_str(&format!("+++ {new_path}\n"));
1410    } else {
1411        output.push_str("+++ ");
1412        output.push_str(&format_diff_path_with_prefix(
1413            dst_prefix,
1414            new_path,
1415            quote_path_fully,
1416        ));
1417        output.push('\n');
1418    }
1419
1420    let old_lines: Vec<&str> = old_content.lines().collect();
1421    for hunk_str in imara_unified_hunk_slices(&body) {
1422        if hunk_str.is_empty() {
1423            continue;
1424        }
1425        if let Some(first_newline) = hunk_str.find('\n') {
1426            let header_line = &hunk_str[..first_newline];
1427            let rest = &hunk_str[first_newline..];
1428            if let Some(func_ctx) =
1429                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1430            {
1431                output.push_str(header_line);
1432                output.push(' ');
1433                output.push_str(&func_ctx);
1434                output.push_str(rest);
1435            } else {
1436                output.push_str(hunk_str);
1437            }
1438        } else {
1439            output.push_str(hunk_str);
1440        }
1441    }
1442
1443    output
1444}
1445
1446/// `diff.indentHeuristic` from config (Git defaults to true when unset).
1447#[must_use]
1448pub fn indent_heuristic_from_config(config: &ConfigSet) -> bool {
1449    match config.get_bool("diff.indentHeuristic") {
1450        Some(Ok(b)) => b,
1451        Some(Err(_)) | None => true,
1452    }
1453}
1454
1455/// Resolve indent heuristic: `--no-indent-heuristic` and `--indent-heuristic` override config.
1456#[must_use]
1457pub fn resolve_indent_heuristic(
1458    config: &ConfigSet,
1459    cli_indent_heuristic: bool,
1460    cli_no_indent_heuristic: bool,
1461) -> bool {
1462    if cli_no_indent_heuristic {
1463        false
1464    } else if cli_indent_heuristic {
1465        true
1466    } else {
1467        indent_heuristic_from_config(config)
1468    }
1469}
1470
1471// ---------------------------------------------------------------------------
1472// Git-compatible implementation of the xdiff Myers engine,
1473// restricted to what the word-diff path needs (no rename/ignore-whitespace
1474// flags). Git's word diff runs the *default* xdiff Myers over the per-word
1475// token streams; matching Git's exact change-record selection requires
1476// reproducing its preprocessing (record classification + `xdl_cleanup_records`
1477// + `xdl_trim_ends`) and its divide-and-conquer split, because the histogram
1478// engine (and `similar`) pick different — but equally minimal — alignments for inputs
1479// with many repeated tokens (e.g. the `{`/`}`/`,` in the `bibtex` driver).
1480mod git_xdiff {
1481    use std::cell::RefCell;
1482
1483    const XDL_MAX_COST_MIN: i64 = 256;
1484    const XDL_HEUR_MIN_COST: i64 = 256;
1485    const XDL_SNAKE_CNT: i64 = 20;
1486    const XDL_K_HEUR: i64 = 4;
1487    const XDL_LINE_MAX: i64 = i64::MAX;
1488    const XDL_KPDIS_RUN: i64 = 4;
1489    const XDL_MAX_EQLIMIT: i64 = 1024;
1490    const XDL_SIMSCAN_WINDOW: i64 = 100;
1491
1492    // record-classification actions (xprepare.c)
1493    const DISCARD: u8 = 0;
1494    const KEEP: u8 = 1;
1495    const INVESTIGATE: u8 = 2;
1496
1497    /// Classical integer square root approximation using shifts (`xdl_bogosqrt`).
1498    fn bogosqrt(mut n: i64) -> i64 {
1499        let mut i: i64 = 1;
1500        while n > 0 {
1501            i <<= 1;
1502            n >>= 2;
1503        }
1504        i
1505    }
1506
1507    struct XdAlgoEnv {
1508        mxcost: i64,
1509        snake_cnt: i64,
1510        heur_min: i64,
1511    }
1512
1513    struct XdpSplit {
1514        i1: i64,
1515        i2: i64,
1516        min_lo: bool,
1517        min_hi: bool,
1518    }
1519
1520    /// A single side of the diff: token ids plus the `changed` flags and the
1521    /// `reference_index` mapping from effective record -> original record.
1522    struct XdFile<'a> {
1523        #[allow(dead_code)]
1524        ids: &'a [u32],
1525        changed: Vec<u8>,
1526        reference_index: Vec<usize>,
1527        /// Interned token id per effective record (parallel to `reference_index`).
1528        effective_ids: Vec<u32>,
1529        dstart: i64,
1530        dend: i64,
1531        nreff: i64,
1532    }
1533
1534    #[inline]
1535    fn effective_id(xdf: &XdFile<'_>, idx: i64) -> u32 {
1536        xdf.effective_ids[idx as usize]
1537    }
1538
1539    struct XdiffScratch {
1540        count1: Vec<i64>,
1541        count2: Vec<i64>,
1542        action1: Vec<u8>,
1543        action2: Vec<u8>,
1544        kvd: Vec<i64>,
1545    }
1546
1547    impl XdiffScratch {
1548        fn prepare_counts(&mut self, nclasses: usize) {
1549            if self.count1.len() < nclasses {
1550                self.count1.resize(nclasses, 0);
1551                self.count2.resize(nclasses, 0);
1552            }
1553            self.count1[..nclasses].fill(0);
1554            self.count2[..nclasses].fill(0);
1555        }
1556
1557        fn prepare_actions(&mut self, nrec1: usize, nrec2: usize) {
1558            let a1 = nrec1 + 1;
1559            let a2 = nrec2 + 1;
1560            if self.action1.len() < a1 {
1561                self.action1.resize(a1, 0);
1562            } else {
1563                self.action1[..a1].fill(0);
1564            }
1565            if self.action2.len() < a2 {
1566                self.action2.resize(a2, 0);
1567            } else {
1568                self.action2[..a2].fill(0);
1569            }
1570        }
1571
1572        fn prepare_kvd(&mut self, kvd_len: usize) {
1573            if self.kvd.len() < kvd_len {
1574                self.kvd.resize(kvd_len, 0);
1575            }
1576        }
1577    }
1578
1579    thread_local! { // hygiene: per-thread xdiff Myers scratch (class counts, K-vectors) reused across files
1580        static XDIFF_SCRATCH: RefCell<XdiffScratch> = const {
1581            RefCell::new(XdiffScratch {
1582                count1: Vec::new(),
1583                count2: Vec::new(),
1584                action1: Vec::new(),
1585                action2: Vec::new(),
1586                kvd: Vec::new(),
1587            })
1588        };
1589    }
1590
1591    fn bool_changed(changed: Vec<u8>) -> Vec<bool> {
1592        changed.into_iter().map(|b| b != 0).collect()
1593    }
1594
1595    /// Faithful port of `xdl_split` (the middle-snake finder). `kvdf`/`kvdb` are
1596    /// the forward/backward k-vectors, offset so index 0 maps to diagonal 0.
1597    #[allow(clippy::too_many_arguments)]
1598    fn xdl_split(
1599        xdf1: &XdFile<'_>,
1600        off1: i64,
1601        lim1: i64,
1602        xdf2: &XdFile<'_>,
1603        off2: i64,
1604        lim2: i64,
1605        kvdf: &mut [i64],
1606        kvdb: &mut [i64],
1607        koff: i64,
1608        need_min: bool,
1609        spl: &mut XdpSplit,
1610        xenv: &XdAlgoEnv,
1611    ) -> i64 {
1612        let dmin = off1 - lim2;
1613        let dmax = lim1 - off2;
1614        let fmid = off1 - off2;
1615        let bmid = lim1 - lim2;
1616        let odd = ((fmid - bmid) & 1) != 0;
1617        let mut fmin = fmid;
1618        let mut fmax = fmid;
1619        let mut bmin = bmid;
1620        let mut bmax = bmid;
1621
1622        let kf = |k: i64| -> usize { (k + koff) as usize };
1623
1624        kvdf[kf(fmid)] = off1;
1625        kvdb[kf(bmid)] = lim1;
1626
1627        let mut ec: i64 = 1;
1628        loop {
1629            let mut got_snake = false;
1630
1631            if fmin > dmin {
1632                fmin -= 1;
1633                kvdf[kf(fmin - 1)] = -1;
1634            } else {
1635                fmin += 1;
1636            }
1637            if fmax < dmax {
1638                fmax += 1;
1639                kvdf[kf(fmax + 1)] = -1;
1640            } else {
1641                fmax -= 1;
1642            }
1643
1644            let mut d = fmax;
1645            while d >= fmin {
1646                let mut i1 = if kvdf[kf(d - 1)] >= kvdf[kf(d + 1)] {
1647                    kvdf[kf(d - 1)] + 1
1648                } else {
1649                    kvdf[kf(d + 1)]
1650                };
1651                let prev1 = i1;
1652                let mut i2 = i1 - d;
1653                while i1 < lim1 && i2 < lim2 && effective_id(xdf1, i1) == effective_id(xdf2, i2) {
1654                    i1 += 1;
1655                    i2 += 1;
1656                }
1657                if i1 - prev1 > xenv.snake_cnt {
1658                    got_snake = true;
1659                }
1660                kvdf[kf(d)] = i1;
1661                if odd && bmin <= d && d <= bmax && kvdb[kf(d)] <= i1 {
1662                    spl.i1 = i1;
1663                    spl.i2 = i2;
1664                    spl.min_lo = true;
1665                    spl.min_hi = true;
1666                    return ec;
1667                }
1668                d -= 2;
1669            }
1670
1671            if bmin > dmin {
1672                bmin -= 1;
1673                kvdb[kf(bmin - 1)] = XDL_LINE_MAX;
1674            } else {
1675                bmin += 1;
1676            }
1677            if bmax < dmax {
1678                bmax += 1;
1679                kvdb[kf(bmax + 1)] = XDL_LINE_MAX;
1680            } else {
1681                bmax -= 1;
1682            }
1683
1684            let mut d = bmax;
1685            while d >= bmin {
1686                let mut i1 = if kvdb[kf(d - 1)] < kvdb[kf(d + 1)] {
1687                    kvdb[kf(d - 1)]
1688                } else {
1689                    kvdb[kf(d + 1)] - 1
1690                };
1691                let prev1 = i1;
1692                let mut i2 = i1 - d;
1693                while i1 > off1
1694                    && i2 > off2
1695                    && effective_id(xdf1, i1 - 1) == effective_id(xdf2, i2 - 1)
1696                {
1697                    i1 -= 1;
1698                    i2 -= 1;
1699                }
1700                if prev1 - i1 > xenv.snake_cnt {
1701                    got_snake = true;
1702                }
1703                kvdb[kf(d)] = i1;
1704                if !odd && fmin <= d && d <= fmax && i1 <= kvdf[kf(d)] {
1705                    spl.i1 = i1;
1706                    spl.i2 = i2;
1707                    spl.min_lo = true;
1708                    spl.min_hi = true;
1709                    return ec;
1710                }
1711                d -= 2;
1712            }
1713
1714            if need_min {
1715                ec += 1;
1716                continue;
1717            }
1718
1719            if got_snake && ec > xenv.heur_min {
1720                let mut best = 0i64;
1721                let mut d = fmax;
1722                while d >= fmin {
1723                    let dd = if d > fmid { d - fmid } else { fmid - d };
1724                    let i1 = kvdf[kf(d)];
1725                    let i2 = i1 - d;
1726                    let v = (i1 - off1) + (i2 - off2) - dd;
1727                    if v > XDL_K_HEUR * ec
1728                        && v > best
1729                        && off1 + xenv.snake_cnt <= i1
1730                        && i1 < lim1
1731                        && off2 + xenv.snake_cnt <= i2
1732                        && i2 < lim2
1733                    {
1734                        let mut k = 1i64;
1735                        while effective_id(xdf1, i1 - k) == effective_id(xdf2, i2 - k) {
1736                            if k == xenv.snake_cnt {
1737                                best = v;
1738                                spl.i1 = i1;
1739                                spl.i2 = i2;
1740                                break;
1741                            }
1742                            k += 1;
1743                        }
1744                    }
1745                    d -= 2;
1746                }
1747                if best > 0 {
1748                    spl.min_lo = true;
1749                    spl.min_hi = false;
1750                    return ec;
1751                }
1752
1753                let mut best = 0i64;
1754                let mut d = bmax;
1755                while d >= bmin {
1756                    let dd = if d > bmid { d - bmid } else { bmid - d };
1757                    let i1 = kvdb[kf(d)];
1758                    let i2 = i1 - d;
1759                    let v = (lim1 - i1) + (lim2 - i2) - dd;
1760                    if v > XDL_K_HEUR * ec
1761                        && v > best
1762                        && off1 < i1
1763                        && i1 <= lim1 - xenv.snake_cnt
1764                        && off2 < i2
1765                        && i2 <= lim2 - xenv.snake_cnt
1766                    {
1767                        let mut k = 0i64;
1768                        while effective_id(xdf1, i1 + k) == effective_id(xdf2, i2 + k) {
1769                            if k == xenv.snake_cnt - 1 {
1770                                best = v;
1771                                spl.i1 = i1;
1772                                spl.i2 = i2;
1773                                break;
1774                            }
1775                            k += 1;
1776                        }
1777                    }
1778                    d -= 2;
1779                }
1780                if best > 0 {
1781                    spl.min_lo = false;
1782                    spl.min_hi = true;
1783                    return ec;
1784                }
1785            }
1786
1787            if ec >= xenv.mxcost {
1788                let mut fbest = -1i64;
1789                let mut fbest1 = -1i64;
1790                let mut d = fmax;
1791                while d >= fmin {
1792                    let mut i1 = kvdf[kf(d)].min(lim1);
1793                    let mut i2 = i1 - d;
1794                    if lim2 < i2 {
1795                        i1 = lim2 + d;
1796                        i2 = lim2;
1797                    }
1798                    if fbest < i1 + i2 {
1799                        fbest = i1 + i2;
1800                        fbest1 = i1;
1801                    }
1802                    d -= 2;
1803                }
1804
1805                let mut bbest = XDL_LINE_MAX;
1806                let mut bbest1 = XDL_LINE_MAX;
1807                let mut d = bmax;
1808                while d >= bmin {
1809                    let mut i1 = off1.max(kvdb[kf(d)]);
1810                    let mut i2 = i1 - d;
1811                    if i2 < off2 {
1812                        i1 = off2 + d;
1813                        i2 = off2;
1814                    }
1815                    if i1 + i2 < bbest {
1816                        bbest = i1 + i2;
1817                        bbest1 = i1;
1818                    }
1819                    d -= 2;
1820                }
1821
1822                if (lim1 + lim2) - bbest < fbest - (off1 + off2) {
1823                    spl.i1 = fbest1;
1824                    spl.i2 = fbest - fbest1;
1825                    spl.min_lo = true;
1826                    spl.min_hi = false;
1827                } else {
1828                    spl.i1 = bbest1;
1829                    spl.i2 = bbest - bbest1;
1830                    spl.min_lo = false;
1831                    spl.min_hi = true;
1832                }
1833                return ec;
1834            }
1835
1836            ec += 1;
1837        }
1838    }
1839
1840    /// Faithful port of `xdl_recs_cmp` (divide & conquer). Marks `changed` flags.
1841    #[allow(clippy::too_many_arguments)]
1842    fn xdl_recs_cmp(
1843        xdf1: &mut XdFile<'_>,
1844        mut off1: i64,
1845        mut lim1: i64,
1846        xdf2: &mut XdFile<'_>,
1847        mut off2: i64,
1848        mut lim2: i64,
1849        kvdf: &mut [i64],
1850        kvdb: &mut [i64],
1851        koff: i64,
1852        need_min: bool,
1853        xenv: &XdAlgoEnv,
1854    ) {
1855        while off1 < lim1 && off2 < lim2 && effective_id(xdf1, off1) == effective_id(xdf2, off2) {
1856            off1 += 1;
1857            off2 += 1;
1858        }
1859        while off1 < lim1
1860            && off2 < lim2
1861            && effective_id(xdf1, lim1 - 1) == effective_id(xdf2, lim2 - 1)
1862        {
1863            lim1 -= 1;
1864            lim2 -= 1;
1865        }
1866
1867        if off1 == lim1 {
1868            while off2 < lim2 {
1869                let r = xdf2.reference_index[off2 as usize];
1870                xdf2.changed[r] = 1;
1871                off2 += 1;
1872            }
1873        } else if off2 == lim2 {
1874            while off1 < lim1 {
1875                let r = xdf1.reference_index[off1 as usize];
1876                xdf1.changed[r] = 1;
1877                off1 += 1;
1878            }
1879        } else {
1880            let mut spl = XdpSplit {
1881                i1: 0,
1882                i2: 0,
1883                min_lo: false,
1884                min_hi: false,
1885            };
1886            xdl_split(
1887                xdf1, off1, lim1, xdf2, off2, lim2, kvdf, kvdb, koff, need_min, &mut spl, xenv,
1888            );
1889            xdl_recs_cmp(
1890                xdf1, off1, spl.i1, xdf2, off2, spl.i2, kvdf, kvdb, koff, spl.min_lo, xenv,
1891            );
1892            xdl_recs_cmp(
1893                xdf1, spl.i1, lim1, xdf2, spl.i2, lim2, kvdf, kvdb, koff, spl.min_hi, xenv,
1894            );
1895        }
1896    }
1897
1898    /// `xdl_clean_mmatch`: decide whether a multimatch record should be discarded.
1899    fn clean_mmatch(action: &[u8], i: i64, mut s: i64, mut e: i64) -> bool {
1900        if i - s > XDL_SIMSCAN_WINDOW {
1901            s = i - XDL_SIMSCAN_WINDOW;
1902        }
1903        if e - i > XDL_SIMSCAN_WINDOW {
1904            e = i + XDL_SIMSCAN_WINDOW;
1905        }
1906
1907        let mut rdis0 = 0i64;
1908        let mut rpdis0 = 1i64;
1909        let mut r = 1i64;
1910        while i - r >= s {
1911            match action[(i - r) as usize] {
1912                DISCARD => rdis0 += 1,
1913                INVESTIGATE => rpdis0 += 1,
1914                _ => break, // KEEP
1915            }
1916            r += 1;
1917        }
1918        if rdis0 == 0 {
1919            return false;
1920        }
1921        let mut rdis1 = 0i64;
1922        let mut rpdis1 = 1i64;
1923        let mut r = 1i64;
1924        while i + r <= e {
1925            match action[(i + r) as usize] {
1926                DISCARD => rdis1 += 1,
1927                INVESTIGATE => rpdis1 += 1,
1928                _ => break, // KEEP
1929            }
1930            r += 1;
1931        }
1932        if rdis1 == 0 {
1933            return false;
1934        }
1935        rdis1 += rdis0;
1936        rpdis1 += rpdis0;
1937        rpdis1 * XDL_KPDIS_RUN < (rpdis1 + rdis1)
1938    }
1939
1940    /// Build the `changed` flags for both token streams using Git's xdiff engine.
1941    /// When `need_min` is true, Git's minimal diff (`--minimal`) is used.
1942    /// `ids1`/`ids2` are dense interned token ids from one [`crate::line_interner::LineInterner`]
1943    /// (equal id <=> equal token); per-id tables are sized by the largest id.
1944    /// Returns `(changed1, changed2)`, one bool per original token.
1945    pub fn changed_flags(ids1: &[u32], ids2: &[u32], need_min: bool) -> (Vec<bool>, Vec<bool>) {
1946        changed_flags_with_shared(ids1, ids2, &[], 0, need_min)
1947    }
1948
1949    /// Like [`changed_flags`] for slices cut from two larger files whose other records are
1950    /// identical on both sides.
1951    ///
1952    /// Git classifies each record by how often it occurs in the whole other file, so the
1953    /// records left out still count: `shared_counts[id]` is how many of them have token `id`
1954    /// (ids past its end have none), and `shared_records` is how many there are on each side.
1955    pub fn changed_flags_with_shared(
1956        ids1: &[u32],
1957        ids2: &[u32],
1958        shared_counts: &[u32],
1959        shared_records: usize,
1960        need_min: bool,
1961    ) -> (Vec<bool>, Vec<bool>) {
1962        XDIFF_SCRATCH.with(|cell| {
1963            changed_flags_with_shared_inner(
1964                cell,
1965                ids1,
1966                ids2,
1967                shared_counts,
1968                shared_records,
1969                need_min,
1970            )
1971        })
1972    }
1973
1974    fn changed_flags_with_shared_inner(
1975        scratch: &RefCell<XdiffScratch>,
1976        ids1: &[u32],
1977        ids2: &[u32],
1978        shared_counts: &[u32],
1979        shared_records: usize,
1980        need_min: bool,
1981    ) -> (Vec<bool>, Vec<bool>) {
1982        let nrec1 = ids1.len();
1983        let nrec2 = ids2.len();
1984
1985        // Per-token occurrence counts on each side (class len1/len2), indexed by the dense ids.
1986        let nclasses = ids1
1987            .iter()
1988            .chain(ids2)
1989            .max()
1990            .map_or(0, |&max| max as usize + 1);
1991        let mut work = scratch.borrow_mut();
1992        work.prepare_counts(nclasses);
1993        for &id in ids1 {
1994            work.count1[id as usize] += 1;
1995        }
1996        for &id in ids2 {
1997            work.count2[id as usize] += 1;
1998        }
1999        for (id, &shared) in shared_counts.iter().enumerate().take(nclasses) {
2000            work.count1[id] += i64::from(shared);
2001            work.count2[id] += i64::from(shared);
2002        }
2003
2004        let mut xdf1 = XdFile {
2005            ids: ids1,
2006            changed: vec![0u8; nrec1],
2007            reference_index: Vec::new(),
2008            effective_ids: Vec::new(),
2009            dstart: 0,
2010            dend: nrec1 as i64 - 1,
2011            nreff: 0,
2012        };
2013        let mut xdf2 = XdFile {
2014            ids: ids2,
2015            changed: vec![0u8; nrec2],
2016            reference_index: Vec::new(),
2017            effective_ids: Vec::new(),
2018            dstart: 0,
2019            dend: nrec2 as i64 - 1,
2020            nreff: 0,
2021        };
2022
2023        // xdl_trim_ends: trim leading/trailing matching records.
2024        let lim = nrec1.min(nrec2) as i64;
2025        let mut i = 0i64;
2026        while i < lim && ids1[i as usize] == ids2[i as usize] {
2027            i += 1;
2028        }
2029        xdf1.dstart = i;
2030        xdf2.dstart = i;
2031        let mut j = 0i64;
2032        let rem = lim - i;
2033        while j < rem && ids1[nrec1 - 1 - j as usize] == ids2[nrec2 - 1 - j as usize] {
2034            j += 1;
2035        }
2036        xdf1.dend = nrec1 as i64 - j - 1;
2037        xdf2.dend = nrec2 as i64 - j - 1;
2038
2039        // xdl_cleanup_records: classify and reduce to effective records.
2040        work.prepare_actions(nrec1, nrec2);
2041
2042        let mlim1 = if need_min {
2043            i64::MAX
2044        } else {
2045            let mut mlim = bogosqrt((nrec1 + shared_records) as i64);
2046            if mlim > XDL_MAX_EQLIMIT {
2047                mlim = XDL_MAX_EQLIMIT;
2048            }
2049            mlim
2050        };
2051        let mut idx = xdf1.dstart;
2052        while idx <= xdf1.dend {
2053            let id = ids1[idx as usize];
2054            let nm = work.count2[id as usize];
2055            work.action1[idx as usize] = if nm == 0 {
2056                DISCARD
2057            } else if nm >= mlim1 {
2058                INVESTIGATE
2059            } else {
2060                KEEP
2061            };
2062            idx += 1;
2063        }
2064
2065        let mlim2 = if need_min {
2066            i64::MAX
2067        } else {
2068            let mut mlim = bogosqrt((nrec2 + shared_records) as i64);
2069            if mlim > XDL_MAX_EQLIMIT {
2070                mlim = XDL_MAX_EQLIMIT;
2071            }
2072            mlim
2073        };
2074        let mut idx = xdf2.dstart;
2075        while idx <= xdf2.dend {
2076            let id = ids2[idx as usize];
2077            let nm = work.count1[id as usize];
2078            work.action2[idx as usize] = if nm == 0 {
2079                DISCARD
2080            } else if nm >= mlim2 {
2081                INVESTIGATE
2082            } else {
2083                KEEP
2084            };
2085            idx += 1;
2086        }
2087
2088        let mut idx = xdf1.dstart;
2089        while idx <= xdf1.dend {
2090            let id = ids1[idx as usize];
2091            let a = work.action1[idx as usize];
2092            if a == KEEP
2093                || (a == INVESTIGATE
2094                    && !clean_mmatch(&work.action1[..nrec1 + 1], idx, xdf1.dstart, xdf1.dend))
2095            {
2096                xdf1.reference_index.push(idx as usize);
2097                xdf1.effective_ids.push(id);
2098            } else {
2099                xdf1.changed[idx as usize] = 1;
2100            }
2101            idx += 1;
2102        }
2103        xdf1.nreff = xdf1.reference_index.len() as i64;
2104
2105        let mut idx = xdf2.dstart;
2106        while idx <= xdf2.dend {
2107            let id = ids2[idx as usize];
2108            let a = work.action2[idx as usize];
2109            if a == KEEP
2110                || (a == INVESTIGATE
2111                    && !clean_mmatch(&work.action2[..nrec2 + 1], idx, xdf2.dstart, xdf2.dend))
2112            {
2113                xdf2.reference_index.push(idx as usize);
2114                xdf2.effective_ids.push(id);
2115            } else {
2116                xdf2.changed[idx as usize] = 1;
2117            }
2118            idx += 1;
2119        }
2120        xdf2.nreff = xdf2.reference_index.len() as i64;
2121
2122        // Allocate K vectors (xdl_do_diff). koff lets us use negative diagonals.
2123        let ndiags = xdf1.nreff + xdf2.nreff + 3;
2124        let kvd_len = (2 * ndiags + 2) as usize;
2125        work.prepare_kvd(kvd_len);
2126        let kvd = &mut work.kvd[..kvd_len];
2127        // kvdf base offset = nreff2 + 1; kvdb base offset = ndiags + nreff2 + 1.
2128        let koff = xdf2.nreff + 1;
2129        let (kvdf_slice, kvdb_slice) = kvd.split_at_mut(ndiags as usize);
2130        // Both slices are indexed as [k + koff]; their length covers the diagonal range.
2131
2132        let xenv = XdAlgoEnv {
2133            mxcost: bogosqrt(ndiags).max(XDL_MAX_COST_MIN),
2134            snake_cnt: XDL_SNAKE_CNT,
2135            heur_min: XDL_HEUR_MIN_COST,
2136        };
2137
2138        let nreff1 = xdf1.nreff;
2139        let nreff2 = xdf2.nreff;
2140        xdl_recs_cmp(
2141            &mut xdf1, 0, nreff1, &mut xdf2, 0, nreff2, kvdf_slice, kvdb_slice, koff, need_min,
2142            &xenv,
2143        );
2144
2145        (bool_changed(xdf1.changed), bool_changed(xdf2.changed))
2146    }
2147
2148    /// Patience diff: match unique lines, then recurse on gaps with xdiff Myers.
2149    pub fn patience_changed_flags(lines1: &[&str], lines2: &[&str]) -> (Vec<bool>, Vec<bool>) {
2150        let mut changed1 = vec![false; lines1.len()];
2151        let mut changed2 = vec![false; lines2.len()];
2152        patience_recurse(
2153            lines1,
2154            lines2,
2155            &mut changed1,
2156            &mut changed2,
2157            0,
2158            lines1.len(),
2159            0,
2160            lines2.len(),
2161        );
2162        (changed1, changed2)
2163    }
2164
2165    #[allow(clippy::too_many_arguments)]
2166    fn patience_recurse(
2167        lines1: &[&str],
2168        lines2: &[&str],
2169        changed1: &mut [bool],
2170        changed2: &mut [bool],
2171        o_start: usize,
2172        o_end: usize,
2173        n_start: usize,
2174        n_end: usize,
2175    ) {
2176        if o_start >= o_end && n_start >= n_end {
2177            return;
2178        }
2179        if o_start >= o_end {
2180            changed2[n_start..n_end].fill(true);
2181            return;
2182        }
2183        if n_start >= n_end {
2184            changed1[o_start..o_end].fill(true);
2185            return;
2186        }
2187
2188        let anchors = patience_anchors(lines1, lines2, o_start, o_end, n_start, n_end);
2189        if anchors.is_empty() {
2190            merge_myers_region(
2191                lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
2192            );
2193            return;
2194        }
2195
2196        let mut prev_o = o_start;
2197        let mut prev_n = n_start;
2198        for (ao, an) in anchors {
2199            patience_recurse(lines1, lines2, changed1, changed2, prev_o, ao, prev_n, an);
2200            prev_o = ao + 1;
2201            prev_n = an + 1;
2202        }
2203        patience_recurse(
2204            lines1, lines2, changed1, changed2, prev_o, o_end, prev_n, n_end,
2205        );
2206    }
2207
2208    #[allow(clippy::too_many_arguments)]
2209    fn patience_anchors(
2210        lines1: &[&str],
2211        lines2: &[&str],
2212        o_start: usize,
2213        o_end: usize,
2214        n_start: usize,
2215        n_end: usize,
2216    ) -> Vec<(usize, usize)> {
2217        use std::collections::HashMap;
2218
2219        let old_slice = &lines1[o_start..o_end];
2220        let new_slice = &lines2[n_start..n_end];
2221        let mut old_pos: HashMap<&str, Vec<usize>> = HashMap::new();
2222        let mut new_pos: HashMap<&str, Vec<usize>> = HashMap::new();
2223        for (i, line) in old_slice.iter().enumerate() {
2224            old_pos.entry(*line).or_default().push(o_start + i);
2225        }
2226        for (i, line) in new_slice.iter().enumerate() {
2227            new_pos.entry(*line).or_default().push(n_start + i);
2228        }
2229
2230        let mut pairs: Vec<(usize, usize)> = Vec::new();
2231        for (line, o_indices) in &old_pos {
2232            if o_indices.len() != 1 {
2233                continue;
2234            }
2235            if let Some(n_indices) = new_pos.get(line) {
2236                if n_indices.len() == 1 {
2237                    pairs.push((o_indices[0], n_indices[0]));
2238                }
2239            }
2240        }
2241        pairs.sort_by_key(|p| p.0);
2242        longest_increasing_by_second(&pairs)
2243    }
2244
2245    fn longest_increasing_by_second(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
2246        if pairs.is_empty() {
2247            return Vec::new();
2248        }
2249        let n = pairs.len();
2250        let mut tails = vec![0usize; n];
2251        let mut prev = vec![usize::MAX; n];
2252        let mut len = 0usize;
2253        for i in 0..n {
2254            let v = pairs[i].1;
2255            let mut lo = 0usize;
2256            let mut hi = len;
2257            while lo < hi {
2258                let mid = (lo + hi) / 2;
2259                if pairs[tails[mid]].1 < v {
2260                    lo = mid + 1;
2261                } else {
2262                    hi = mid;
2263                }
2264            }
2265            if lo > 0 {
2266                prev[i] = tails[lo - 1];
2267            }
2268            tails[lo] = i;
2269            if lo == len {
2270                len += 1;
2271            }
2272        }
2273        let mut out = Vec::with_capacity(len);
2274        let mut k = tails[len.saturating_sub(1)];
2275        while k != usize::MAX {
2276            out.push(pairs[k]);
2277            k = prev[k];
2278        }
2279        out.reverse();
2280        out
2281    }
2282
2283    pub(crate) fn histogram_changed_flags(
2284        lines1: &[&str],
2285        lines2: &[&str],
2286    ) -> (Vec<bool>, Vec<bool>) {
2287        crate::diff::git_histogram::histogram_changed_flags(
2288            lines1,
2289            lines2,
2290            |changed1, changed2, o_start, o_end, n_start, n_end| {
2291                merge_myers_region(
2292                    lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
2293                );
2294            },
2295        )
2296    }
2297
2298    #[allow(clippy::too_many_arguments)]
2299    fn merge_myers_region(
2300        lines1: &[&str],
2301        lines2: &[&str],
2302        changed1: &mut [bool],
2303        changed2: &mut [bool],
2304        o_start: usize,
2305        o_end: usize,
2306        n_start: usize,
2307        n_end: usize,
2308    ) {
2309        use crate::line_interner::LineInterner;
2310
2311        let old_slice = &lines1[o_start..o_end];
2312        let new_slice = &lines2[n_start..n_end];
2313        if old_slice.is_empty() && new_slice.is_empty() {
2314            return;
2315        }
2316        let mut interner = LineInterner::with_capacity(old_slice.len() + new_slice.len());
2317        let ids1 = interner.intern_all(old_slice);
2318        let ids2 = interner.intern_all(new_slice);
2319        let (sub1, sub2) = changed_flags(&ids1, &ids2, false);
2320        for (i, ch) in sub1.iter().enumerate() {
2321            if *ch {
2322                changed1[o_start + i] = true;
2323            }
2324        }
2325        for (j, ch) in sub2.iter().enumerate() {
2326            if *ch {
2327                changed2[n_start + j] = true;
2328            }
2329        }
2330    }
2331}
2332
2333/// Myers changed-line masks for a line slice, honoring [`super::WhitespaceMode`] like Git `xdl_fall_back_diff`.
2334pub(crate) fn myers_rediff_changed_masks_for_region<'a>(
2335    old_lines: &[&'a str],
2336    new_lines: &[&'a str],
2337    ws: super::WhitespaceMode,
2338) -> (Vec<bool>, Vec<bool>) {
2339    use crate::line_interner::LineInterner;
2340
2341    if old_lines.is_empty() && new_lines.is_empty() {
2342        return (Vec::new(), Vec::new());
2343    }
2344    let normalize = |lines: &[&'a str]| -> Vec<Cow<'a, str>> {
2345        lines
2346            .iter()
2347            .map(|line| ws.normalize_line_for_compare(line))
2348            .collect()
2349    };
2350    let (old_normalized, new_normalized) = (normalize(old_lines), normalize(new_lines));
2351    let old_keys: Vec<&str> = old_normalized.iter().map(Cow::as_ref).collect();
2352    let new_keys: Vec<&str> = new_normalized.iter().map(Cow::as_ref).collect();
2353    let mut interner = LineInterner::with_capacity(old_keys.len() + new_keys.len());
2354    let ids1 = interner.intern_all(&old_keys);
2355    let ids2 = interner.intern_all(&new_keys);
2356    git_xdiff::changed_flags(&ids1, &ids2, false)
2357}
2358
2359/// Convert per-token `changed` flags (Git xdiff style) into `similar::DiffOp`s.
2360fn changed_flags_to_ops(
2361    changed1: &[bool],
2362    changed2: &[bool],
2363    old_len: usize,
2364    new_len: usize,
2365) -> Vec<similar::DiffOp> {
2366    use similar::DiffOp;
2367    let mut ops: Vec<DiffOp> = Vec::new();
2368    let mut i = 0usize;
2369    let mut j = 0usize;
2370    while i < old_len || j < new_len {
2371        let del = i < old_len && changed1[i];
2372        let ins = j < new_len && changed2[j];
2373        if !del && !ins {
2374            if i < old_len && j < new_len {
2375                // Equal run on both sides.
2376                let start_i = i;
2377                let start_j = j;
2378                while i < old_len && j < new_len && !changed1[i] && !changed2[j] {
2379                    i += 1;
2380                    j += 1;
2381                }
2382                ops.push(DiffOp::Equal {
2383                    old_index: start_i,
2384                    new_index: start_j,
2385                    len: i - start_i,
2386                });
2387            } else if i < old_len {
2388                let start_i = i;
2389                while i < old_len {
2390                    i += 1;
2391                }
2392                ops.push(DiffOp::Delete {
2393                    old_index: start_i,
2394                    old_len: i - start_i,
2395                    new_index: j,
2396                });
2397            } else if j < new_len {
2398                let start_j = j;
2399                while j < new_len {
2400                    j += 1;
2401                }
2402                ops.push(DiffOp::Insert {
2403                    old_index: i,
2404                    new_index: start_j,
2405                    new_len: j - start_j,
2406                });
2407            }
2408        } else {
2409            // Changed run: consecutive deletions then/and insertions.
2410            let start_i = i;
2411            let start_j = j;
2412            while i < old_len && changed1[i] {
2413                i += 1;
2414            }
2415            while j < new_len && changed2[j] {
2416                j += 1;
2417            }
2418            let dlen = i - start_i;
2419            let ilen = j - start_j;
2420            if dlen > 0 && ilen > 0 {
2421                ops.push(DiffOp::Replace {
2422                    old_index: start_i,
2423                    old_len: dlen,
2424                    new_index: start_j,
2425                    new_len: ilen,
2426                });
2427            } else if dlen > 0 {
2428                ops.push(DiffOp::Delete {
2429                    old_index: start_i,
2430                    old_len: dlen,
2431                    new_index: start_j,
2432                });
2433            } else if ilen > 0 {
2434                ops.push(DiffOp::Insert {
2435                    old_index: start_i,
2436                    new_index: start_j,
2437                    new_len: ilen,
2438                });
2439            }
2440        }
2441    }
2442    ops
2443}
2444
2445/// Line-diff ops for string slices after Git `xdl_change_compact` (and optional indent heuristic).
2446#[must_use]
2447pub fn diff_slice_ops_compacted(
2448    old_lines: &[&str],
2449    new_lines: &[&str],
2450    algorithm: similar::Algorithm,
2451    indent_heuristic: bool,
2452) -> Vec<similar::DiffOp> {
2453    diff_indent_heuristic::diff_slice_ops_compacted(
2454        old_lines,
2455        new_lines,
2456        algorithm,
2457        indent_heuristic,
2458    )
2459}
2460
2461/// Compacted line-diff ops from Git's xdiff Myers engine over precomputed line ids for each side
2462/// (default blame path; faster than `similar` on large files).
2463#[must_use]
2464///
2465/// `shared_counts` and `shared_records` describe identical records of both files left out of
2466/// the slices, as for [`git_xdiff::changed_flags_with_shared`]; pass `&[]` and `0` for whole files.
2467#[allow(clippy::too_many_arguments)]
2468pub(crate) fn line_diff_ops_git_xdiff_compacted_ids(
2469    old_ids: &[u32],
2470    new_ids: &[u32],
2471    shared_counts: &[u32],
2472    shared_records: usize,
2473    old_lines: &[&str],
2474    new_lines: &[&str],
2475    indent_heuristic: bool,
2476    ws: super::WhitespaceMode,
2477) -> Vec<similar::DiffOp> {
2478    let (changed1, changed2) = git_xdiff::changed_flags_with_shared(
2479        old_ids,
2480        new_ids,
2481        shared_counts,
2482        shared_records,
2483        false,
2484    );
2485    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2486    diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
2487        &ops,
2488        old_lines,
2489        new_lines,
2490        indent_heuristic,
2491        ws,
2492    )
2493}
2494
2495/// Whether each new-side line is marked changed by Git's xdiff engine (after change compaction).
2496#[cfg(test)]
2497#[must_use]
2498pub(crate) fn xdiff_new_line_changed_flags(
2499    old_lines: &[&str],
2500    new_lines: &[&str],
2501    indent_heuristic: bool,
2502) -> Vec<bool> {
2503    let mut interner =
2504        crate::line_interner::LineInterner::with_capacity(old_lines.len() + new_lines.len());
2505    let ids1 = interner.intern_all(old_lines);
2506    let ids2 = interner.intern_all(new_lines);
2507    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
2508    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2509    let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
2510        &ops,
2511        old_lines,
2512        new_lines,
2513        indent_heuristic,
2514    );
2515    let mut flags = vec![false; new_lines.len()];
2516    for op in compacted {
2517        match op.tag() {
2518            similar::DiffTag::Insert | similar::DiffTag::Replace => {
2519                for idx in op.new_range() {
2520                    if idx < flags.len() {
2521                        flags[idx] = true;
2522                    }
2523                }
2524            }
2525            similar::DiffTag::Equal | similar::DiffTag::Delete => {}
2526        }
2527    }
2528    flags
2529}
2530
2531/// The kind of change between two sides of a diff.
2532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2533#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2534#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
2535pub enum DiffStatus {
2536    /// File was added.
2537    Added,
2538    /// File was deleted.
2539    Deleted,
2540    /// File was modified (content or mode change).
2541    Modified,
2542    /// File was renamed (with optional content change).
2543    Renamed,
2544    /// File was copied.
2545    Copied,
2546    /// File type changed (e.g. regular → symlink).
2547    TypeChanged,
2548    /// Unmerged (conflict).
2549    Unmerged,
2550}
2551
2552impl DiffStatus {
2553    /// Single-character status letter used in raw diff output.
2554    #[must_use]
2555    pub fn letter(&self) -> char {
2556        match self {
2557            Self::Added => 'A',
2558            Self::Deleted => 'D',
2559            Self::Modified => 'M',
2560            Self::Renamed => 'R',
2561            Self::Copied => 'C',
2562            Self::TypeChanged => 'T',
2563            Self::Unmerged => 'U',
2564        }
2565    }
2566
2567    /// Status for a path present on both sides with differing content or mode.
2568    ///
2569    /// Returns [`DiffStatus::TypeChanged`] when the object type bits of the two
2570    /// modes differ (regular file, symlink, gitlink) and [`DiffStatus::Modified`]
2571    /// otherwise, so a permission-only change stays `Modified`. Every diff mode
2572    /// (tree, index, work tree) shares this rule.
2573    #[must_use]
2574    pub(crate) fn for_content_change(old_mode: u32, new_mode: u32) -> Self {
2575        if old_mode & 0o170000 != new_mode & 0o170000 {
2576            Self::TypeChanged
2577        } else {
2578            Self::Modified
2579        }
2580    }
2581}
2582
2583/// A single diff entry representing one changed path.
2584#[derive(Debug, Clone, PartialEq, Eq)]
2585#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2586pub struct DiffEntry {
2587    /// The status of this change.
2588    pub status: DiffStatus,
2589    /// Path in the "old" side (None for Added).
2590    pub old_path: Option<String>,
2591    /// Path in the "new" side (None for Deleted).
2592    pub new_path: Option<String>,
2593    /// Old file mode ([`FileMode::ABSENT`] for Added).
2594    pub old_mode: FileMode,
2595    /// New file mode ([`FileMode::ABSENT`] for Deleted).
2596    pub new_mode: FileMode,
2597    /// Old object ID (zero OID for Added).
2598    pub old_oid: ObjectId,
2599    /// New object ID (zero OID for Deleted, or for Modified index↔worktree / tree↔worktree
2600    /// entries whose content lives only on disk — raw output uses the null OID on that side).
2601    pub new_oid: ObjectId,
2602    /// Similarity score (0–100) for renames/copies.
2603    pub score: Option<u32>,
2604}
2605
2606impl DiffEntry {
2607    /// The primary path for display (new_path for adds, old_path for deletes).
2608    #[must_use]
2609    pub fn path(&self) -> &str {
2610        self.new_path
2611            .as_deref()
2612            .or(self.old_path.as_deref())
2613            .unwrap_or("")
2614    }
2615
2616    /// Return a human-oriented path display for this entry.
2617    ///
2618    /// For renames and copies this returns `old -> new`; for all other entry
2619    /// kinds this returns the primary path.
2620    #[must_use]
2621    pub fn display_path(&self) -> String {
2622        match self.status {
2623            DiffStatus::Renamed | DiffStatus::Copied => {
2624                let old = self.old_path.as_deref().unwrap_or("");
2625                let new = self.new_path.as_deref().unwrap_or("");
2626                if old.is_empty() || new.is_empty() {
2627                    self.path().to_owned()
2628                } else {
2629                    format!("{old} -> {new}")
2630                }
2631            }
2632            _ => self.path().to_owned(),
2633        }
2634    }
2635}
2636
2637/// Return the zero ObjectId.
2638#[must_use]
2639pub fn zero_oid() -> ObjectId {
2640    ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
2641        // This should never fail since we pass exactly 20 bytes
2642        panic!("internal error: failed to create zero OID");
2643    })
2644}
2645
2646/// Return the ObjectId for the empty blob object.
2647#[must_use]
2648pub fn empty_blob_oid() -> ObjectId {
2649    ObjectId::from_hex("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap_or_else(|_| {
2650        // This should never fail since the object ID literal is valid.
2651        panic!("internal error: failed to create empty blob OID");
2652    })
2653}
2654
2655// ── Tree-to-tree diff ───────────────────────────────────────────────
2656
2657/// Compare two trees and return the list of changed entries.
2658///
2659/// # Parameters
2660///
2661/// - `odb` — object database to read tree objects from.
2662/// - `old_tree_oid` — OID of the old tree (or `None` for comparison against empty).
2663/// - `new_tree_oid` — OID of the new tree (or `None` for comparison against empty).
2664/// - `prefix` — path prefix for nested tree recursion (empty string for root).
2665///
2666/// # Errors
2667///
2668/// Returns errors from object database reads.
2669pub fn diff_trees(
2670    odb: &Odb,
2671    old_tree_oid: Option<&ObjectId>,
2672    new_tree_oid: Option<&ObjectId>,
2673    prefix: &str,
2674) -> Result<Vec<DiffEntry>> {
2675    crate::trace::trace_span!("grit.diff.tree");
2676    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, false, None)
2677}
2678
2679/// Like [`diff_trees`], but when `index` matches `old_tree_oid` deleted subtrees are expanded
2680/// from sorted index entries instead of reading every tree object from the ODB.
2681pub(crate) fn diff_trees_with_index(
2682    odb: &Odb,
2683    index: &Index,
2684    old_tree_oid: Option<&ObjectId>,
2685    new_tree_oid: Option<&ObjectId>,
2686    prefix: &str,
2687    trust_index_for_old_tree: bool,
2688) -> Result<Vec<DiffEntry>> {
2689    let hint = (trust_index_for_old_tree
2690        && old_tree_oid.is_some()
2691        && index.entries_sorted
2692        && !index.entries.iter().any(|e| e.stage() != 0))
2693    .then_some(DiffTreesWalkHint {
2694        index: Some(index),
2695        old_tree_matches: true,
2696        ..DiffTreesWalkHint::default()
2697    });
2698    diff_trees_opts(
2699        odb,
2700        old_tree_oid,
2701        new_tree_oid,
2702        prefix,
2703        false,
2704        hint.as_ref(),
2705    )
2706}
2707
2708/// Like [`diff_trees`], but subtrees at directories `index` holds as sparse-directory rows are
2709/// not descended into, so no entry lies inside one. Paths of those directories themselves (a
2710/// blob replaced by a tree, say) are still reported.
2711pub(crate) fn diff_trees_outside_sparse_directories(
2712    odb: &Odb,
2713    index: &Index,
2714    old_tree_oid: Option<&ObjectId>,
2715    new_tree_oid: Option<&ObjectId>,
2716) -> Result<Vec<DiffEntry>> {
2717    crate::trace::trace_span!("grit.diff.tree");
2718    let hint = index
2719        .has_sparse_directory_placeholders()
2720        .then_some(DiffTreesWalkHint {
2721            index: Some(index),
2722            skip_sparse_directories: true,
2723            ..DiffTreesWalkHint::default()
2724        });
2725    diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, hint.as_ref())
2726}
2727
2728/// Like [`diff_trees`], but only paths `pathspecs` select (Git's pathspec-limited tree diff).
2729///
2730/// Directories no pathspec can reach are not read. An empty list selects every path.
2731pub(crate) fn diff_trees_for_pathspecs(
2732    odb: &Odb,
2733    old_tree_oid: Option<&ObjectId>,
2734    new_tree_oid: Option<&ObjectId>,
2735    pathspecs: &[String],
2736) -> Result<Vec<DiffEntry>> {
2737    if pathspecs.is_empty() {
2738        return diff_trees(odb, old_tree_oid, new_tree_oid, "");
2739    }
2740    crate::trace::trace_span!("grit.diff.tree");
2741    let hint = DiffTreesWalkHint {
2742        pathspecs,
2743        ..DiffTreesWalkHint::default()
2744    };
2745    let mut entries = diff_trees_opts(odb, old_tree_oid, new_tree_oid, "", false, Some(&hint))?;
2746    entries.retain(|entry| {
2747        let path = entry.path();
2748        !path.is_empty() && crate::pathspec::matches_pathspec_list(path, pathspecs)
2749    });
2750    Ok(entries)
2751}
2752
2753/// Directories a tree diff may skip, and an index that can stand in for the old tree.
2754#[derive(Clone, Copy, Default)]
2755struct DiffTreesWalkHint<'a> {
2756    index: Option<&'a Index>,
2757    /// `index` holds exactly the old tree's entries, so deleted subtrees may be listed from it.
2758    old_tree_matches: bool,
2759    /// Do not descend into directories `index` holds as sparse-directory rows.
2760    skip_sparse_directories: bool,
2761    /// When non-empty, do not descend into directories these pathspecs cannot match inside.
2762    pathspecs: &'a [String],
2763}
2764
2765impl DiffTreesWalkHint<'_> {
2766    /// Whether the walk must not enter directory `dir` (stored form, no trailing slash).
2767    fn skips(&self, dir: &str) -> bool {
2768        if dir.is_empty() {
2769            return false;
2770        }
2771        if !self.pathspecs.is_empty()
2772            && !crate::pathspec::pathspec_entry_is_relevant(dir, true, self.pathspecs)
2773        {
2774            return true;
2775        }
2776        let Some(index) = self.index.filter(|_| self.skip_sparse_directories) else {
2777            return false;
2778        };
2779        let mut row = crate::repo_relpath::storage_bytes(dir).into_owned();
2780        row.push(b'/');
2781        index
2782            .find_entry_pos(&row, 0)
2783            .is_some_and(|pos| index.entries[pos].is_sparse_directory_placeholder())
2784    }
2785}
2786
2787/// Count path-level differences between two trees without building a [`DiffEntry`] list.
2788///
2789/// Same semantics as [`diff_trees`], but avoids allocating diff records (issue #1064 commit hot path).
2790///
2791/// # Errors
2792///
2793/// Returns errors from object database reads.
2794pub fn diff_tree_path_change_count(
2795    odb: &Odb,
2796    old_tree_oid: Option<&ObjectId>,
2797    new_tree_oid: Option<&ObjectId>,
2798    prefix: &str,
2799) -> Result<usize> {
2800    if old_tree_oid == new_tree_oid {
2801        return Ok(0);
2802    }
2803    let old_entries = match old_tree_oid {
2804        Some(oid) => read_tree(odb, oid)?,
2805        None => Vec::new(),
2806    };
2807    let new_entries = match new_tree_oid {
2808        Some(oid) => read_tree(odb, oid)?,
2809        None => Vec::new(),
2810    };
2811    let mut count = 0usize;
2812    diff_tree_entries_count(odb, &old_entries, &new_entries, prefix, false, &mut count)?;
2813    Ok(count)
2814}
2815
2816/// Diff two trees and pair deleted/added paths as renames when content is similar enough.
2817///
2818/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
2819/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
2820///
2821/// # Errors
2822///
2823/// Propagates errors from [`diff_trees`].
2824pub fn diff_trees_with_renames(
2825    odb: &Odb,
2826    old_tree_oid: Option<&ObjectId>,
2827    new_tree_oid: Option<&ObjectId>,
2828    prefix: &str,
2829    rename_threshold: u32,
2830) -> Result<Vec<DiffEntry>> {
2831    let config = ConfigSet::new();
2832    let policy = ThreadPolicy::default();
2833    diff_trees_with_renames_and_policy(
2834        odb,
2835        old_tree_oid,
2836        new_tree_oid,
2837        prefix,
2838        rename_threshold,
2839        &config,
2840        &policy,
2841    )
2842}
2843
2844/// Like [`diff_trees_with_renames`], but honors `index.threads` / embedder thread policy.
2845///
2846/// # Errors
2847///
2848/// Propagates errors from [`diff_trees`].
2849pub fn diff_trees_with_renames_and_policy(
2850    odb: &Odb,
2851    old_tree_oid: Option<&ObjectId>,
2852    new_tree_oid: Option<&ObjectId>,
2853    prefix: &str,
2854    rename_threshold: u32,
2855    config: &ConfigSet,
2856    thread_policy: &ThreadPolicy,
2857) -> Result<Vec<DiffEntry>> {
2858    let mut entries = diff_trees(odb, old_tree_oid, new_tree_oid, prefix)?;
2859    if rename_threshold <= 100 {
2860        entries = detect_renames_with_policy(
2861            odb,
2862            None,
2863            entries,
2864            rename_threshold,
2865            config,
2866            thread_policy,
2867            None,
2868        )?;
2869    }
2870    Ok(entries)
2871}
2872
2873/// Return the tree OID stored in a commit object.
2874///
2875/// # Errors
2876///
2877/// Returns [`Error::InvalidRef`] when `commit_oid` is not a commit, and object read/parse
2878/// errors when it is missing or malformed.
2879pub fn tree_oid_of_commit(odb: &Odb, commit_oid: &ObjectId) -> Result<ObjectId> {
2880    odb.commit_tree(commit_oid).map_err(|err| match err {
2881        Error::UnexpectedObjectKind { .. } => {
2882            Error::InvalidRef(format!("invalid ref: {commit_oid} is not a commit"))
2883        }
2884        other => other,
2885    })
2886}
2887
2888/// Like [`tree_oid_of_commit`], but uses the repository commit-graph cache when available.
2889///
2890/// # Errors
2891///
2892/// Returns object read/parse errors when `commit_oid` is missing or not a commit.
2893pub fn tree_oid_of_commit_in_repo(
2894    repo: &crate::repo::Repository,
2895    commit_oid: &ObjectId,
2896) -> Result<ObjectId> {
2897    if let Some(chain) = repo.odb().commit_graph_chain() {
2898        if let Some(tree) = chain.graph_commit_tree(commit_oid) {
2899            return Ok(tree);
2900        }
2901    }
2902    tree_oid_of_commit(repo.odb(), commit_oid)
2903}
2904
2905/// Diff the trees recorded at two commits (`base` is the old side, `tip` the new side).
2906///
2907/// # Errors
2908///
2909/// Propagates object read errors and tree diff failures.
2910pub fn diff_commits(odb: &Odb, base: &ObjectId, tip: &ObjectId) -> Result<Vec<DiffEntry>> {
2911    let base_tree = tree_oid_of_commit(odb, base)?;
2912    let tip_tree = tree_oid_of_commit(odb, tip)?;
2913    diff_trees(odb, Some(&base_tree), Some(&tip_tree), "")
2914}
2915
2916/// Diff the trees at two commits and pair deleted/added paths as renames when similar enough.
2917///
2918/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
2919/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
2920///
2921/// # Errors
2922///
2923/// Propagates object read errors and tree diff failures.
2924pub fn diff_commits_with_renames(
2925    odb: &Odb,
2926    base: &ObjectId,
2927    tip: &ObjectId,
2928    rename_threshold: u32,
2929) -> Result<Vec<DiffEntry>> {
2930    let config = ConfigSet::new();
2931    let policy = ThreadPolicy::default();
2932    diff_commits_with_renames_and_policy(odb, base, tip, rename_threshold, &config, &policy)
2933}
2934
2935/// Like [`diff_commits_with_renames`], but honors `index.threads` / embedder thread policy.
2936///
2937/// # Errors
2938///
2939/// Propagates object read errors and tree diff failures.
2940pub fn diff_commits_with_renames_and_policy(
2941    odb: &Odb,
2942    base: &ObjectId,
2943    tip: &ObjectId,
2944    rename_threshold: u32,
2945    config: &ConfigSet,
2946    thread_policy: &ThreadPolicy,
2947) -> Result<Vec<DiffEntry>> {
2948    let base_tree = tree_oid_of_commit(odb, base)?;
2949    let tip_tree = tree_oid_of_commit(odb, tip)?;
2950    diff_trees_with_renames_and_policy(
2951        odb,
2952        Some(&base_tree),
2953        Some(&tip_tree),
2954        "",
2955        rename_threshold,
2956        config,
2957        thread_policy,
2958    )
2959}
2960
2961/// Like [`diff_trees`], but emit entries for tree objects themselves in addition
2962/// to their recursive contents (Git's `diff-tree -t` behavior).
2963///
2964/// # Errors
2965///
2966/// Returns errors from object database reads.
2967pub fn diff_trees_show_tree_entries(
2968    odb: &Odb,
2969    old_tree_oid: Option<&ObjectId>,
2970    new_tree_oid: Option<&ObjectId>,
2971    prefix: &str,
2972) -> Result<Vec<DiffEntry>> {
2973    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, true, None)
2974}
2975
2976fn diff_subtrees_in_place(
2977    odb: &Odb,
2978    old_tree_oid: Option<&ObjectId>,
2979    new_tree_oid: Option<&ObjectId>,
2980    prefix: &str,
2981    show_trees: bool,
2982    index_hint: Option<&DiffTreesWalkHint<'_>>,
2983    result: &mut Vec<DiffEntry>,
2984) -> Result<()> {
2985    if index_hint.is_some_and(|hint| hint.skips(prefix)) {
2986        return Ok(());
2987    }
2988    if !show_trees {
2989        if let (Some(a), Some(b)) = (old_tree_oid, new_tree_oid) {
2990            if a == b {
2991                return Ok(());
2992            }
2993        }
2994    }
2995    // The ODB already budgets cached trees. Keep only active recursion frames
2996    // here so completing siblings can release trees evicted from that cache.
2997    let old_tree = old_tree_oid
2998        .map(|oid| odb.cached_tree_for_walk(oid))
2999        .transpose()?;
3000    let new_tree = new_tree_oid
3001        .map(|oid| odb.cached_tree_for_walk(oid))
3002        .transpose()?;
3003    let oid_len = odb.hash_algo().len();
3004    diff_tree_entries_opts(
3005        odb,
3006        WalkEntries::new(old_tree.as_ref(), oid_len),
3007        WalkEntries::new(new_tree.as_ref(), oid_len),
3008        prefix,
3009        show_trees,
3010        index_hint,
3011        result,
3012    )
3013}
3014
3015/// Entries of a tree from [`Odb::cached_tree_for_walk`] in stored order, borrowing names
3016/// from it.
3017enum WalkEntries<'a> {
3018    Parsed(std::slice::Iter<'a, TreeEntry>),
3019    Raw(TreeRefIter<'a>),
3020}
3021
3022impl<'a> WalkEntries<'a> {
3023    /// Iterate `tree`, or nothing when the side is absent.
3024    fn new(tree: Option<&'a SessionTree>, oid_len: usize) -> Self {
3025        match tree {
3026            Some(SessionTree::Parsed(entries)) => Self::Parsed(entries.iter()),
3027            Some(SessionTree::Raw(data)) => Self::Raw(TreeRefIter::with_oid_len(data, oid_len)),
3028            None => Self::Parsed([].iter()),
3029        }
3030    }
3031
3032    fn next_entry(&mut self) -> Result<Option<TreeEntryRef<'a>>> {
3033        match self {
3034            Self::Parsed(entries) => Ok(entries.next().map(|entry| TreeEntryRef {
3035                mode: entry.mode,
3036                name: &entry.name,
3037                oid: entry.oid,
3038            })),
3039            Self::Raw(entries) => entries.next().transpose(),
3040        }
3041    }
3042}
3043
3044fn diff_trees_opts(
3045    odb: &Odb,
3046    old_tree_oid: Option<&ObjectId>,
3047    new_tree_oid: Option<&ObjectId>,
3048    prefix: &str,
3049    show_trees: bool,
3050    index_hint: Option<&DiffTreesWalkHint<'_>>,
3051) -> Result<Vec<DiffEntry>> {
3052    let mut result = Vec::new();
3053    diff_subtrees_in_place(
3054        odb,
3055        old_tree_oid,
3056        new_tree_oid,
3057        prefix,
3058        show_trees,
3059        index_hint,
3060        &mut result,
3061    )?;
3062    Ok(result)
3063}
3064
3065#[cfg(test)]
3066use std::cell::RefCell;
3067#[cfg(test)]
3068use std::rc::Rc;
3069#[cfg(test)]
3070use std::sync::atomic::{AtomicUsize, Ordering};
3071
3072#[cfg(test)]
3073thread_local! {
3074    static DIFF_TREE_READ_HOOK: RefCell<Option<Rc<AtomicUsize>>> = const { RefCell::new(None) };
3075}
3076
3077/// Install a per-thread tree read counter for unit tests (cleared when `hook` is `None`).
3078#[cfg(test)]
3079pub(crate) fn set_diff_tree_read_hook_for_tests(hook: Option<Rc<AtomicUsize>>) {
3080    DIFF_TREE_READ_HOOK.with(|cell| *cell.borrow_mut() = hook);
3081}
3082
3083/// Read and parse a tree object from the ODB (no session cache).
3084fn read_tree_from_odb(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
3085    #[cfg(test)]
3086    if let Some(counter) = DIFF_TREE_READ_HOOK.with(|cell| cell.borrow().clone()) {
3087        counter.fetch_add(1, Ordering::Relaxed);
3088    }
3089    Ok((*odb.cached_tree_entries(oid)?).clone())
3090}
3091
3092/// Read and parse a tree object from the ODB.
3093fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
3094    read_tree_from_odb(odb, oid)
3095}
3096
3097fn diff_tree_entries_count(
3098    odb: &Odb,
3099    old: &[TreeEntry],
3100    new: &[TreeEntry],
3101    prefix: &str,
3102    show_trees: bool,
3103    count: &mut usize,
3104) -> Result<()> {
3105    let mut oi = 0;
3106    let mut ni = 0;
3107
3108    while oi < old.len() || ni < new.len() {
3109        match (old.get(oi), new.get(ni)) {
3110            (Some(o), Some(n)) => {
3111                let cmp = crate::objects::tree_entry_cmp(
3112                    &o.name,
3113                    is_tree_mode(o.mode),
3114                    &n.name,
3115                    is_tree_mode(n.mode),
3116                );
3117                match cmp {
3118                    std::cmp::Ordering::Less => {
3119                        emit_deleted_count(odb, o, prefix, show_trees, count)?;
3120                        oi += 1;
3121                    }
3122                    std::cmp::Ordering::Greater => {
3123                        emit_added_count(odb, n, prefix, show_trees, count)?;
3124                        ni += 1;
3125                    }
3126                    std::cmp::Ordering::Equal => {
3127                        if o.oid != n.oid
3128                            || crate::index::canon_mode(o.mode) != crate::index::canon_mode(n.mode)
3129                        {
3130                            let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
3131                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3132                                if show_trees {
3133                                    *count += 1;
3134                                }
3135                                *count += diff_tree_path_change_count(
3136                                    odb,
3137                                    Some(&o.oid),
3138                                    Some(&n.oid),
3139                                    &path,
3140                                )?;
3141                            } else if is_tree_mode(o.mode) != is_tree_mode(n.mode) {
3142                                emit_deleted_count(odb, o, prefix, show_trees, count)?;
3143                                emit_added_count(odb, n, prefix, show_trees, count)?;
3144                            } else {
3145                                *count += 1;
3146                            }
3147                        }
3148                        oi += 1;
3149                        ni += 1;
3150                    }
3151                }
3152            }
3153            (Some(o), None) => {
3154                emit_deleted_count(odb, o, prefix, show_trees, count)?;
3155                oi += 1;
3156            }
3157            (None, Some(n)) => {
3158                emit_added_count(odb, n, prefix, show_trees, count)?;
3159                ni += 1;
3160            }
3161            (None, None) => break,
3162        }
3163    }
3164
3165    Ok(())
3166}
3167
3168fn emit_deleted_count(
3169    odb: &Odb,
3170    entry: &TreeEntry,
3171    prefix: &str,
3172    show_trees: bool,
3173    count: &mut usize,
3174) -> Result<()> {
3175    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3176    if is_tree_mode(entry.mode) {
3177        if show_trees {
3178            *count += 1;
3179        }
3180        *count += diff_tree_path_change_count(odb, Some(&entry.oid), None, &path)?;
3181    } else {
3182        *count += 1;
3183    }
3184    Ok(())
3185}
3186
3187fn emit_added_count(
3188    odb: &Odb,
3189    entry: &TreeEntry,
3190    prefix: &str,
3191    show_trees: bool,
3192    count: &mut usize,
3193) -> Result<()> {
3194    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3195    if is_tree_mode(entry.mode) {
3196        if show_trees {
3197            *count += 1;
3198        }
3199        *count += diff_tree_path_change_count(odb, None, Some(&entry.oid), &path)?;
3200    } else {
3201        *count += 1;
3202    }
3203    Ok(())
3204}
3205
3206/// Compare two sorted streams of tree entries, recursing into subtrees.
3207fn diff_tree_entries_opts(
3208    odb: &Odb,
3209    mut old: WalkEntries<'_>,
3210    mut new: WalkEntries<'_>,
3211    prefix: &str,
3212    show_trees: bool,
3213    index_hint: Option<&DiffTreesWalkHint<'_>>,
3214    result: &mut Vec<DiffEntry>,
3215) -> Result<()> {
3216    let mut old_entry = old.next_entry()?;
3217    let mut new_entry = new.next_entry()?;
3218
3219    loop {
3220        match (&old_entry, &new_entry) {
3221            (Some(o), Some(n)) => {
3222                let cmp = crate::objects::tree_entry_cmp(
3223                    o.name,
3224                    is_tree_mode(o.mode),
3225                    n.name,
3226                    is_tree_mode(n.mode),
3227                );
3228                match cmp {
3229                    std::cmp::Ordering::Less => {
3230                        // Old entry not in new → deleted
3231                        emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3232                        old_entry = old.next_entry()?;
3233                    }
3234                    std::cmp::Ordering::Greater => {
3235                        // New entry not in old → added
3236                        emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3237                        new_entry = new.next_entry()?;
3238                    }
3239                    std::cmp::Ordering::Equal => {
3240                        // Both present — check for changes
3241                        if o.oid != n.oid
3242                            || crate::index::canon_mode(o.mode) != crate::index::canon_mode(n.mode)
3243                        {
3244                            let path = crate::repo_relpath::format_path_stored(prefix, o.name);
3245                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3246                                // Both are trees
3247                                if show_trees {
3248                                    result.push(DiffEntry {
3249                                        status: DiffStatus::Modified,
3250                                        old_path: Some(path.clone()),
3251                                        new_path: Some(path.clone()),
3252                                        old_mode: FileMode::new(crate::index::canon_mode(o.mode)),
3253                                        new_mode: FileMode::new(crate::index::canon_mode(n.mode)),
3254                                        old_oid: o.oid,
3255                                        new_oid: n.oid,
3256                                        score: None,
3257                                    });
3258                                }
3259                                diff_subtrees_in_place(
3260                                    odb,
3261                                    Some(&o.oid),
3262                                    Some(&n.oid),
3263                                    &path,
3264                                    show_trees,
3265                                    index_hint,
3266                                    result,
3267                                )?;
3268                            } else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
3269                                // Tree → blob: delete tree contents, add blob
3270                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3271                                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3272                            } else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
3273                                // Blob → tree: delete blob, add tree contents
3274                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3275                                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3276                            } else {
3277                                result.push(DiffEntry {
3278                                    status: DiffStatus::for_content_change(o.mode, n.mode),
3279                                    old_path: Some(path.clone()),
3280                                    new_path: Some(path),
3281                                    old_mode: FileMode::new(o.mode),
3282                                    new_mode: FileMode::new(n.mode),
3283                                    old_oid: o.oid,
3284                                    new_oid: n.oid,
3285                                    score: None,
3286                                });
3287                            }
3288                        }
3289                        old_entry = old.next_entry()?;
3290                        new_entry = new.next_entry()?;
3291                    }
3292                }
3293            }
3294            (Some(o), None) => {
3295                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
3296                old_entry = old.next_entry()?;
3297            }
3298            (None, Some(n)) => {
3299                emit_added_opts(odb, n, prefix, show_trees, index_hint, result)?;
3300                new_entry = new.next_entry()?;
3301            }
3302            (None, None) => break,
3303        }
3304    }
3305
3306    Ok(())
3307}
3308
3309fn emit_deleted_tree_from_index(index: &Index, dir_prefix: &str, result: &mut Vec<DiffEntry>) {
3310    let scan_prefix: Vec<u8> = if dir_prefix.is_empty() {
3311        Vec::new()
3312    } else {
3313        format!("{dir_prefix}/").into_bytes()
3314    };
3315    if index.entries_sorted {
3316        let start = if scan_prefix.is_empty() {
3317            0
3318        } else {
3319            index
3320                .entries
3321                .partition_point(|e| e.path.as_slice() < scan_prefix.as_slice())
3322        };
3323        for ie in index.entries[start..].iter() {
3324            if ie.stage() != 0 {
3325                continue;
3326            }
3327            if !scan_prefix.is_empty() && !ie.path.starts_with(&scan_prefix) {
3328                break;
3329            }
3330            if scan_prefix.is_empty() && ie.path.contains(&b'/') {
3331                continue;
3332            }
3333            let path = String::from_utf8_lossy(&ie.path).into_owned();
3334            result.push(DiffEntry {
3335                status: DiffStatus::Deleted,
3336                old_path: Some(path),
3337                new_path: None,
3338                old_mode: FileMode::new(ie.mode),
3339                new_mode: FileMode::ABSENT,
3340                old_oid: ie.oid,
3341                new_oid: zero_oid(),
3342                score: None,
3343            });
3344        }
3345        return;
3346    }
3347    for ie in &index.entries {
3348        if ie.stage() != 0 {
3349            continue;
3350        }
3351        let Ok(path) = std::str::from_utf8(&ie.path) else {
3352            continue;
3353        };
3354        let under = if dir_prefix.is_empty() {
3355            !path.contains('/')
3356        } else if path == dir_prefix {
3357            false
3358        } else {
3359            path.strip_prefix(dir_prefix)
3360                .is_some_and(|rest| rest.starts_with('/'))
3361        };
3362        if !under {
3363            continue;
3364        }
3365        result.push(DiffEntry {
3366            status: DiffStatus::Deleted,
3367            old_path: Some(path.to_owned()),
3368            new_path: None,
3369            old_mode: FileMode::new(ie.mode),
3370            new_mode: FileMode::ABSENT,
3371            old_oid: ie.oid,
3372            new_oid: zero_oid(),
3373            score: None,
3374        });
3375    }
3376}
3377
3378fn emit_deleted_opts(
3379    odb: &Odb,
3380    entry: &TreeEntryRef<'_>,
3381    prefix: &str,
3382    show_trees: bool,
3383    index_hint: Option<&DiffTreesWalkHint<'_>>,
3384    result: &mut Vec<DiffEntry>,
3385) -> Result<()> {
3386    let path = crate::repo_relpath::format_path_stored(prefix, entry.name);
3387    if is_tree_mode(entry.mode) {
3388        if show_trees {
3389            result.push(DiffEntry {
3390                status: DiffStatus::Deleted,
3391                old_path: Some(path.clone()),
3392                new_path: None,
3393                old_mode: FileMode::new(entry.mode),
3394                new_mode: FileMode::ABSENT,
3395                old_oid: entry.oid,
3396                new_oid: zero_oid(),
3397                score: None,
3398            });
3399        }
3400        if let Some(index) = index_hint
3401            .filter(|hint| hint.old_tree_matches)
3402            .and_then(|hint| hint.index)
3403            .filter(|index| !index.has_sparse_directory_placeholders())
3404        {
3405            emit_deleted_tree_from_index(index, &path, result);
3406            return Ok(());
3407        }
3408        diff_subtrees_in_place(
3409            odb,
3410            Some(&entry.oid),
3411            None,
3412            &path,
3413            show_trees,
3414            index_hint,
3415            result,
3416        )?;
3417    } else {
3418        result.push(DiffEntry {
3419            status: DiffStatus::Deleted,
3420            old_path: Some(path.clone()),
3421            new_path: None,
3422            old_mode: FileMode::new(entry.mode),
3423            new_mode: FileMode::ABSENT,
3424            old_oid: entry.oid,
3425            new_oid: zero_oid(),
3426            score: None,
3427        });
3428    }
3429    Ok(())
3430}
3431
3432fn emit_added_opts(
3433    odb: &Odb,
3434    entry: &TreeEntryRef<'_>,
3435    prefix: &str,
3436    show_trees: bool,
3437    index_hint: Option<&DiffTreesWalkHint<'_>>,
3438    result: &mut Vec<DiffEntry>,
3439) -> Result<()> {
3440    let path = crate::repo_relpath::format_path_stored(prefix, entry.name);
3441    if is_tree_mode(entry.mode) {
3442        if show_trees {
3443            result.push(DiffEntry {
3444                status: DiffStatus::Added,
3445                old_path: None,
3446                new_path: Some(path.clone()),
3447                old_mode: FileMode::ABSENT,
3448                new_mode: FileMode::new(entry.mode),
3449                old_oid: zero_oid(),
3450                new_oid: entry.oid,
3451                score: None,
3452            });
3453        }
3454        diff_subtrees_in_place(
3455            odb,
3456            None,
3457            Some(&entry.oid),
3458            &path,
3459            show_trees,
3460            index_hint,
3461            result,
3462        )?;
3463    } else {
3464        result.push(DiffEntry {
3465            status: DiffStatus::Added,
3466            old_path: None,
3467            new_path: Some(path),
3468            old_mode: FileMode::ABSENT,
3469            new_mode: FileMode::new(entry.mode),
3470            old_oid: zero_oid(),
3471            new_oid: entry.oid,
3472            score: None,
3473        });
3474    }
3475    Ok(())
3476}
3477
3478// ── Index-to-tree diff (staged changes) ─────────────────────────────
3479
3480/// Compare the index against a tree (usually HEAD's tree).
3481///
3482/// This shows "staged" changes — what would be committed.
3483///
3484/// # Parameters
3485///
3486/// - `odb` — object database.
3487/// - `index` — the current index.
3488/// - `tree_oid` — the tree to compare against (e.g. HEAD's tree), or `None`
3489///   for comparison against an empty tree (initial commit).
3490///
3491/// # Errors
3492///
3493/// Returns errors from ODB reads.
3494///
3495/// When `ignore_submodules` is true, gitlink (`160000`) paths are omitted from the diff, matching
3496/// Git's `require_clean_work_tree(..., ignore_submodules=1)` used by `git rebase` / `git pull`.
3497pub fn diff_index_to_tree(
3498    odb: &Odb,
3499    index: &Index,
3500    tree_oid: Option<&ObjectId>,
3501    ignore_submodules: bool,
3502) -> Result<Vec<DiffEntry>> {
3503    match index_cache_tree_walk(index, tree_oid) {
3504        CacheTreeWalk::Fast {
3505            head_tree,
3506            cache_root,
3507        } => {
3508            diff_index_to_tree_with_cache_tree(odb, index, head_tree, cache_root, ignore_submodules)
3509        }
3510        CacheTreeWalk::Flatten => {
3511            diff_index_to_tree_flatten(odb, index, tree_oid, ignore_submodules)
3512        }
3513    }
3514}
3515
3516/// Diff the index against a tree and pair deleted/added paths as renames when similar enough.
3517///
3518/// `rename_threshold` is Git's `-M` percentage (50 is the usual default); pass `100` to skip
3519/// rename detection.
3520///
3521/// # Errors
3522///
3523/// Propagates errors from [`diff_index_to_tree`].
3524pub fn diff_index_to_tree_with_renames(
3525    odb: &Odb,
3526    index: &Index,
3527    tree_oid: Option<&ObjectId>,
3528    ignore_submodules: bool,
3529    rename_threshold: u32,
3530    config: &ConfigSet,
3531    thread_policy: &ThreadPolicy,
3532) -> Result<Vec<DiffEntry>> {
3533    let entries = diff_index_to_tree(odb, index, tree_oid, ignore_submodules)?;
3534    apply_rename_detection_to_diff_entries(
3535        odb,
3536        entries,
3537        None,
3538        rename_threshold,
3539        config,
3540        thread_policy,
3541    )
3542}
3543
3544/// Apply Git `-M` rename detection to a flat diff (index↔tree, tree↔tree, or index↔worktree).
3545///
3546/// Pass `work_root` when added sides may be read from the working tree (index↔worktree diffs).
3547/// Use `rename_threshold` `100` to leave entries unchanged.
3548///
3549/// # Errors
3550///
3551/// Propagates object read failures while scoring renames.
3552pub fn apply_rename_detection_to_diff_entries(
3553    odb: &Odb,
3554    entries: Vec<DiffEntry>,
3555    work_root: Option<&Path>,
3556    rename_threshold: u32,
3557    config: &ConfigSet,
3558    thread_policy: &ThreadPolicy,
3559) -> Result<Vec<DiffEntry>> {
3560    if entries.is_empty() || rename_threshold >= 100 {
3561        return Ok(entries);
3562    }
3563    if work_root.is_some() {
3564        let opts = DiffPostprocessOptions {
3565            rename_threshold,
3566            ..DiffPostprocessOptions::default()
3567        };
3568        postprocess_diff_entries(
3569            odb,
3570            entries,
3571            None,
3572            work_root,
3573            None,
3574            config,
3575            thread_policy,
3576            &opts,
3577        )
3578    } else {
3579        detect_renames_with_policy(
3580            odb,
3581            None,
3582            entries,
3583            rename_threshold,
3584            config,
3585            thread_policy,
3586            None,
3587        )
3588    }
3589}
3590
3591/// Add [`DiffStatus::Added`] entries for untracked worktree files so rename detection can pair
3592/// plain filesystem moves (index still lists the old path, new path only on disk).
3593///
3594/// The returned path set lists every synthetic added candidate. After rename detection, drop
3595/// entries whose `new_path` is still in that set and still [`DiffStatus::Added`] — they were
3596/// not consumed by a rename pair and must not appear in the diff (Git does not report unrelated
3597/// untracked paths in `git diff`).
3598///
3599/// # Errors
3600///
3601/// Propagates I/O and ignore-rule load failures while scanning the work tree.
3602pub fn append_untracked_worktree_added_for_rename_detection(
3603    repo: &Repository,
3604    index: &Index,
3605    rules: &WorktreeRules,
3606    mut entries: Vec<DiffEntry>,
3607) -> Result<(Vec<DiffEntry>, HashSet<String>)> {
3608    let mut synthetic_new_paths = HashSet::new();
3609    if !entries.iter().any(|e| e.status == DiffStatus::Deleted) {
3610        return Ok((entries, synthetic_new_paths));
3611    }
3612    let mut existing_new: HashSet<String> =
3613        entries.iter().filter_map(|e| e.new_path.clone()).collect();
3614    let untracked = list_untracked_worktree_files_for_rename(repo, index, rules)?;
3615    for path in untracked {
3616        if existing_new.contains(&path) {
3617            continue;
3618        }
3619        existing_new.insert(path.clone());
3620        synthetic_new_paths.insert(path.clone());
3621        entries.push(DiffEntry {
3622            status: DiffStatus::Added,
3623            old_path: None,
3624            new_path: Some(path),
3625            old_mode: FileMode::ABSENT,
3626            new_mode: FileMode::REGULAR,
3627            old_oid: zero_oid(),
3628            new_oid: zero_oid(),
3629            score: None,
3630        });
3631    }
3632    Ok((entries, synthetic_new_paths))
3633}
3634
3635/// Drop synthetic untracked rename candidates that rename detection did not pair.
3636#[must_use]
3637pub fn drop_unused_synthetic_rename_candidates(
3638    entries: Vec<DiffEntry>,
3639    synthetic_new_paths: &HashSet<String>,
3640) -> Vec<DiffEntry> {
3641    if synthetic_new_paths.is_empty() {
3642        return entries;
3643    }
3644    entries
3645        .into_iter()
3646        .filter(|e| {
3647            !(e.status == DiffStatus::Added
3648                && e.new_path
3649                    .as_ref()
3650                    .is_some_and(|p| synthetic_new_paths.contains(p)))
3651        })
3652        .collect()
3653}
3654
3655fn list_untracked_worktree_files_for_rename(
3656    repo: &Repository,
3657    index: &Index,
3658    rules: &WorktreeRules,
3659) -> Result<Vec<String>> {
3660    let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
3661    let mut out = Vec::new();
3662    walk_untracked_worktree_files(repo, index, work_tree, "", rules, &mut out)?;
3663    out.sort();
3664    Ok(out)
3665}
3666
3667fn walk_untracked_worktree_files(
3668    repo: &Repository,
3669    index: &Index,
3670    dir: &Path,
3671    rel_prefix: &str,
3672    rules: &WorktreeRules,
3673    out: &mut Vec<String>,
3674) -> Result<()> {
3675    let read = match fs::read_dir(dir) {
3676        Ok(r) => r,
3677        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3678        Err(e) => return Err(Error::Io(e)),
3679    };
3680    for entry in read {
3681        let entry = entry.map_err(Error::Io)?;
3682        let name = entry.file_name();
3683        let name = name.to_string_lossy();
3684        if rel_prefix.is_empty() && name == ".git" {
3685            continue;
3686        }
3687        let rel_path = if rel_prefix.is_empty() {
3688            name.to_string()
3689        } else {
3690            format!("{rel_prefix}/{name}")
3691        };
3692        let file_type = entry.file_type().map_err(Error::Io)?;
3693        if file_type.is_dir() {
3694            let (ignored, _) = rules
3695                .ignore_mut()
3696                .check_path(repo, Some(index), &rel_path, true)?;
3697            if ignored {
3698                continue;
3699            }
3700            walk_untracked_worktree_files(
3701                repo,
3702                index,
3703                &dir.join(name.as_ref()),
3704                &rel_path,
3705                rules,
3706                out,
3707            )?;
3708        } else if file_type.is_file() || file_type.is_symlink() {
3709            if index.get(rel_path.as_bytes(), 0).is_some() {
3710                continue;
3711            }
3712            let (ignored, _) =
3713                rules
3714                    .ignore_mut()
3715                    .check_path(repo, Some(index), &rel_path, false)?;
3716            if !ignored {
3717                out.push(rel_path);
3718            }
3719        }
3720    }
3721    Ok(())
3722}
3723
3724/// Diff the index against the working tree and pair deleted/added paths as renames when similar.
3725///
3726/// Runs [`diff_index_to_worktree_with_options`], appends untracked worktree paths as added
3727/// candidates when needed, then [`apply_rename_detection_to_diff_entries`] with `work_root`
3728/// set to `work_tree`.
3729///
3730/// # Errors
3731///
3732/// Propagates errors from the index↔worktree diff or rename post-processing.
3733pub fn diff_index_to_worktree_with_renames(
3734    repo: &Repository,
3735    index: &mut Index,
3736    work_tree: &Path,
3737    options: DiffIndexToWorktreeOptions,
3738    rename_threshold: u32,
3739) -> Result<(Vec<DiffEntry>, bool)> {
3740    let config = repo.config()?.as_ref().clone();
3741    let thread_policy = repo.thread_policy();
3742    let odb = repo.odb();
3743    let rules_for_append = options.worktree_rules.clone();
3744    let (mut entries, changed) =
3745        diff_index_to_worktree_with_options(odb, index, work_tree, options)?;
3746    let mut synthetic_new_paths = HashSet::new();
3747    if rename_threshold < 100 {
3748        if let Some(rules_arc) = rules_for_append {
3749            let rules = rules_arc
3750                .lock()
3751                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))?;
3752            (entries, synthetic_new_paths) =
3753                append_untracked_worktree_added_for_rename_detection(repo, index, &rules, entries)?;
3754        } else {
3755            let rules = WorktreeRules::from_repository(repo, index)?;
3756            (entries, synthetic_new_paths) =
3757                append_untracked_worktree_added_for_rename_detection(repo, index, &rules, entries)?;
3758        }
3759    }
3760    let entries = apply_rename_detection_to_diff_entries(
3761        odb,
3762        entries,
3763        Some(work_tree),
3764        rename_threshold,
3765        &config,
3766        &thread_policy,
3767    )?;
3768    let entries = drop_unused_synthetic_rename_candidates(entries, &synthetic_new_paths);
3769    Ok((entries, changed))
3770}
3771
3772enum CacheTreeWalk<'a> {
3773    Fast {
3774        head_tree: &'a ObjectId,
3775        cache_root: &'a CacheTreeNode,
3776    },
3777    Flatten,
3778}
3779
3780/// Whether the index may use the cache-tree walker (possibly with an invalid root and valid
3781/// descendant nodes after partial invalidation).
3782fn index_cache_tree_walk<'a>(
3783    index: &'a Index,
3784    tree_oid: Option<&'a ObjectId>,
3785) -> CacheTreeWalk<'a> {
3786    let Some(head_tree) = tree_oid else {
3787        return CacheTreeWalk::Flatten;
3788    };
3789    let Some(cache_root) = index.cache_tree.as_ref() else {
3790        return CacheTreeWalk::Flatten;
3791    };
3792    if index.entries.iter().any(|e| e.stage() != 0) {
3793        return CacheTreeWalk::Flatten;
3794    }
3795    CacheTreeWalk::Fast {
3796        head_tree,
3797        cache_root,
3798    }
3799}
3800
3801/// Flatten-tree index-vs-tree diff (used when the index has no cache-tree extension).
3802fn diff_index_to_tree_flatten(
3803    odb: &Odb,
3804    index: &Index,
3805    tree_oid: Option<&ObjectId>,
3806    ignore_submodules: bool,
3807) -> Result<Vec<DiffEntry>> {
3808    let sparse_index = index.has_sparse_directory_placeholders();
3809    // Flatten the tree into a sorted list of (path, mode, oid)
3810    let tree_entries = match tree_oid {
3811        Some(oid) if sparse_index => flatten_tree_with_index(odb, oid, "", Some(index))?,
3812        Some(oid) => flatten_tree(odb, oid, "")?,
3813        None => Vec::new(),
3814    };
3815
3816    // Build maps keyed by path
3817    let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
3818        std::collections::BTreeMap::new();
3819    for entry in &tree_entries {
3820        tree_map.insert(&entry.path, entry);
3821    }
3822
3823    let mut result = Vec::new();
3824    let mut stage0_paths = std::collections::BTreeSet::new();
3825    let mut unmerged_modes: std::collections::BTreeMap<String, (u8, u32)> =
3826        std::collections::BTreeMap::new();
3827
3828    // Check index entries against tree
3829    for ie in &index.entries {
3830        let path = String::from_utf8_lossy(&ie.path).to_string();
3831        if ie.stage() == 0 && ie.intent_to_add() {
3832            // Intent-to-add entries are not "staged" for diff-index / status
3833            // (matches Git: `git diff --cached` is empty for `-N` paths).
3834            continue;
3835        }
3836        if ie.stage() == 0 && ie.is_sparse_directory_placeholder() {
3837            let dir = path.strip_suffix('/').unwrap_or(path.as_str());
3838            let index_flat = flatten_tree(odb, &ie.oid, dir)?;
3839            let head_flat = match tree_oid {
3840                None => Vec::new(),
3841                Some(head_root) => match subtree_oid_at_path(odb, head_root, dir)? {
3842                    None => Vec::new(),
3843                    Some(head_sub) if head_sub == ie.oid => {
3844                        continue;
3845                    }
3846                    Some(head_sub) => flatten_tree(odb, &head_sub, dir)?,
3847                },
3848            };
3849            diff_flattened_subtrees(
3850                &head_flat,
3851                &index_flat,
3852                ignore_submodules,
3853                &mut result,
3854                &mut stage0_paths,
3855                &mut tree_map,
3856            );
3857            continue;
3858        }
3859        if ie.stage() != 0 {
3860            let rank = match ie.stage() {
3861                2 => 0u8,
3862                3 => 1u8,
3863                1 => 2u8,
3864                _ => 3u8,
3865            };
3866            match unmerged_modes.get(&path) {
3867                Some((existing_rank, _)) if *existing_rank <= rank => {}
3868                _ => {
3869                    unmerged_modes.insert(path, (rank, ie.mode));
3870                }
3871            }
3872            continue;
3873        }
3874        if ignore_submodules && ie.mode == 0o160000 {
3875            let _ = tree_map.remove(path.as_str());
3876            stage0_paths.insert(path.clone());
3877            continue;
3878        }
3879        stage0_paths.insert(path.clone());
3880        match tree_map.remove(path.as_str()) {
3881            Some(te) => {
3882                // Present in both — check for differences
3883                if te.oid != ie.oid
3884                    || crate::index::canon_mode(te.mode) != crate::index::canon_mode(ie.mode)
3885                {
3886                    let tree_mode = crate::index::canon_mode(te.mode);
3887                    let index_mode = crate::index::canon_mode(ie.mode);
3888                    result.push(DiffEntry {
3889                        status: DiffStatus::for_content_change(tree_mode, index_mode),
3890                        old_path: Some(path.clone()),
3891                        new_path: Some(path),
3892                        old_mode: FileMode::new(tree_mode),
3893                        new_mode: FileMode::new(index_mode),
3894                        old_oid: te.oid,
3895                        new_oid: ie.oid,
3896                        score: None,
3897                    });
3898                }
3899            }
3900            None => {
3901                // In index but not tree → added
3902                result.push(DiffEntry {
3903                    status: DiffStatus::Added,
3904                    old_path: None,
3905                    new_path: Some(path),
3906                    old_mode: FileMode::ABSENT,
3907                    new_mode: FileMode::new(ie.mode),
3908                    old_oid: zero_oid(),
3909                    new_oid: ie.oid,
3910                    score: None,
3911                });
3912            }
3913        }
3914    }
3915
3916    diff_index_to_tree_finish_unmerged_and_deleted(
3917        ignore_submodules,
3918        &mut tree_map,
3919        &unmerged_modes,
3920        &stage0_paths,
3921        &mut result,
3922    );
3923
3924    sort_entries_by_path(&mut result);
3925    Ok(result)
3926}
3927
3928fn diff_index_to_tree_finish_unmerged_and_deleted(
3929    ignore_submodules: bool,
3930    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
3931    unmerged_modes: &std::collections::BTreeMap<String, (u8, u32)>,
3932    stage0_paths: &std::collections::BTreeSet<String>,
3933    result: &mut Vec<DiffEntry>,
3934) {
3935    for (path, (_, mode)) in unmerged_modes {
3936        if stage0_paths.contains(path) {
3937            continue;
3938        }
3939        tree_map.remove(path.as_str());
3940        result.push(DiffEntry {
3941            status: DiffStatus::Unmerged,
3942            old_path: Some(path.clone()),
3943            new_path: Some(path.clone()),
3944            old_mode: FileMode::ABSENT,
3945            new_mode: FileMode::new(*mode),
3946            old_oid: zero_oid(),
3947            new_oid: zero_oid(),
3948            score: None,
3949        });
3950    }
3951
3952    // Remaining tree entries not in index → deleted
3953    for (path, te) in std::mem::take(tree_map) {
3954        if ignore_submodules && te.mode == 0o160000 {
3955            continue;
3956        }
3957        result.push(DiffEntry {
3958            status: DiffStatus::Deleted,
3959            old_path: Some(path.to_owned()),
3960            new_path: None,
3961            old_mode: FileMode::new(te.mode),
3962            new_mode: FileMode::ABSENT,
3963            old_oid: te.oid,
3964            new_oid: zero_oid(),
3965            score: None,
3966        });
3967    }
3968}
3969
3970/// Cache-tree-aware index-vs-tree diff: walk HEAD tree and index together and skip
3971/// subtrees whose valid cache-tree node OID matches the tree entry (Git unpack-trees).
3972fn diff_index_to_tree_with_cache_tree(
3973    odb: &Odb,
3974    index: &Index,
3975    head_tree: &ObjectId,
3976    cache_root: &CacheTreeNode,
3977    ignore_submodules: bool,
3978) -> Result<Vec<DiffEntry>> {
3979    let mut result = Vec::new();
3980    let mut idx_cursor = 0usize;
3981
3982    let mut walk = DiffIndexTreeWalk {
3983        odb,
3984        index,
3985        idx_cursor: &mut idx_cursor,
3986        ignore_submodules,
3987        result: &mut result,
3988        abort_to_flatten: false,
3989    };
3990    diff_index_tree_at(&mut walk, head_tree, Some(cache_root), b"")?;
3991    if walk.abort_to_flatten {
3992        return diff_index_to_tree_flatten(odb, index, Some(head_tree), ignore_submodules);
3993    }
3994
3995    // Index entries not consumed by the tree walk (added paths).
3996    while idx_cursor < index.entries.len() {
3997        let ie = &index.entries[idx_cursor];
3998        idx_cursor += 1;
3999        let path = String::from_utf8_lossy(&ie.path).to_string();
4000        if ie.stage() == 0 && ie.intent_to_add() {
4001            continue;
4002        }
4003        if ie.stage() != 0 {
4004            continue;
4005        }
4006        if ignore_submodules && ie.mode == 0o160000 {
4007            continue;
4008        }
4009        result.push(DiffEntry {
4010            status: DiffStatus::Added,
4011            old_path: None,
4012            new_path: Some(path),
4013            old_mode: FileMode::ABSENT,
4014            new_mode: FileMode::new(ie.mode),
4015            old_oid: zero_oid(),
4016            new_oid: ie.oid,
4017            score: None,
4018        });
4019    }
4020
4021    sort_entries_by_path(&mut result);
4022    Ok(result)
4023}
4024
4025enum IndexLevelKind<'a> {
4026    Blob {
4027        entry: &'a IndexEntry,
4028        name: &'a [u8],
4029    },
4030    Tree {
4031        name: &'a [u8],
4032    },
4033}
4034
4035/// `path` relative to the directory `dir_prefix` (raw bytes, no trailing slash), or `None` when
4036/// `path` lies outside it. The directory's own path maps to an empty slice.
4037fn index_relative_in_dir<'a>(path: &'a [u8], dir_prefix: &[u8]) -> Option<&'a [u8]> {
4038    if dir_prefix.is_empty() {
4039        return Some(path);
4040    }
4041    let rest = path.strip_prefix(dir_prefix)?;
4042    match rest.split_first() {
4043        None => Some(rest),
4044        Some((b'/', tail)) => Some(tail),
4045        Some(_) => None,
4046    }
4047}
4048
4049fn join_index_path(dir_prefix: &[u8], name: &[u8]) -> Vec<u8> {
4050    if dir_prefix.is_empty() {
4051        return name.to_vec();
4052    }
4053    let mut path = Vec::with_capacity(dir_prefix.len() + 1 + name.len());
4054    path.extend_from_slice(dir_prefix);
4055    path.push(b'/');
4056    path.extend_from_slice(name);
4057    path
4058}
4059
4060fn peek_index_level_entry<'a>(
4061    index: &'a Index,
4062    cursor: &mut usize,
4063    dir_prefix: &[u8],
4064    ignore_submodules: bool,
4065) -> Option<IndexLevelKind<'a>> {
4066    while let Some(ie) = index.entries.get(*cursor) {
4067        let rel = index_relative_in_dir(&ie.path, dir_prefix)?;
4068        if ie.stage() != 0 || ie.intent_to_add() || rel.is_empty() {
4069            *cursor += 1;
4070            continue;
4071        }
4072        if let Some(slash) = rel.iter().position(|&b| b == b'/') {
4073            return Some(IndexLevelKind::Tree {
4074                name: &rel[..slash],
4075            });
4076        }
4077        if ignore_submodules && ie.mode == 0o160000 {
4078            *cursor += 1;
4079            continue;
4080        }
4081        return Some(IndexLevelKind::Blob {
4082            entry: ie,
4083            name: rel,
4084        });
4085    }
4086    None
4087}
4088
4089fn skip_index_subtree(index: &Index, cursor: &mut usize, dir_prefix: &[u8]) {
4090    while index
4091        .entries
4092        .get(*cursor)
4093        .is_some_and(|ie| index_relative_in_dir(&ie.path, dir_prefix).is_some())
4094    {
4095        *cursor += 1;
4096    }
4097}
4098
4099/// Children of a cache-tree node up to which a linear name scan beats sorting.
4100const CACHE_CHILD_LINEAR_SCAN_MAX: usize = 16;
4101
4102/// Name lookup over one cache-tree node's subtrees.
4103///
4104/// The index extension does not promise any child order, and a wide directory is looked up
4105/// once per tree entry, so nodes with many children are sorted once per visit instead of
4106/// being scanned for every entry. The first child with a matching name wins either way.
4107struct CacheChildren<'a> {
4108    children: &'a [CacheTreeNode],
4109    sorted: Vec<&'a CacheTreeNode>,
4110}
4111
4112impl<'a> CacheChildren<'a> {
4113    fn new(node: Option<&'a CacheTreeNode>) -> Self {
4114        let children = node.map_or(&[][..], |n| n.children.as_slice());
4115        let sorted = if children.len() > CACHE_CHILD_LINEAR_SCAN_MAX {
4116            let mut sorted: Vec<&CacheTreeNode> = children.iter().collect();
4117            sorted.sort_by(|a, b| a.name.cmp(&b.name));
4118            sorted
4119        } else {
4120            Vec::new()
4121        };
4122        Self { children, sorted }
4123    }
4124
4125    fn get(&self, name: &[u8]) -> Option<&'a CacheTreeNode> {
4126        if self.sorted.is_empty() {
4127            return self.children.iter().find(|c| c.name == name);
4128        }
4129        let pos = self.sorted.partition_point(|c| c.name.as_slice() < name);
4130        self.sorted.get(pos).copied().filter(|c| c.name == name)
4131    }
4132}
4133
4134struct DiffIndexTreeWalk<'a, 'b> {
4135    odb: &'a Odb,
4136    index: &'a Index,
4137    idx_cursor: &'b mut usize,
4138    ignore_submodules: bool,
4139    result: &'b mut Vec<DiffEntry>,
4140    /// Set when a tree object has duplicate entry names; the caller falls back to flatten.
4141    abort_to_flatten: bool,
4142}
4143
4144fn tree_entries_have_duplicate_names(entries: &[TreeEntry]) -> bool {
4145    let mut seen = std::collections::BTreeSet::<&[u8]>::new();
4146    for entry in entries {
4147        if !seen.insert(entry.name.as_slice()) {
4148            return true;
4149        }
4150    }
4151    false
4152}
4153
4154/// Walk the tree `tree_oid` against the index entries under `dir_prefix` (raw path bytes).
4155fn diff_index_tree_at(
4156    walk: &mut DiffIndexTreeWalk<'_, '_>,
4157    tree_oid: &ObjectId,
4158    cache_node: Option<&CacheTreeNode>,
4159    dir_prefix: &[u8],
4160) -> Result<()> {
4161    if !dir_prefix.is_empty() {
4162        let mut path = dir_prefix.to_vec();
4163        path.push(b'/');
4164        if let Some(entry) = walk
4165            .index
4166            .entries
4167            .get(*walk.idx_cursor)
4168            .filter(|entry| entry.path == path && entry.is_sparse_directory_placeholder())
4169        {
4170            if entry.oid == *tree_oid {
4171                *walk.idx_cursor += 1;
4172                return Ok(());
4173            }
4174            let dir = String::from_utf8_lossy(dir_prefix);
4175            let head_flat = flatten_tree(walk.odb, tree_oid, dir.as_ref())?;
4176            let index_flat = flatten_tree(walk.odb, &entry.oid, dir.as_ref())?;
4177            let mut stage0_paths = std::collections::BTreeSet::new();
4178            let mut tree_map = std::collections::BTreeMap::new();
4179            diff_flattened_subtrees(
4180                &head_flat,
4181                &index_flat,
4182                walk.ignore_submodules,
4183                walk.result,
4184                &mut stage0_paths,
4185                &mut tree_map,
4186            );
4187            *walk.idx_cursor += 1;
4188            return Ok(());
4189        }
4190    }
4191    let tree_entries = read_tree(walk.odb, tree_oid)?;
4192    if tree_entries_have_duplicate_names(&tree_entries) {
4193        walk.abort_to_flatten = true;
4194        return Ok(());
4195    }
4196    let cache_children = CacheChildren::new(cache_node);
4197    let mut ti = 0usize;
4198
4199    loop {
4200        let te = tree_entries.get(ti);
4201        let ie_kind = peek_index_level_entry(
4202            walk.index,
4203            walk.idx_cursor,
4204            dir_prefix,
4205            walk.ignore_submodules,
4206        );
4207
4208        match (te, ie_kind) {
4209            (Some(t_entry), Some(ie)) => {
4210                let t_name = &t_entry.name;
4211                let (i_name, i_is_tree) = match &ie {
4212                    IndexLevelKind::Blob { name, .. } => (*name, false),
4213                    IndexLevelKind::Tree { name } => (*name, true),
4214                };
4215                let t_is_tree = is_tree_mode(t_entry.mode);
4216                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4217                match cmp {
4218                    std::cmp::Ordering::Less => {
4219                        emit_index_tree_deleted(
4220                            walk.odb,
4221                            t_entry,
4222                            dir_prefix,
4223                            walk.ignore_submodules,
4224                            walk.result,
4225                        )?;
4226                        ti += 1;
4227                    }
4228                    std::cmp::Ordering::Greater => {
4229                        emit_index_tree_added(walk, dir_prefix)?;
4230                    }
4231                    std::cmp::Ordering::Equal => {
4232                        if t_is_tree && i_is_tree {
4233                            let full_path = join_index_path(dir_prefix, t_name);
4234                            if diff_index_tree_skip_sparse_placeholder(walk, &full_path, t_entry)? {
4235                                ti += 1;
4236                                continue;
4237                            }
4238                            let child_cache = cache_children.get(t_name);
4239                            if child_cache
4240                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
4241                            {
4242                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4243                                ti += 1;
4244                                continue;
4245                            }
4246                            diff_index_tree_at(walk, &t_entry.oid, child_cache, &full_path)?;
4247                        } else if t_is_tree != i_is_tree {
4248                            emit_index_tree_deleted(
4249                                walk.odb,
4250                                t_entry,
4251                                dir_prefix,
4252                                walk.ignore_submodules,
4253                                walk.result,
4254                            )?;
4255                            emit_index_tree_added(walk, dir_prefix)?;
4256                        } else {
4257                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
4258                                ti += 1;
4259                                continue;
4260                            };
4261                            *walk.idx_cursor += 1;
4262                            if t_entry.oid != ie.oid
4263                                || crate::index::canon_mode(t_entry.mode)
4264                                    != crate::index::canon_mode(ie.mode)
4265                            {
4266                                let path = String::from_utf8_lossy(&ie.path).into_owned();
4267                                let tree_mode = crate::index::canon_mode(t_entry.mode);
4268                                let index_mode = crate::index::canon_mode(ie.mode);
4269                                walk.result.push(DiffEntry {
4270                                    status: DiffStatus::for_content_change(tree_mode, index_mode),
4271                                    old_path: Some(path.clone()),
4272                                    new_path: Some(path),
4273                                    old_mode: FileMode::new(tree_mode),
4274                                    new_mode: FileMode::new(index_mode),
4275                                    old_oid: t_entry.oid,
4276                                    new_oid: ie.oid,
4277                                    score: None,
4278                                });
4279                            }
4280                        }
4281                        ti += 1;
4282                    }
4283                }
4284            }
4285            (Some(t_entry), None) => {
4286                emit_index_tree_deleted(
4287                    walk.odb,
4288                    t_entry,
4289                    dir_prefix,
4290                    walk.ignore_submodules,
4291                    walk.result,
4292                )?;
4293                ti += 1;
4294            }
4295            (None, Some(_)) => {
4296                emit_index_tree_added(walk, dir_prefix)?;
4297            }
4298            (None, None) => break,
4299        }
4300    }
4301
4302    Ok(())
4303}
4304
4305fn diff_index_tree_skip_sparse_placeholder(
4306    walk: &mut DiffIndexTreeWalk<'_, '_>,
4307    full_path: &[u8],
4308    t_entry: &TreeEntry,
4309) -> Result<bool> {
4310    let Some(entry) = walk.index.entries.get(*walk.idx_cursor) else {
4311        return Ok(false);
4312    };
4313    if !entry.is_sparse_directory_placeholder() {
4314        return Ok(false);
4315    }
4316    let mut dir = full_path.to_vec();
4317    if !dir.ends_with(b"/") {
4318        dir.push(b'/');
4319    }
4320    if entry.path != dir || entry.oid != t_entry.oid {
4321        return Ok(false);
4322    }
4323    *walk.idx_cursor += 1;
4324    Ok(true)
4325}
4326
4327fn emit_index_tree_deleted(
4328    odb: &Odb,
4329    entry: &TreeEntry,
4330    dir_prefix: &[u8],
4331    ignore_submodules: bool,
4332    result: &mut Vec<DiffEntry>,
4333) -> Result<()> {
4334    if ignore_submodules && entry.mode == 0o160000 {
4335        return Ok(());
4336    }
4337    let path = String::from_utf8_lossy(&join_index_path(dir_prefix, &entry.name)).into_owned();
4338    if is_tree_mode(entry.mode) {
4339        for fe in flatten_tree(odb, &entry.oid, &path)? {
4340            if ignore_submodules && fe.mode == 0o160000 {
4341                continue;
4342            }
4343            result.push(DiffEntry {
4344                status: DiffStatus::Deleted,
4345                old_path: Some(fe.path.clone()),
4346                new_path: None,
4347                old_mode: FileMode::new(fe.mode),
4348                new_mode: FileMode::ABSENT,
4349                old_oid: fe.oid,
4350                new_oid: zero_oid(),
4351                score: None,
4352            });
4353        }
4354    } else {
4355        result.push(DiffEntry {
4356            status: DiffStatus::Deleted,
4357            old_path: Some(path),
4358            new_path: None,
4359            old_mode: FileMode::new(entry.mode),
4360            new_mode: FileMode::ABSENT,
4361            old_oid: entry.oid,
4362            new_oid: zero_oid(),
4363            score: None,
4364        });
4365    }
4366    Ok(())
4367}
4368
4369fn emit_index_tree_added(walk: &mut DiffIndexTreeWalk<'_, '_>, dir_prefix: &[u8]) -> Result<()> {
4370    let ie_kind = peek_index_level_entry(
4371        walk.index,
4372        walk.idx_cursor,
4373        dir_prefix,
4374        walk.ignore_submodules,
4375    )
4376    .ok_or_else(|| Error::IndexError("missing index entry during tree walk".to_owned()))?;
4377    match ie_kind {
4378        IndexLevelKind::Blob { entry: ie, .. } => {
4379            let path = String::from_utf8_lossy(&ie.path).into_owned();
4380            *walk.idx_cursor += 1;
4381            walk.result.push(DiffEntry {
4382                status: DiffStatus::Added,
4383                old_path: None,
4384                new_path: Some(path),
4385                old_mode: FileMode::ABSENT,
4386                new_mode: FileMode::new(ie.mode),
4387                old_oid: zero_oid(),
4388                new_oid: ie.oid,
4389                score: None,
4390            });
4391        }
4392        IndexLevelKind::Tree { name } => {
4393            let full_path = join_index_path(dir_prefix, name);
4394            let mut path_slash = full_path.clone();
4395            path_slash.push(b'/');
4396            if let Some(entry) =
4397                walk.index.entries.get(*walk.idx_cursor).filter(|entry| {
4398                    entry.path == path_slash && entry.is_sparse_directory_placeholder()
4399                })
4400            {
4401                let dir = String::from_utf8_lossy(if dir_prefix.is_empty() {
4402                    name
4403                } else {
4404                    &full_path
4405                });
4406                emit_flattened_subtree_as_added(
4407                    walk.odb,
4408                    &entry.oid,
4409                    dir.as_ref(),
4410                    walk.ignore_submodules,
4411                    walk.result,
4412                )?;
4413                *walk.idx_cursor += 1;
4414                return Ok(());
4415            }
4416            let start = *walk.idx_cursor;
4417            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4418            for ie in &walk.index.entries[start..*walk.idx_cursor] {
4419                if ie.stage() != 0 || ie.intent_to_add() {
4420                    continue;
4421                }
4422                if walk.ignore_submodules && ie.mode == 0o160000 {
4423                    continue;
4424                }
4425                let path = String::from_utf8_lossy(&ie.path).into_owned();
4426                walk.result.push(DiffEntry {
4427                    status: DiffStatus::Added,
4428                    old_path: None,
4429                    new_path: Some(path),
4430                    old_mode: FileMode::ABSENT,
4431                    new_mode: FileMode::new(ie.mode),
4432                    old_oid: zero_oid(),
4433                    new_oid: ie.oid,
4434                    score: None,
4435                });
4436            }
4437        }
4438    }
4439    Ok(())
4440}
4441
4442/// Paths and rows to reconcile an index with a target tree during hard reset.
4443#[derive(Default)]
4444pub(crate) struct HardResetIndexPlan {
4445    pub remove_paths: Vec<Vec<u8>>,
4446    pub updates: Vec<(Vec<u8>, u32, ObjectId)>,
4447    pub insert_entries: Vec<IndexEntry>,
4448}
4449
4450/// Plan index changes to match `target_tree` using a cache-tree-aware lockstep walk (raw paths).
4451///
4452/// Never builds a flattened tree map; when the cache-tree extension is absent, walks the full tree
4453/// against the index instead of calling [`diff_index_to_tree_flatten`].
4454pub(crate) fn plan_hard_reset_index_align(
4455    odb: &Odb,
4456    index: &Index,
4457    target_tree: &ObjectId,
4458) -> Result<HardResetIndexPlan> {
4459    let mut plan = HardResetIndexPlan::default();
4460    let mut idx_cursor = 0usize;
4461    let cache_root = index.cache_tree.as_ref();
4462    let use_cache = cache_root.is_some()
4463        && index
4464            .entries
4465            .iter()
4466            .all(|entry| entry.stage() == 0 && !entry.intent_to_add());
4467    let cache_node = if use_cache { cache_root } else { None };
4468    let retry = {
4469        let mut walk = HardResetAlignWalk {
4470            odb,
4471            index,
4472            idx_cursor: &mut idx_cursor,
4473            ignore_submodules: false,
4474            plan: &mut plan,
4475            retry_without_cache: false,
4476        };
4477        hard_reset_align_index_tree_at(&mut walk, target_tree, cache_node, b"")?;
4478        walk.retry_without_cache
4479    };
4480    if retry {
4481        idx_cursor = 0;
4482        plan = HardResetIndexPlan::default();
4483        let mut walk = HardResetAlignWalk {
4484            odb,
4485            index,
4486            idx_cursor: &mut idx_cursor,
4487            ignore_submodules: false,
4488            plan: &mut plan,
4489            retry_without_cache: false,
4490        };
4491        hard_reset_align_index_tree_at(&mut walk, target_tree, None, b"")?;
4492    }
4493    while idx_cursor < index.entries.len() {
4494        let ie = &index.entries[idx_cursor];
4495        idx_cursor += 1;
4496        if ie.stage() != 0 || ie.intent_to_add() {
4497            continue;
4498        }
4499        plan.remove_paths.push(ie.path.clone());
4500    }
4501    Ok(plan)
4502}
4503
4504/// Apply a [`HardResetIndexPlan`] and invalidate cache-tree nodes for touched paths.
4505pub(crate) fn apply_hard_reset_index_plan(index: &mut Index, plan: HardResetIndexPlan) {
4506    for path in &plan.remove_paths {
4507        index.remove_path_all_stages(path);
4508        index.invalidate_cache_tree_for_path(path);
4509    }
4510    for (path, mode, oid) in plan.updates {
4511        if let Some(pos) = index.find_entry_pos(&path, 0) {
4512            index.entries[pos].mode = mode;
4513            index.entries[pos].oid = oid;
4514            index.entries[pos].size = 0;
4515            index.invalidate_cache_tree_for_path(&path);
4516        }
4517    }
4518    for entry in plan.insert_entries {
4519        let path = entry.path.clone();
4520        index.add_or_replace(entry);
4521        index.invalidate_cache_tree_for_path(&path);
4522    }
4523    index.sort();
4524}
4525
4526/// Rebuild the cache-tree extension after hard-reset index alignment, reusing valid seed
4527/// subtrees from the lockstep tree/index walk.
4528pub(crate) fn rebuild_hard_reset_cache_tree(
4529    odb: &Odb,
4530    index: &mut Index,
4531    target_tree: &ObjectId,
4532) -> Result<()> {
4533    let stage0_count = index
4534        .entries
4535        .iter()
4536        .filter(|entry| entry.stage() == 0 && !entry.intent_to_add())
4537        .count();
4538    let seed_root = index.cache_tree.as_ref();
4539    let mut idx_cursor = 0usize;
4540    let root = rebuild_hard_reset_cache_tree_at(
4541        odb,
4542        index,
4543        &mut idx_cursor,
4544        target_tree,
4545        seed_root,
4546        b"",
4547        Vec::new(),
4548    )?;
4549    if usize::try_from(root.entry_count).ok() == Some(stage0_count) {
4550        index.set_cache_tree(root);
4551        Ok(())
4552    } else {
4553        Err(Error::IndexError(
4554            "hard reset cache-tree entry count does not match index".into(),
4555        ))
4556    }
4557}
4558
4559fn rebuild_hard_reset_cache_tree_at(
4560    odb: &Odb,
4561    index: &Index,
4562    idx_cursor: &mut usize,
4563    tree_oid: &ObjectId,
4564    seed_node: Option<&CacheTreeNode>,
4565    dir_prefix: &[u8],
4566    name: Vec<u8>,
4567) -> Result<CacheTreeNode> {
4568    if !dir_prefix.is_empty() {
4569        let mut path = dir_prefix.to_vec();
4570        path.push(b'/');
4571        if let Some(entry) = index.entries.get(*idx_cursor).filter(|entry| {
4572            entry.path == path && entry.is_sparse_directory_placeholder() && entry.oid == *tree_oid
4573        }) {
4574            let _ = entry;
4575            *idx_cursor += 1;
4576            return Ok(CacheTreeNode::valid(name, 1, *tree_oid, Vec::new()));
4577        }
4578    }
4579    let tree_entries = read_tree(odb, tree_oid)?;
4580    let cache_children = CacheChildren::new(seed_node);
4581    let mut entry_count: i32 = 0;
4582    let mut children: Vec<CacheTreeNode> = Vec::new();
4583    let mut ti = 0usize;
4584
4585    loop {
4586        let te = tree_entries.get(ti);
4587        let ie_kind = peek_index_level_entry(index, idx_cursor, dir_prefix, false);
4588        match (te, ie_kind) {
4589            (Some(t_entry), Some(ie)) => {
4590                let t_name = &t_entry.name;
4591                let (i_name, i_is_tree) = match &ie {
4592                    IndexLevelKind::Blob { name, .. } => (*name, false),
4593                    IndexLevelKind::Tree { name } => (*name, true),
4594                };
4595                let t_is_tree = is_tree_mode(t_entry.mode);
4596                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4597                if cmp != std::cmp::Ordering::Equal {
4598                    return Err(Error::IndexError(
4599                        "hard reset cache-tree rebuild: index out of sync with target tree".into(),
4600                    ));
4601                }
4602                if t_is_tree && i_is_tree {
4603                    let full_path = join_index_path(dir_prefix, t_name);
4604                    if hard_reset_skip_sparse_cache_rebuild(index, idx_cursor, &full_path, t_entry)?
4605                    {
4606                        entry_count = entry_count.saturating_add(1);
4607                        ti += 1;
4608                        continue;
4609                    }
4610                    let child = if let Some(child_cache) = cache_children
4611                        .get(t_name)
4612                        .filter(|node| node.is_valid() && node.oid == Some(t_entry.oid))
4613                    {
4614                        skip_index_subtree(index, idx_cursor, &full_path);
4615                        let mut reused = (*child_cache).clone();
4616                        reused.name = t_name.clone();
4617                        reused
4618                    } else {
4619                        rebuild_hard_reset_cache_tree_at(
4620                            odb,
4621                            index,
4622                            idx_cursor,
4623                            &t_entry.oid,
4624                            cache_children.get(t_name),
4625                            &full_path,
4626                            t_name.clone(),
4627                        )?
4628                    };
4629                    entry_count = entry_count.saturating_add(child.entry_count.max(0));
4630                    children.push(child);
4631                } else if t_is_tree != i_is_tree {
4632                    return Err(Error::IndexError(
4633                        "hard reset cache-tree rebuild: tree/index type mismatch".into(),
4634                    ));
4635                } else {
4636                    *idx_cursor += 1;
4637                    entry_count = entry_count.saturating_add(1);
4638                }
4639                ti += 1;
4640            }
4641            (None, None) => break,
4642            _ => {
4643                return Err(Error::IndexError(
4644                    "hard reset cache-tree rebuild: index out of sync with target tree".into(),
4645                ));
4646            }
4647        }
4648    }
4649    children.sort_by(|a, b| a.name.cmp(&b.name));
4650    Ok(CacheTreeNode::valid(name, entry_count, *tree_oid, children))
4651}
4652
4653fn hard_reset_skip_sparse_cache_rebuild(
4654    index: &Index,
4655    idx_cursor: &mut usize,
4656    full_path: &[u8],
4657    t_entry: &TreeEntry,
4658) -> Result<bool> {
4659    let Some(entry) = index.entries.get(*idx_cursor) else {
4660        return Ok(false);
4661    };
4662    if !entry.is_sparse_directory_placeholder() {
4663        return Ok(false);
4664    }
4665    let mut dir = full_path.to_vec();
4666    if !dir.ends_with(b"/") {
4667        dir.push(b'/');
4668    }
4669    if entry.path != dir || entry.oid != t_entry.oid {
4670        return Ok(false);
4671    }
4672    *idx_cursor += 1;
4673    Ok(true)
4674}
4675
4676struct HardResetAlignWalk<'a, 'b> {
4677    odb: &'a Odb,
4678    index: &'a Index,
4679    idx_cursor: &'b mut usize,
4680    ignore_submodules: bool,
4681    plan: &'b mut HardResetIndexPlan,
4682    retry_without_cache: bool,
4683}
4684
4685fn hard_reset_align_index_tree_at(
4686    walk: &mut HardResetAlignWalk<'_, '_>,
4687    tree_oid: &ObjectId,
4688    cache_node: Option<&CacheTreeNode>,
4689    dir_prefix: &[u8],
4690) -> Result<()> {
4691    let tree_entries = read_tree(walk.odb, tree_oid)?;
4692    if tree_entries_have_duplicate_names(&tree_entries) {
4693        walk.retry_without_cache = true;
4694        return Ok(());
4695    }
4696    let cache_children = CacheChildren::new(cache_node);
4697    let mut ti = 0usize;
4698
4699    loop {
4700        let te = tree_entries.get(ti);
4701        let ie_kind = peek_index_level_entry(
4702            walk.index,
4703            walk.idx_cursor,
4704            dir_prefix,
4705            walk.ignore_submodules,
4706        );
4707
4708        match (te, ie_kind) {
4709            (Some(t_entry), Some(ie)) => {
4710                let t_name = &t_entry.name;
4711                let (i_name, i_is_tree) = match &ie {
4712                    IndexLevelKind::Blob { name, .. } => (*name, false),
4713                    IndexLevelKind::Tree { name } => (*name, true),
4714                };
4715                let t_is_tree = is_tree_mode(t_entry.mode);
4716                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
4717                match cmp {
4718                    std::cmp::Ordering::Less => {
4719                        hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
4720                        ti += 1;
4721                    }
4722                    std::cmp::Ordering::Greater => {
4723                        hard_reset_plan_index_only(walk, dir_prefix)?;
4724                    }
4725                    std::cmp::Ordering::Equal => {
4726                        if t_is_tree && i_is_tree {
4727                            let full_path = join_index_path(dir_prefix, t_name);
4728                            if hard_reset_skip_sparse_placeholder(walk, &full_path, t_entry)? {
4729                                ti += 1;
4730                                continue;
4731                            }
4732                            let child_cache = cache_children.get(t_name);
4733                            if child_cache
4734                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
4735                            {
4736                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4737                                ti += 1;
4738                                continue;
4739                            }
4740                            hard_reset_align_index_tree_at(
4741                                walk,
4742                                &t_entry.oid,
4743                                child_cache,
4744                                &full_path,
4745                            )?;
4746                        } else if t_is_tree != i_is_tree {
4747                            hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
4748                            hard_reset_plan_index_only(walk, dir_prefix)?;
4749                        } else {
4750                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
4751                                ti += 1;
4752                                continue;
4753                            };
4754                            *walk.idx_cursor += 1;
4755                            if t_entry.oid != ie.oid
4756                                || crate::index::canon_mode(t_entry.mode)
4757                                    != crate::index::canon_mode(ie.mode)
4758                            {
4759                                walk.plan.updates.push((
4760                                    ie.path.clone(),
4761                                    crate::index::canon_mode(t_entry.mode),
4762                                    t_entry.oid,
4763                                ));
4764                            }
4765                        }
4766                        ti += 1;
4767                    }
4768                }
4769            }
4770            (Some(t_entry), None) => {
4771                hard_reset_plan_tree_only(walk, t_entry, dir_prefix)?;
4772                ti += 1;
4773            }
4774            (None, Some(_)) => {
4775                hard_reset_plan_index_only(walk, dir_prefix)?;
4776            }
4777            (None, None) => break,
4778        }
4779    }
4780    Ok(())
4781}
4782
4783fn hard_reset_skip_sparse_placeholder(
4784    walk: &mut HardResetAlignWalk<'_, '_>,
4785    full_path: &[u8],
4786    t_entry: &TreeEntry,
4787) -> Result<bool> {
4788    let Some(ie) = walk.index.entries.get(*walk.idx_cursor) else {
4789        return Ok(false);
4790    };
4791    if !ie.is_sparse_directory_placeholder() {
4792        return Ok(false);
4793    }
4794    let mut dir = full_path.to_vec();
4795    if !dir.ends_with(b"/") {
4796        dir.push(b'/');
4797    }
4798    if ie.path != dir || ie.oid != t_entry.oid {
4799        return Ok(false);
4800    }
4801    *walk.idx_cursor += 1;
4802    Ok(true)
4803}
4804
4805fn hard_reset_plan_index_only(
4806    walk: &mut HardResetAlignWalk<'_, '_>,
4807    dir_prefix: &[u8],
4808) -> Result<()> {
4809    let ie_kind = peek_index_level_entry(
4810        walk.index,
4811        walk.idx_cursor,
4812        dir_prefix,
4813        walk.ignore_submodules,
4814    )
4815    .ok_or_else(|| Error::IndexError("missing index entry during hard reset align".into()))?;
4816    match ie_kind {
4817        IndexLevelKind::Blob { entry: ie, .. } => {
4818            walk.plan.remove_paths.push(ie.path.clone());
4819            *walk.idx_cursor += 1;
4820        }
4821        IndexLevelKind::Tree { name } => {
4822            let full_path = join_index_path(dir_prefix, name);
4823            let start = *walk.idx_cursor;
4824            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
4825            for ie in &walk.index.entries[start..*walk.idx_cursor] {
4826                if ie.stage() != 0 || ie.intent_to_add() {
4827                    continue;
4828                }
4829                walk.plan.remove_paths.push(ie.path.clone());
4830            }
4831        }
4832    }
4833    Ok(())
4834}
4835
4836fn hard_reset_plan_tree_only(
4837    walk: &mut HardResetAlignWalk<'_, '_>,
4838    entry: &TreeEntry,
4839    dir_prefix: &[u8],
4840) -> Result<()> {
4841    if walk.ignore_submodules && entry.mode == 0o160000 {
4842        return Ok(());
4843    }
4844    let path = join_index_path(dir_prefix, &entry.name);
4845    if is_tree_mode(entry.mode) {
4846        hard_reset_append_entries_from_tree(
4847            walk.odb,
4848            &entry.oid,
4849            &path,
4850            &mut walk.plan.insert_entries,
4851        )?;
4852    } else {
4853        walk.plan
4854            .insert_entries
4855            .push(hard_reset_index_tree_row(path, entry.mode, entry.oid));
4856    }
4857    Ok(())
4858}
4859
4860fn hard_reset_index_tree_row(path: Vec<u8>, mode: u32, oid: ObjectId) -> IndexEntry {
4861    IndexEntry {
4862        ctime_sec: 0,
4863        ctime_nsec: 0,
4864        mtime_sec: 0,
4865        mtime_nsec: 0,
4866        dev: 0,
4867        ino: 0,
4868        mode: crate::index::canon_mode(mode),
4869        uid: 0,
4870        gid: 0,
4871        size: 0,
4872        oid,
4873        flags: path.len().min(0xFFF) as u16,
4874        flags_extended: None,
4875        path,
4876        base_index_pos: 0,
4877    }
4878}
4879
4880fn hard_reset_append_entries_from_tree(
4881    odb: &Odb,
4882    tree_oid: &ObjectId,
4883    prefix: &[u8],
4884    out: &mut Vec<IndexEntry>,
4885) -> Result<()> {
4886    for te in read_tree(odb, tree_oid)? {
4887        let mut path = prefix.to_vec();
4888        if !path.is_empty() {
4889            path.push(b'/');
4890        }
4891        path.extend_from_slice(&te.name);
4892        if is_tree_mode(te.mode) {
4893            hard_reset_append_entries_from_tree(odb, &te.oid, &path, out)?;
4894        } else {
4895            out.push(hard_reset_index_tree_row(path, te.mode, te.oid));
4896        }
4897    }
4898    Ok(())
4899}
4900
4901// ── Index-to-worktree diff (unstaged changes) ───────────────────────
4902
4903/// Compare the index against the working tree.
4904///
4905/// This shows "unstaged" changes — modifications not yet staged.
4906///
4907/// Entries with [`IndexEntry::assume_unchanged`] or [`IndexEntry::skip_worktree`] are treated as
4908/// matching the work tree without examining the filesystem (Git `CE_VALID` / skip-worktree).
4909///
4910/// # Parameters
4911///
4912/// - `odb` — object database (for hashing worktree files).
4913/// - `index` — the current index.
4914/// - `work_tree` — path to the working tree root.
4915/// - `ignore_submodule_untracked` — when true, gitlink entries are not dirty solely from untracked
4916///   files inside the submodule (matches `git status -uno`).
4917/// - `simplify_gitlinks` — when true, nested gitlink entries only compare the submodule checkout
4918///   HEAD to the recorded OID (ignore dirty work trees inside nested submodules). Used when
4919///   computing `submodule_porcelain_flags` so untracked files under a nested submodule do not set
4920///   the parent submodule's `modified` bit (Git `DIRTY_SUBMODULE_MODIFIED`; t7506).
4921///
4922/// # Errors
4923///
4924/// Returns errors from I/O or hashing.
4925pub fn diff_index_to_worktree(
4926    odb: &Odb,
4927    index: &mut Index,
4928    work_tree: &Path,
4929    ignore_submodule_untracked: bool,
4930    simplify_gitlinks: bool,
4931) -> Result<Vec<DiffEntry>> {
4932    diff_index_to_worktree_with_options(
4933        odb,
4934        index,
4935        work_tree,
4936        DiffIndexToWorktreeOptions {
4937            ignore_submodule_untracked,
4938            simplify_gitlinks,
4939            ..DiffIndexToWorktreeOptions::default()
4940        },
4941    )
4942    .map(|(entries, _)| entries)
4943}
4944
4945/// Additional inputs for [`diff_index_to_worktree_with_options`].
4946#[derive(Clone)]
4947pub struct DiffIndexToWorktreeOptions {
4948    /// Optional index mtime pair `(sec, nsec)` sampled when the index was read.
4949    ///
4950    /// When provided, entries with matching stat data are still considered dirty candidates if
4951    /// their recorded mtime is "racy" (at or after this timestamp), matching Git's
4952    /// `is_racy_timestamp` behavior.
4953    pub index_mtime: Option<(u32, u32)>,
4954    /// When true, gitlink entries are not dirty solely from untracked files inside the submodule.
4955    pub ignore_submodule_untracked: bool,
4956    /// When true, nested gitlink entries only compare the submodule checkout HEAD to the recorded OID.
4957    pub simplify_gitlinks: bool,
4958    /// When true, a populated gitlink checkout whose `.git` indirection cannot resolve to a HEAD
4959    /// is returned as an error instead of a normal modified gitlink.
4960    pub error_on_broken_gitlinks: bool,
4961    /// Repository git directory used to load config (separate git dir / linked worktrees).
4962    ///
4963    /// When unset, defaults to `work_tree/.git`, which is wrong for gitfile layouts.
4964    pub repository_git_dir: Option<std::path::PathBuf>,
4965    /// When true, adopt refreshed stat data for unchanged blobs during the same directory scan
4966    /// as the worktree diff (one lstat per tracked path).
4967    pub refresh_index_stat_in_pass: bool,
4968    /// When set, used instead of loading config from [`Self::repository_git_dir`].
4969    pub config: Option<std::sync::Arc<crate::config::ConfigSet>>,
4970    /// Cap parallel stat workers (`Some(1)` forces serial), also applied to hashing the
4971    /// racily clean entries the stat refresh verifies. When `None`, honors
4972    /// `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
4973    pub stat_parallel_threads: Option<usize>,
4974    /// CLI / embedder thread override applied to stat preload and hashing in this diff.
4975    pub thread_policy: Option<crate::parallel::ThreadPolicy>,
4976    /// Shared per-operation attribute/conversion context (see [`crate::worktree_rules::WorktreeRules`]).
4977    pub worktree_rules:
4978        Option<std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
4979    /// Status-only shortcuts (default `false`).
4980    ///
4981    /// When true, a tracked file whose size differs from its index entry is reported as
4982    /// modified without reading it, with a null worktree `new_oid`, and intent-to-add
4983    /// entries are not hashed. Content diffs must leave this off so a content change always
4984    /// carries a null worktree `new_oid` (`old_oid != new_oid`).
4985    pub for_status: bool,
4986    /// When true (default), query `core.fsmonitor` before scanning the worktree.
4987    pub refresh_fsmonitor: bool,
4988    /// Injectable subprocess runner for fsmonitor hooks; defaults to the system runner.
4989    pub command_runner: Option<std::sync::Arc<dyn crate::command_runner::CommandRunner>>,
4990    /// When set, used as the hook protocol v1 query boundary; otherwise [`crate::environment::wall_clock_unix_timestamp_nanos`] is sampled once per diff.
4991    pub fsmonitor_query_boundary: Option<crate::fsmonitor::FsmonitorQueryBoundary>,
4992    /// When set, only index paths in this set are compared against the work tree.
4993    ///
4994    /// Used by branch switch to verify dirty paths only where the tree-to-tree diff would
4995    /// update the worktree, avoiding a full tracked scan on large indexes. Callers must
4996    /// refuse the operation when the index has unmerged entries; this filter does not
4997    /// substitute for a full unmerged scan.
4998    pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
4999    /// When true with staging materialization, compute blob OIDs without writing loose objects.
5000    pub hash_blobs_without_write: bool,
5001    /// When set, long loops abort with [`crate::error::Error::Cancelled`] once the flag is raised
5002    /// (see [`crate::progress::cancel_watch_arc`] and [`crate::progress::poll_cancel_watch`]).
5003    pub cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
5004}
5005
5006impl Default for DiffIndexToWorktreeOptions {
5007    fn default() -> Self {
5008        Self {
5009            index_mtime: None,
5010            ignore_submodule_untracked: false,
5011            simplify_gitlinks: false,
5012            error_on_broken_gitlinks: false,
5013            repository_git_dir: None,
5014            refresh_index_stat_in_pass: false,
5015            config: None,
5016            stat_parallel_threads: None,
5017            thread_policy: None,
5018            worktree_rules: None,
5019            for_status: false,
5020            refresh_fsmonitor: true,
5021            command_runner: None,
5022            fsmonitor_query_boundary: None,
5023            restrict_to_paths: None,
5024            hash_blobs_without_write: false,
5025            cancel_watch: None,
5026        }
5027    }
5028}
5029
5030#[inline]
5031fn poll_diff_cancel_watch(
5032    watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
5033) -> crate::error::Result<()> {
5034    if let Some(flag) = watch {
5035        crate::progress::poll_watch(flag)?;
5036    }
5037    Ok(())
5038}
5039
5040#[inline]
5041fn poll_diff_cancel(
5042    options: &DiffIndexToWorktreeOptions,
5043    owner_poll: Option<&std::sync::Arc<crate::progress::StackCancelPoll>>,
5044) -> crate::error::Result<()> {
5045    if let Some(poll) = owner_poll {
5046        poll.poll_owner()?;
5047    } else {
5048        poll_diff_cancel_watch(options.cancel_watch.as_ref())?;
5049    }
5050    Ok(())
5051}
5052
5053struct StagingHashJob {
5054    entry_index: usize,
5055    rel_path: String,
5056    file_path: PathBuf,
5057    entry: IndexEntry,
5058    /// Worktree mode after [`crate::index::WorktreeModePolicy`] against `entry.mode`.
5059    worktree_mode: u32,
5060    file_attrs: crate::crlf::FileAttrs,
5061}
5062
5063#[allow(clippy::too_many_arguments)]
5064fn parallel_process_blob_worktree_jobs(
5065    odb: &Odb,
5066    jobs: &[StagingHashJob],
5067    thread_policy: &crate::parallel::ThreadPolicy,
5068    config: &crate::config::ConfigSet,
5069    materialize_dirty_blobs: bool,
5070    hash_blobs_without_write: bool,
5071    conv: &crate::crlf::ConversionConfig,
5072    filter_process: Option<&crate::filter_process::FilterProcessState>,
5073    _icase_collisions: Option<&crate::path_icase::IcaseCheckoutCollisionMap>,
5074    result: &mut Vec<DiffEntry>,
5075) -> Result<()> {
5076    use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
5077
5078    if jobs.is_empty() {
5079        return Ok(());
5080    }
5081    let mut par_opts = thread_policy.resolve(ParallelOp::IndexRefresh, config);
5082    par_opts.min_items = 0;
5083    par_opts.min_total_bytes = None;
5084    let est_bytes = jobs.len().saturating_mul(8192);
5085    let store_dirty_blobs = (materialize_dirty_blobs && !hash_blobs_without_write)
5086        .then(|| odb.bulk_stage_write_options());
5087    let stream_from = if store_dirty_blobs.is_some() {
5088        odb.big_file_threshold()?
5089    } else {
5090        hash_stream_threshold(odb)?
5091    };
5092
5093    let rows = try_par_map::<StagingHashJob, Option<(usize, DiffEntry)>, Error, _>(
5094        jobs,
5095        par_opts,
5096        est_bytes,
5097        |job| {
5098            let modified = |new_oid| {
5099                Ok(Some((
5100                    job.entry_index,
5101                    worktree_content_change_entry(
5102                        &job.entry,
5103                        &job.rel_path,
5104                        job.worktree_mode,
5105                        new_oid,
5106                    ),
5107                )))
5108            };
5109            let content = match read_regular_worktree_blob(
5110                odb,
5111                &job.file_path,
5112                conv,
5113                &job.file_attrs,
5114                &job.rel_path,
5115                Some(&job.entry),
5116                filter_process,
5117                stream_from,
5118            ) {
5119                Ok(content) => content,
5120                Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
5121                    return modified(zero_oid());
5122                }
5123                Err(err) => return Err(err),
5124            };
5125            let eff_oid = content.object_id(odb, store_dirty_blobs)?;
5126            if eff_oid != job.entry.oid || job.worktree_mode != job.entry.mode {
5127                let new_oid = if eff_oid != job.entry.oid && !materialize_dirty_blobs {
5128                    zero_oid()
5129                } else {
5130                    eff_oid
5131                };
5132                modified(new_oid)
5133            } else {
5134                Ok(None)
5135            }
5136        },
5137    )
5138    .map_err(|e| match e {
5139        ParallelHashError::Task(err) => err,
5140    })?;
5141
5142    let mut rows: Vec<(usize, DiffEntry)> = rows.into_iter().flatten().collect();
5143    rows.sort_by_key(|(idx, _)| *idx);
5144    result.extend(rows.into_iter().map(|(_, d)| d));
5145    Ok(())
5146}
5147
5148/// Index-vs-worktree entry for a tracked path whose content or mode differs from `ie`.
5149fn worktree_content_change_entry(
5150    ie: &IndexEntry,
5151    path: &str,
5152    worktree_mode: u32,
5153    new_oid: ObjectId,
5154) -> DiffEntry {
5155    DiffEntry {
5156        status: DiffStatus::for_content_change(ie.mode, worktree_mode),
5157        old_path: Some(path.to_owned()),
5158        new_path: Some(path.to_owned()),
5159        old_mode: FileMode::new(ie.mode),
5160        new_mode: FileMode::new(worktree_mode),
5161        old_oid: ie.oid,
5162        new_oid,
5163        score: None,
5164    }
5165}
5166
5167/// Whether a failed read of a tracked file's content reports the path as modified (with an
5168/// unknown worktree oid) instead of failing the whole scan.
5169///
5170/// A file that cannot be opened (no permission, held exclusively by another process) cannot
5171/// be proven to match the index, so it counts as changed, as in Git. Callers that need the
5172/// bytes themselves (`materialize_dirty_blobs`, i.e. staging) still get the error.
5173fn unreadable_worktree_blob_is_modified(err: &Error, materialize_dirty_blobs: bool) -> bool {
5174    !materialize_dirty_blobs
5175        && matches!(
5176            err,
5177            Error::WorktreePathIo {
5178                operation: crate::worktree::WorktreePathOperation::Read,
5179                ..
5180            }
5181        )
5182}
5183
5184/// Whether status may report `ie` modified because the worktree size (already munged to the
5185/// index's 32-bit form) differs from the cached one, without reading the file (Git
5186/// `compare_stat_data`).
5187///
5188/// Only sound when clean conversion is the identity: a line-ending rule, filter, encoding or
5189/// `ident` can turn a file of another size into exactly the indexed blob. The
5190/// `conversion_is_identity` check runs only once the sizes differ, so clean entries never
5191/// resolve attributes here.
5192fn status_size_change_proves_modified(
5193    ie: &IndexEntry,
5194    worktree_size: u32,
5195    for_status: bool,
5196    conversion_is_identity: impl FnOnce() -> bool,
5197) -> bool {
5198    for_status && !ie.stat_cache_unset() && worktree_size != ie.size && conversion_is_identity()
5199}
5200
5201/// Report `ie` modified when [`status_size_change_proves_modified`] holds; returns whether it
5202/// did. The worktree side uses the null OID because the file was not hashed.
5203#[allow(clippy::too_many_arguments)]
5204fn try_push_status_modified_on_size_mismatch(
5205    odb: &Odb,
5206    ie: &IndexEntry,
5207    path_str_ref: &str,
5208    worktree_size: u32,
5209    worktree_mode: u32,
5210    raw_worktree_len: u64,
5211    for_status: bool,
5212    conversion_is_identity: impl FnOnce() -> bool,
5213    result: &mut Vec<DiffEntry>,
5214) -> Result<bool> {
5215    let identity = conversion_is_identity();
5216    let size_differs = worktree_size != ie.size;
5217    if !size_differs || !identity {
5218        return Ok(false);
5219    }
5220    let threshold = odb.big_file_threshold()?;
5221    let big_file = raw_worktree_len > threshold;
5222    let proves_modified = if big_file {
5223        true
5224    } else {
5225        status_size_change_proves_modified(ie, worktree_size, for_status, || identity)
5226    };
5227    if !proves_modified {
5228        return Ok(false);
5229    }
5230    result.push(worktree_content_change_entry(
5231        ie,
5232        path_str_ref,
5233        worktree_mode,
5234        zero_oid(),
5235    ));
5236    Ok(true)
5237}
5238
5239#[allow(clippy::too_many_arguments)]
5240fn push_index_blob_worktree_diff(
5241    odb: &Odb,
5242    ie: &IndexEntry,
5243    path_str_ref: &str,
5244    file_path: &Path,
5245    meta: fs::Metadata,
5246    index_mtime: Option<(u32, u32)>,
5247    mode_policy: crate::index::WorktreeModePolicy,
5248    stat_policy: crate::index::StatMatchPolicy,
5249    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
5250    conv: &crate::crlf::ConversionConfig,
5251    materialize_dirty_blobs: bool,
5252    hash_blobs_without_write: bool,
5253    for_status: bool,
5254    filter_process: Option<&crate::filter_process::FilterProcessState>,
5255    icase_collisions: Option<&crate::path_icase::IcaseCheckoutCollisionMap>,
5256    result: &mut Vec<DiffEntry>,
5257) -> Result<()> {
5258    if meta.is_dir() {
5259        if file_path.join(".git").exists() {
5260            let head = read_submodule_head_oid(file_path).unwrap_or_else(zero_oid);
5261            let path_owned = path_str_ref.to_owned();
5262            result.push(DiffEntry {
5263                status: DiffStatus::TypeChanged,
5264                old_path: Some(path_owned.clone()),
5265                new_path: Some(path_owned),
5266                old_mode: FileMode::new(ie.mode),
5267                new_mode: FileMode::new(0o160000),
5268                old_oid: ie.oid,
5269                new_oid: head,
5270                score: None,
5271            });
5272            return Ok(());
5273        }
5274        result.push(DiffEntry {
5275            status: DiffStatus::Deleted,
5276            old_path: Some(path_str_ref.to_owned()),
5277            new_path: None,
5278            old_mode: FileMode::new(ie.mode),
5279            new_mode: FileMode::ABSENT,
5280            old_oid: ie.oid,
5281            new_oid: zero_oid(),
5282            score: None,
5283        });
5284        return Ok(());
5285    }
5286
5287    let worktree_mode =
5288        mode_policy.index_mode_for_worktree(mode_from_metadata(&meta), Some(ie.mode));
5289    let stat_same = stat_matches_with_policy(ie, &meta, stat_policy);
5290    if stat_same && worktree_mode != ie.mode {
5291        result.push(worktree_content_change_entry(
5292            ie,
5293            path_str_ref,
5294            worktree_mode,
5295            ie.oid,
5296        ));
5297        return Ok(());
5298    }
5299
5300    if stat_same
5301        && index_entry_skips_worktree_content_hash_after_stat_match(ie, index_mtime, stat_policy)
5302    {
5303        return Ok(());
5304    }
5305
5306    let file_attrs = file_attrs_for(path_str_ref);
5307    if !icase_collisions.is_some_and(|map| map.diff_requires_worktree_hash(ie.path.as_slice()))
5308        && try_push_status_modified_on_size_mismatch(
5309            odb,
5310            ie,
5311            path_str_ref,
5312            crate::index::munge_index_size(meta.len()),
5313            worktree_mode,
5314            meta.len(),
5315            for_status,
5316            || crate::crlf::git_conversion_is_identity(conv, &file_attrs),
5317            result,
5318        )?
5319    {
5320        return Ok(());
5321    }
5322
5323    let store = (materialize_dirty_blobs && !hash_blobs_without_write)
5324        .then(|| odb.bulk_stage_write_options());
5325    let stream_from = if store.is_some() {
5326        odb.big_file_threshold()?
5327    } else {
5328        hash_stream_threshold(odb)?
5329    };
5330    let content = match worktree_blob_content_from(
5331        odb,
5332        file_path,
5333        &meta,
5334        conv,
5335        &file_attrs,
5336        path_str_ref,
5337        Some(ie),
5338        filter_process,
5339        stream_from,
5340    ) {
5341        Ok(content) => content,
5342        Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
5343            result.push(worktree_content_change_entry(
5344                ie,
5345                path_str_ref,
5346                worktree_mode,
5347                zero_oid(),
5348            ));
5349            return Ok(());
5350        }
5351        Err(err) => return Err(err),
5352    };
5353    let eff_oid = content.object_id(odb, store)?;
5354
5355    if eff_oid != ie.oid || worktree_mode != ie.mode {
5356        // `git diff-files --raw` uses the null OID for the worktree side when content differs.
5357        // Staging (`diff_index_to_worktree_for_staging`) materializes blobs and needs the real OID.
5358        let new_oid = if eff_oid != ie.oid && !materialize_dirty_blobs {
5359            zero_oid()
5360        } else {
5361            eff_oid
5362        };
5363        result.push(worktree_content_change_entry(
5364            ie,
5365            path_str_ref,
5366            worktree_mode,
5367            new_oid,
5368        ));
5369    }
5370    Ok(())
5371}
5372
5373/// Compare tracked index entries with the worktree, using link-target bytes for symlinks.
5374///
5375/// Symlink targets bypass regular-file filters and line-ending conversion. Parallel and
5376/// serial scans use the same file-type classification, including directory replacements
5377/// and regular-file placeholders checked out with `core.symlinks=false`.
5378///
5379/// # Errors
5380///
5381/// Returns errors from I/O or hashing.
5382pub fn diff_index_to_worktree_with_options(
5383    odb: &Odb,
5384    index: &mut Index,
5385    work_tree: &Path,
5386    options: DiffIndexToWorktreeOptions,
5387) -> Result<(Vec<DiffEntry>, bool)> {
5388    let default_git_dir = work_tree.join(".git");
5389    let git_dir = options
5390        .repository_git_dir
5391        .clone()
5392        .unwrap_or(default_git_dir);
5393    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false, None)
5394        .map(|(entries, changed, _)| (entries, changed))
5395}
5396
5397/// Like [`diff_index_to_worktree_with_options`], with an owner-thread progress poll hook for
5398/// long stat scans (used by [`crate::porcelain::status::status`]).
5399pub(crate) fn diff_index_to_worktree_with_owner_poll(
5400    odb: &Odb,
5401    index: &mut Index,
5402    work_tree: &Path,
5403    options: DiffIndexToWorktreeOptions,
5404    owner_poll: Option<std::sync::Arc<crate::progress::StackCancelPoll>>,
5405) -> Result<(Vec<DiffEntry>, bool)> {
5406    let default_git_dir = work_tree.join(".git");
5407    let git_dir = options
5408        .repository_git_dir
5409        .clone()
5410        .unwrap_or(default_git_dir);
5411    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false, owner_poll)
5412        .map(|(entries, changed, _)| (entries, changed))
5413}
5414
5415/// Like [`diff_index_to_worktree_with_options`], but loads config from `repository_git_dir`
5416/// (the main repository for linked worktrees) and persists each hashed worktree blob into `odb`
5417/// so staging can update the index without re-reading file contents.
5418/// Symlinks store their target bytes. With [`DiffIndexToWorktreeOptions::hash_blobs_without_write`],
5419/// returns the same blob IDs without writing objects, for staging previews.
5420pub fn diff_index_to_worktree_for_staging(
5421    odb: &Odb,
5422    repository_git_dir: &Path,
5423    index: &mut Index,
5424    work_tree: &Path,
5425    options: DiffIndexToWorktreeOptions,
5426) -> Result<(
5427    Vec<DiffEntry>,
5428    bool,
5429    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
5430)> {
5431    diff_index_to_worktree_inner(
5432        odb,
5433        index,
5434        work_tree,
5435        repository_git_dir,
5436        options,
5437        true,
5438        None,
5439    )
5440}
5441
5442/// First pass of [`diff_index_to_worktree_inner`]: gitlinks and intent-to-add paths only.
5443#[allow(clippy::too_many_arguments)]
5444fn worktree_diff_pass1_index_entry(
5445    odb: &Odb,
5446    work_tree: &Path,
5447    ie: &IndexEntry,
5448    path_str_ref: &str,
5449    simplify_gitlinks: bool,
5450    ignore_submodule_untracked: bool,
5451    error_on_broken_gitlinks: bool,
5452    materialize_dirty_blobs: bool,
5453    filter_fp: Option<&crate::filter_process::FilterProcessState>,
5454    conv: &crate::crlf::ConversionConfig,
5455    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
5456    precompose_unicode: bool,
5457    ignorecase: bool,
5458    result: &mut Vec<DiffEntry>,
5459) -> Result<()> {
5460    if ie.skip_worktree() || ie.assume_unchanged() {
5461        return Ok(());
5462    }
5463    let is_intent_to_add = ie.intent_to_add();
5464
5465    if ie.mode == 0o160000 {
5466        let sub_dir =
5467            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
5468        match fs::symlink_metadata(&sub_dir) {
5469            Err(e)
5470                if e.kind() == std::io::ErrorKind::NotFound
5471                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5472            {
5473                if !simplify_gitlinks {
5474                    let path_owned = path_str_ref.to_owned();
5475                    result.push(DiffEntry {
5476                        status: DiffStatus::Deleted,
5477                        old_path: Some(path_owned.clone()),
5478                        new_path: Some(path_owned),
5479                        old_mode: FileMode::new(ie.mode),
5480                        new_mode: FileMode::ABSENT,
5481                        old_oid: ie.oid,
5482                        new_oid: zero_oid(),
5483                        score: None,
5484                    });
5485                }
5486                return Ok(());
5487            }
5488            Err(e) => return Err(Error::Io(e)),
5489            Ok(meta) if !meta.is_dir() => {
5490                let worktree_mode = mode_from_metadata(&meta);
5491                let file_attrs = file_attrs_for(path_str_ref);
5492                let worktree_oid = worktree_file_oid(
5493                    odb,
5494                    &sub_dir,
5495                    &meta,
5496                    conv,
5497                    &file_attrs,
5498                    path_str_ref,
5499                    None,
5500                    materialize_dirty_blobs,
5501                    filter_fp,
5502                )?;
5503                let path_owned = path_str_ref.to_owned();
5504                result.push(DiffEntry {
5505                    status: DiffStatus::TypeChanged,
5506                    old_path: Some(path_owned.clone()),
5507                    new_path: Some(path_owned),
5508                    old_mode: FileMode::new(ie.mode),
5509                    new_mode: FileMode::new(worktree_mode),
5510                    old_oid: ie.oid,
5511                    new_oid: worktree_oid,
5512                    score: None,
5513                });
5514                return Ok(());
5515            }
5516            Ok(_) => {}
5517        }
5518        let sub_head_oid = read_submodule_head_oid(&sub_dir);
5519        let ref_matches = if let Some(oid) = sub_head_oid {
5520            oid == ie.oid
5521        } else {
5522            if error_on_broken_gitlinks && submodule_embedded_git_dir(&sub_dir).is_some() {
5523                return Err(Error::Config(
5524                    format!("could not read submodule HEAD for '{path_str_ref}'").into(),
5525                ));
5526            }
5527            uninitialized_gitlink_worktree_matches_index(&sub_dir)
5528        };
5529        if simplify_gitlinks {
5530            if !ref_matches {
5531                let path_owned = path_str_ref.to_owned();
5532                let new_oid = sub_head_oid.unwrap_or_else(zero_oid);
5533                result.push(DiffEntry {
5534                    status: DiffStatus::Modified,
5535                    old_path: Some(path_owned.clone()),
5536                    new_path: Some(path_owned),
5537                    old_mode: FileMode::new(ie.mode),
5538                    new_mode: FileMode::new(ie.mode),
5539                    old_oid: ie.oid,
5540                    new_oid,
5541                    score: None,
5542                });
5543            }
5544            return Ok(());
5545        }
5546        if sub_head_oid.is_some() && submodule_head_object_broken(&sub_dir) {
5547            return Err(Error::Config(
5548                format!("'git status --porcelain=2' failed in submodule {path_str_ref}").into(),
5549            ));
5550        }
5551        let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
5552        if ignore_submodule_untracked {
5553            flags.untracked = false;
5554        }
5555        let inner_dirty = flags.modified || flags.untracked;
5556        if !ref_matches || inner_dirty {
5557            let path_owned = path_str_ref.to_owned();
5558            let new_oid = if !ref_matches {
5559                sub_head_oid.unwrap_or_else(zero_oid)
5560            } else {
5561                zero_oid()
5562            };
5563            result.push(DiffEntry {
5564                status: DiffStatus::Modified,
5565                old_path: Some(path_owned.clone()),
5566                new_path: Some(path_owned),
5567                old_mode: FileMode::new(ie.mode),
5568                new_mode: FileMode::new(ie.mode),
5569                old_oid: ie.oid,
5570                new_oid,
5571                score: None,
5572            });
5573        }
5574        return Ok(());
5575    }
5576
5577    let file_path =
5578        index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
5579    let path_match = crate::repo_relpath::as_utf8_for_matching(path_str_ref);
5580
5581    if is_intent_to_add {
5582        match fs::symlink_metadata(&file_path) {
5583            Ok(meta) => {
5584                let file_attrs = file_attrs_for(path_match.as_ref());
5585                let worktree_oid = worktree_file_oid(
5586                    odb,
5587                    &file_path,
5588                    &meta,
5589                    conv,
5590                    &file_attrs,
5591                    path_match.as_ref(),
5592                    None,
5593                    materialize_dirty_blobs,
5594                    filter_fp,
5595                )?;
5596                let worktree_mode = mode_from_metadata(&meta);
5597                result.push(DiffEntry {
5598                    status: DiffStatus::Added,
5599                    old_path: None,
5600                    new_path: Some(path_str_ref.to_owned()),
5601                    old_mode: FileMode::ABSENT,
5602                    new_mode: FileMode::new(worktree_mode),
5603                    old_oid: zero_oid(),
5604                    new_oid: worktree_oid,
5605                    score: None,
5606                });
5607            }
5608            Err(e)
5609                if e.kind() == std::io::ErrorKind::NotFound
5610                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5611            {
5612                result.push(DiffEntry {
5613                    status: DiffStatus::Deleted,
5614                    old_path: Some(path_str_ref.to_owned()),
5615                    new_path: None,
5616                    old_mode: FileMode::new(ie.mode),
5617                    new_mode: FileMode::ABSENT,
5618                    old_oid: ie.oid,
5619                    new_oid: zero_oid(),
5620                    score: None,
5621                });
5622            }
5623            Err(e) => return Err(Error::Io(e)),
5624        }
5625    }
5626    Ok(())
5627}
5628
5629#[inline]
5630fn index_entry_needs_worktree_diff_pass1(ie: &IndexEntry) -> bool {
5631    ie.mode == crate::index::MODE_GITLINK || ie.intent_to_add()
5632}
5633
5634fn diff_index_to_worktree_inner(
5635    odb: &Odb,
5636    index: &mut Index,
5637    work_tree: &Path,
5638    repository_git_dir: &Path,
5639    options: DiffIndexToWorktreeOptions,
5640    materialize_dirty_blobs: bool,
5641    owner_poll: Option<std::sync::Arc<crate::progress::StackCancelPoll>>,
5642) -> Result<(
5643    Vec<DiffEntry>,
5644    bool,
5645    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
5646)> {
5647    use crate::config::ConfigSet;
5648    use crate::crlf;
5649    use std::collections::BTreeMap;
5650
5651    let mut staging_stat_cache = BTreeMap::new();
5652    poll_diff_cancel(&options, owner_poll.as_ref())?;
5653    let index_mtime = index_mtime_for_diff(index, options.index_mtime);
5654
5655    let ignore_submodule_untracked = options.ignore_submodule_untracked;
5656    let simplify_gitlinks = options.simplify_gitlinks;
5657
5658    let rules_lock = options
5659        .worktree_rules
5660        .as_ref()
5661        .map(|shared| {
5662            shared
5663                .lock()
5664                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
5665        })
5666        .transpose()?;
5667    let config = if let Some(rules) = rules_lock.as_ref() {
5668        rules.config().clone()
5669    } else if let Some(c) = options.config.as_ref() {
5670        c.as_ref().clone()
5671    } else {
5672        ConfigSet::load(
5673            &crate::environment::Environment::empty(),
5674            Some(repository_git_dir),
5675            true,
5676        )
5677        .unwrap_or_else(|_| ConfigSet::new())
5678    };
5679    let conv = rules_lock
5680        .as_ref()
5681        .map(|r| r.conversion().clone())
5682        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
5683    let legacy_attrs = if rules_lock.is_none() {
5684        Some(crlf::load_gitattributes_parsed(work_tree))
5685    } else {
5686        None
5687    };
5688    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
5689        if let Some(rules) = rules_lock.as_ref() {
5690            rules.file_attrs(path, false)
5691        } else if let Some(parsed) = legacy_attrs.as_ref() {
5692            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
5693        } else {
5694            crlf::FileAttrs::default()
5695        }
5696    };
5697    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
5698    let precompose_unicode = config
5699        .get_bool("core.precomposeunicode")
5700        .and_then(|r| r.ok())
5701        .unwrap_or(false);
5702    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
5703    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
5704    let ignorecase = config
5705        .get_bool("core.ignorecase")
5706        .and_then(|r| r.ok())
5707        .unwrap_or(false);
5708    let icase_collisions = ignorecase
5709        .then(|| crate::path_icase::IcaseCheckoutCollisionMap::from_index(work_tree, index, true));
5710    let hash_blobs_without_write = options.hash_blobs_without_write;
5711
5712    let mut result = Vec::new();
5713    let refresh_index_stat_in_pass = options.refresh_index_stat_in_pass;
5714    let mut index_stat_refresh_changed = false;
5715    let mut honor_fsmonitor_valid = false;
5716    if options.refresh_fsmonitor {
5717        let default_runner = crate::command_runner::system_command_runner();
5718        let runner = options
5719            .command_runner
5720            .as_deref()
5721            .unwrap_or(default_runner.as_ref());
5722        let boundary = options.fsmonitor_query_boundary.unwrap_or_else(|| {
5723            crate::fsmonitor::FsmonitorQueryBoundary(
5724                crate::environment::wall_clock_unix_timestamp_nanos(),
5725            )
5726        });
5727        let refresh_opts = crate::fsmonitor::FsmonitorRefreshOptions::with_query_boundary(boundary);
5728        let refresh = crate::fsmonitor::refresh_index(
5729            index,
5730            repository_git_dir,
5731            work_tree,
5732            &config,
5733            Some(runner),
5734            &refresh_opts,
5735        )?;
5736        if refresh.index_changed {
5737            index_stat_refresh_changed = true;
5738        }
5739        honor_fsmonitor_valid = refresh.honor_valid_bits;
5740    }
5741    let use_fsmonitor_stat_skip = honor_fsmonitor_valid;
5742    let mut unmerged_base: std::collections::BTreeMap<String, (u8, IndexEntry)> =
5743        std::collections::BTreeMap::new();
5744
5745    let error_on_broken_gitlinks = options.error_on_broken_gitlinks;
5746    if options.restrict_to_paths.is_none() {
5747        for ie in &index.entries {
5748            poll_diff_cancel(&options, owner_poll.as_ref())?;
5749            if ie.stage() != 0 {
5750                let path = String::from_utf8_lossy(&ie.path).to_string();
5751                let rank = match ie.stage() {
5752                    2 => 0u8,
5753                    3 => 1u8,
5754                    1 => 2u8,
5755                    _ => 3u8,
5756                };
5757                match unmerged_base.get(&path) {
5758                    Some((existing_rank, _)) if *existing_rank <= rank => {}
5759                    _ => {
5760                        unmerged_base.insert(path, (rank, ie.clone()));
5761                    }
5762                }
5763            }
5764        }
5765    }
5766
5767    if let Some(limit) = options.restrict_to_paths.as_ref() {
5768        for path in limit.iter() {
5769            let Some(ie) = index.get(path.as_bytes(), 0) else {
5770                continue;
5771            };
5772            if !index_entry_needs_worktree_diff_pass1(ie) {
5773                continue;
5774            }
5775            worktree_diff_pass1_index_entry(
5776                odb,
5777                work_tree,
5778                ie,
5779                path.as_str(),
5780                simplify_gitlinks,
5781                ignore_submodule_untracked,
5782                error_on_broken_gitlinks,
5783                materialize_dirty_blobs,
5784                filter_fp,
5785                &conv,
5786                &file_attrs_for,
5787                precompose_unicode,
5788                ignorecase,
5789                &mut result,
5790            )?;
5791        }
5792    } else {
5793        for ie in &index.entries {
5794            poll_diff_cancel(&options, owner_poll.as_ref())?;
5795            if ie.stage() != 0 {
5796                continue;
5797            }
5798            if !index_entry_needs_worktree_diff_pass1(ie) {
5799                continue;
5800            }
5801            let path_stored = crate::repo_relpath::storage_string(&ie.path);
5802            worktree_diff_pass1_index_entry(
5803                odb,
5804                work_tree,
5805                ie,
5806                &path_stored,
5807                simplify_gitlinks,
5808                ignore_submodule_untracked,
5809                error_on_broken_gitlinks,
5810                materialize_dirty_blobs,
5811                filter_fp,
5812                &conv,
5813                &file_attrs_for,
5814                precompose_unicode,
5815                ignorecase,
5816                &mut result,
5817            )?;
5818        }
5819    }
5820
5821    use crate::worktree_scan::{
5822        collect_blob_lookup_batches_with_literal_paths, group_blob_entries_by_dir,
5823        stat_scan_thread_count, visit_blob_lookup_batches, BlobDiskLookup, BlobScanSparseMode,
5824        WorktreeBlobScanOptions, PARALLEL_STAT_MIN_ENTRIES,
5825    };
5826    let preload_index = config
5827        .get_bool("core.preloadIndex")
5828        .and_then(|r| r.ok())
5829        .unwrap_or(true);
5830    let blob_scan = WorktreeBlobScanOptions {
5831        preload_index,
5832        stat_parallel_threads: options.stat_parallel_threads,
5833    };
5834    let blob_dirs = if let Some(limit) = options.restrict_to_paths.as_ref() {
5835        crate::worktree_scan::group_blob_entries_by_dir_for_paths(index, limit)
5836    } else {
5837        group_blob_entries_by_dir(index, honor_fsmonitor_valid)
5838    };
5839    let work_tree_owned = work_tree.to_path_buf();
5840    let dir_abs = move |dir: &[u8]| {
5841        if dir.is_empty() {
5842            work_tree_owned.clone()
5843        } else {
5844            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
5845        }
5846    };
5847    let work_tree_for_files = work_tree.to_path_buf();
5848    let file_abs = move |rel: &[u8]| {
5849        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
5850    };
5851    let blob_count = blob_dirs.len();
5852    let thread_policy = options.thread_policy.unwrap_or_default();
5853    let hash_threads = stat_scan_thread_count(&thread_policy, &config, blob_scan, blob_count);
5854    let parallel_blob_work = blob_count >= PARALLEL_STAT_MIN_ENTRIES && hash_threads > 1;
5855    let mut staging_hash_jobs: Vec<StagingHashJob> = Vec::new();
5856    // Entries the stat refresh just hashed and found matching their recorded blob.
5857    let mut refresh_content_verified = vec![false; index.entries.len()];
5858    // Parallel preload must lstat ancestor directories without following symlinks. When a
5859    // tracked directory is replaced by a symlink, indexed paths under it are deletions even if
5860    // the link target still holds a file at the same relative path with an unchanged stat.
5861    // This is independent of `core.symlinks` (checkout support); detection must stay enabled.
5862    let sparse = if parallel_blob_work {
5863        BlobScanSparseMode::StatusDiff {
5864            stat_policy,
5865            index_mtime,
5866            mode_policy,
5867            for_status: options.for_status,
5868            refresh_index_stat_in_pass,
5869            parallel_blob_work,
5870            honor_fsmonitor_stat_skip: use_fsmonitor_stat_skip,
5871        }
5872    } else {
5873        BlobScanSparseMode::Dense
5874    };
5875    let blob_stat_batches = collect_blob_lookup_batches_with_literal_paths(
5876        index,
5877        &blob_dirs,
5878        blob_scan,
5879        &thread_policy,
5880        &config,
5881        dir_abs,
5882        file_abs,
5883        !precompose_unicode && !ignorecase,
5884        || {
5885            let mut dir_symlinks = SymlinkDirCache::default();
5886            move |rel_path: &[u8]| {
5887                dir_symlinks.has_symlink_in_path_bytes(
5888                    work_tree,
5889                    rel_path,
5890                    precompose_unicode,
5891                    ignorecase,
5892                )
5893            }
5894        },
5895        sparse,
5896        options.cancel_watch.clone(),
5897        owner_poll.clone(),
5898    )?;
5899    // Size-changed entries ask whether their clean conversion is the identity in both the stat
5900    // refresh and the visit below; resolve each one's attributes once.
5901    let identity_by_entry = std::cell::RefCell::new(std::collections::HashMap::new());
5902    let conversion_is_identity = |entry_index: usize, path: &str| -> bool {
5903        *identity_by_entry
5904            .borrow_mut()
5905            .entry(entry_index)
5906            .or_insert_with(|| crlf::git_conversion_is_identity(&conv, &file_attrs_for(path)))
5907    };
5908    let refresh_checks = if refresh_index_stat_in_pass {
5909        let par_opts =
5910            refresh_hash_parallel_options(&thread_policy, &config, options.stat_parallel_threads);
5911        refresh_content_checks(
5912            odb,
5913            index,
5914            &blob_stat_batches,
5915            index_mtime,
5916            stat_policy,
5917            &|entry_index, ie, stat| {
5918                !(use_fsmonitor_stat_skip && crate::fsmonitor::entry_skips_worktree_stat(ie, index))
5919                    && worktree_diff_refreshes_entry(
5920                        ie,
5921                        stat,
5922                        index_mtime,
5923                        stat_policy,
5924                        options.for_status,
5925                        || {
5926                            conversion_is_identity(
5927                                entry_index,
5928                                &crate::repo_relpath::storage_string(&ie.path),
5929                            )
5930                        },
5931                    )
5932            },
5933            &WorktreeHashContext {
5934                work_tree,
5935                precompose_unicode,
5936                ignorecase,
5937                conv: &conv,
5938                file_attrs_for: &file_attrs_for,
5939                filter_process: filter_fp,
5940            },
5941            par_opts,
5942            options.cancel_watch.as_ref(),
5943        )?
5944    } else {
5945        RefreshChecks::default()
5946    };
5947    poll_diff_cancel(&options, owner_poll.as_ref())?;
5948    visit_blob_lookup_batches(blob_stat_batches, |entry_index, lookup| {
5949        poll_diff_cancel(&options, owner_poll.as_ref())?;
5950        let path_owned = crate::repo_relpath::storage_string(&index.entries[entry_index].path);
5951        let path_str_ref = path_owned.as_str();
5952        if use_fsmonitor_stat_skip {
5953            let ie_skip = &index.entries[entry_index];
5954            if crate::fsmonitor::entry_skips_worktree_stat(ie_skip, index) {
5955                return Ok(());
5956            }
5957        }
5958        match lookup {
5959            BlobDiskLookup::Missing => {
5960                if icase_collisions.as_ref().is_some_and(|map| {
5961                    map.preserve_missing_collision_loser(&index.entries[entry_index].path)
5962                }) {
5963                    return Ok(());
5964                }
5965                let ie = &index.entries[entry_index];
5966                result.push(DiffEntry {
5967                    status: DiffStatus::Deleted,
5968                    old_path: Some(path_str_ref.to_owned()),
5969                    new_path: None,
5970                    old_mode: FileMode::new(ie.mode),
5971                    new_mode: FileMode::ABSENT,
5972                    old_oid: ie.oid,
5973                    new_oid: zero_oid(),
5974                    score: None,
5975                });
5976            }
5977            BlobDiskLookup::Io(e) => return Err(Error::Io(e)),
5978            BlobDiskLookup::Present(stat) => {
5979                if materialize_dirty_blobs {
5980                    staging_stat_cache.insert(path_str_ref.to_owned(), stat.fields);
5981                }
5982                if refresh_index_stat_in_pass
5983                    && worktree_diff_refreshes_entry(
5984                        &index.entries[entry_index],
5985                        &stat,
5986                        index_mtime,
5987                        stat_policy,
5988                        options.for_status,
5989                        || conversion_is_identity(entry_index, path_str_ref),
5990                    )
5991                {
5992                    let content_matches = RefreshContent::matches(
5993                        refresh_checks.get(entry_index),
5994                        &index.entries[entry_index].oid,
5995                    );
5996                    let refresh = refresh_index_blob_stat_from_scan(
5997                        &mut index.entries[entry_index],
5998                        &stat,
5999                        index_mtime,
6000                        stat_policy,
6001                        content_matches,
6002                    );
6003                    if refresh.index_changed {
6004                        index_stat_refresh_changed = true;
6005                    }
6006                    if refresh.content_verified {
6007                        refresh_content_verified[entry_index] = true;
6008                        let path = index.entries[entry_index].path.clone();
6009                        let oid = index.entries[entry_index].oid;
6010                        index.mark_content_verified(&path, oid);
6011                    }
6012                    if index.fsmonitor_last_update.is_some()
6013                        && stat_matches_blob_stat(&index.entries[entry_index], &stat, stat_policy)
6014                        && !refresh.index_changed
6015                    {
6016                        index.entries[entry_index].set_fsmonitor_valid(true);
6017                    }
6018                }
6019                let ie = &index.entries[entry_index];
6020                let worktree_mode =
6021                    mode_policy.index_mode_for_worktree(stat.git_mode, Some(ie.mode));
6022                // The stat refresh already hashed this file and found other content, so do not
6023                // read it again. Staging hashes again because it needs the blob stored.
6024                if !materialize_dirty_blobs
6025                    && !stat.is_dir
6026                    && stat.git_mode != crate::index::MODE_SYMLINK
6027                    && RefreshContent::differs(refresh_checks.get(entry_index), &ie.oid)
6028                {
6029                    result.push(worktree_content_change_entry(
6030                        ie,
6031                        path_str_ref,
6032                        worktree_mode,
6033                        zero_oid(),
6034                    ));
6035                    return Ok(());
6036                }
6037                // Streaming jobs read regular files. Links and directories need the shared
6038                // classifier; use the physical type so core.symlinks=false placeholders
6039                // keep the regular-file fast path even with an effective symlink mode.
6040                if parallel_blob_work && !stat.is_dir && stat.git_mode != crate::index::MODE_SYMLINK
6041                {
6042                    let stat_same = stat_matches_blob_stat(ie, &stat, stat_policy);
6043                    if stat_same
6044                        && worktree_mode == ie.mode
6045                        && index_entry_skips_worktree_content_hash_after_stat_match(
6046                            ie,
6047                            index_mtime,
6048                            stat_policy,
6049                        )
6050                    {
6051                        return Ok(());
6052                    }
6053                    if stat_same && worktree_mode != ie.mode {
6054                        result.push(worktree_content_change_entry(
6055                            ie,
6056                            path_str_ref,
6057                            worktree_mode,
6058                            ie.oid,
6059                        ));
6060                    } else if !icase_collisions.as_ref().is_some_and(|map| {
6061                        map.diff_requires_worktree_hash(&index.entries[entry_index].path)
6062                    }) && try_push_status_modified_on_size_mismatch(
6063                        odb,
6064                        ie,
6065                        path_str_ref,
6066                        stat.worktree_size(),
6067                        worktree_mode,
6068                        stat.raw_size,
6069                        options.for_status,
6070                        || conversion_is_identity(entry_index, path_str_ref),
6071                        &mut result,
6072                    )? {
6073                    } else if !refresh_content_verified[entry_index] {
6074                        let file_path = index_entry_worktree_abs_bytes(
6075                            work_tree,
6076                            &index.entries[entry_index].path,
6077                            precompose_unicode,
6078                            ignorecase,
6079                        );
6080                        staging_hash_jobs.push(StagingHashJob {
6081                            entry_index,
6082                            rel_path: path_str_ref.to_owned(),
6083                            file_path,
6084                            entry: ie.clone(),
6085                            worktree_mode,
6086                            file_attrs: file_attrs_for(path_str_ref),
6087                        });
6088                    }
6089                } else {
6090                    // The scan's lstat already proves an unchanged, non-racy entry clean, and the
6091                    // stat refresh above may have just verified a racy entry by content; only
6092                    // re-stat paths that `push_index_blob_worktree_diff` has to inspect.
6093                    let same_file_type = (worktree_mode == crate::index::MODE_SYMLINK)
6094                        == (ie.mode == crate::index::MODE_SYMLINK);
6095                    if !stat.is_dir
6096                        && worktree_mode == ie.mode
6097                        && ((refresh_content_verified[entry_index] && same_file_type)
6098                            || (stat_matches_blob_stat(ie, &stat, stat_policy)
6099                                && index_entry_skips_worktree_content_hash_after_stat_match(
6100                                    ie,
6101                                    index_mtime,
6102                                    stat_policy,
6103                                )))
6104                    {
6105                        return Ok(());
6106                    }
6107                    let file_path = index_entry_worktree_abs_bytes(
6108                        work_tree,
6109                        &index.entries[entry_index].path,
6110                        precompose_unicode,
6111                        ignorecase,
6112                    );
6113                    let meta = fs::symlink_metadata(&file_path).map_err(Error::Io)?;
6114                    push_index_blob_worktree_diff(
6115                        odb,
6116                        ie,
6117                        path_str_ref,
6118                        &file_path,
6119                        meta,
6120                        index_mtime,
6121                        mode_policy,
6122                        stat_policy,
6123                        &file_attrs_for,
6124                        &conv,
6125                        materialize_dirty_blobs,
6126                        hash_blobs_without_write,
6127                        options.for_status,
6128                        filter_fp,
6129                        icase_collisions.as_ref(),
6130                        &mut result,
6131                    )?;
6132                }
6133            }
6134        }
6135        Ok(())
6136    })?;
6137    if parallel_blob_work && !staging_hash_jobs.is_empty() {
6138        parallel_process_blob_worktree_jobs(
6139            odb,
6140            &staging_hash_jobs,
6141            &thread_policy,
6142            &config,
6143            materialize_dirty_blobs,
6144            hash_blobs_without_write,
6145            &conv,
6146            filter_fp,
6147            icase_collisions.as_ref(),
6148            &mut result,
6149        )?;
6150    }
6151
6152    for (path, (_, base_entry)) in unmerged_base {
6153        let file_path = index_entry_worktree_abs(work_tree, &path, precompose_unicode, ignorecase);
6154        let wt_meta = match fs::symlink_metadata(&file_path) {
6155            Ok(meta) => Some(meta),
6156            Err(e)
6157                if e.kind() == std::io::ErrorKind::NotFound
6158                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
6159            {
6160                None
6161            }
6162            Err(e) => return Err(Error::Io(e)),
6163        };
6164
6165        let new_mode = wt_meta.as_ref().map_or_else(
6166            || FileMode::ABSENT,
6167            |meta| FileMode::new(mode_from_metadata(meta)),
6168        );
6169        result.push(DiffEntry {
6170            status: DiffStatus::Unmerged,
6171            old_path: Some(path.clone()),
6172            new_path: Some(path.clone()),
6173            old_mode: FileMode::ABSENT,
6174            new_mode,
6175            old_oid: zero_oid(),
6176            new_oid: zero_oid(),
6177            score: None,
6178        });
6179
6180        if !options.for_status {
6181            if let Some(meta) = wt_meta {
6182                let file_attrs = file_attrs_for(&path);
6183                let wt_oid = worktree_file_oid(
6184                    odb,
6185                    &file_path,
6186                    &meta,
6187                    &conv,
6188                    &file_attrs,
6189                    &path,
6190                    Some(&base_entry),
6191                    materialize_dirty_blobs,
6192                    filter_fp,
6193                )?;
6194                let wt_mode = mode_from_metadata(&meta);
6195                if wt_oid != base_entry.oid || wt_mode != base_entry.mode {
6196                    result.push(DiffEntry {
6197                        status: DiffStatus::for_content_change(base_entry.mode, wt_mode),
6198                        old_path: Some(path.clone()),
6199                        new_path: Some(path),
6200                        old_mode: FileMode::new(base_entry.mode),
6201                        new_mode: FileMode::new(wt_mode),
6202                        old_oid: base_entry.oid,
6203                        new_oid: wt_oid,
6204                        score: None,
6205                    });
6206                }
6207            }
6208        }
6209    }
6210
6211    Ok((result, index_stat_refresh_changed, staging_stat_cache))
6212}
6213
6214/// Per-worker cache answering "is any ancestor directory of this index path a symlink?".
6215///
6216/// It remembers the last chain of ancestors verified to be real directories, plus the first
6217/// ancestor found not to be one. Index paths arrive in sorted order, so consecutive queries
6218/// share most of their leading directories and only the differing components are probed.
6219/// Each scan worker owns its own cache: there is no locking and no per-directory allocation.
6220#[derive(Default)]
6221pub(crate) struct SymlinkDirCache {
6222    /// Leading directory (raw index bytes, no trailing `/`) whose every component is a real
6223    /// directory on disk.
6224    real_dirs: Vec<u8>,
6225    /// First ancestor found not to be a real directory, and whether it is a symlink. Every
6226    /// path under it shares that answer.
6227    blocked: Option<(Vec<u8>, bool)>,
6228    /// Single-walk check for directories several components below `real_dirs`.
6229    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
6230    chain: Option<crate::worktree_scan::RealDirChain>,
6231    /// Filesystem queries made so far.
6232    #[cfg(test)]
6233    fs_queries: usize,
6234}
6235
6236/// Whether `path` is `dir` itself or lies under it (`dir` has no trailing `/`).
6237fn path_within_dir(path: &[u8], dir: &[u8]) -> bool {
6238    path.starts_with(dir) && path.get(dir.len()).is_none_or(|&b| b == b'/')
6239}
6240
6241/// Length of the longest leading run of whole components shared by two directory paths.
6242fn common_dir_prefix_len(a: &[u8], b: &[u8]) -> usize {
6243    let same = a.iter().zip(b).take_while(|(x, y)| x == y).count();
6244    let ends_component = |p: &[u8]| p.get(same).is_none_or(|&c| c == b'/');
6245    if ends_component(a) && ends_component(b) {
6246        return same;
6247    }
6248    a[..same].iter().rposition(|&c| c == b'/').unwrap_or(0)
6249}
6250
6251pub(crate) fn index_entry_worktree_abs(
6252    work_tree: &Path,
6253    index_relpath: &str,
6254    precompose_unicode: bool,
6255    ignorecase: bool,
6256) -> PathBuf {
6257    index_entry_worktree_abs_bytes(
6258        work_tree,
6259        index_relpath.as_bytes(),
6260        precompose_unicode,
6261        ignorecase,
6262    )
6263}
6264
6265pub(crate) fn index_entry_worktree_abs_bytes(
6266    work_tree: &Path,
6267    index_relpath: &[u8],
6268    precompose_unicode: bool,
6269    ignorecase: bool,
6270) -> PathBuf {
6271    if precompose_unicode || ignorecase {
6272        if let Ok(s) = std::str::from_utf8(index_relpath) {
6273            if precompose_unicode {
6274                return crate::unicode_normalization::resolve_worktree_path_for_staging(
6275                    work_tree, s, true,
6276                )
6277                .abs;
6278            }
6279            return crate::path_icase::worktree_path_for_index_entry(work_tree, s, ignorecase);
6280        }
6281    }
6282    crate::index::worktree_path_from_index_rel(work_tree, index_relpath)
6283}
6284
6285/// Effective index-file mtime for racy-git checks.
6286///
6287/// Uses an explicit override when provided, otherwise [`Index::source_mtime`] from the last
6288/// on-disk read.
6289#[must_use]
6290pub fn index_mtime_for_diff(index: &Index, explicit: Option<(u32, u32)>) -> Option<(u32, u32)> {
6291    explicit.or(index.source_mtime)
6292}
6293
6294impl SymlinkDirCache {
6295    /// Whether any parent directory of `rel_path` (raw index path bytes) is a symlink.
6296    ///
6297    /// Assumes the worktree does not change during the scan this cache serves. A missing
6298    /// ancestor, or one that is a file, is not a symlink and hides everything below it.
6299    pub(crate) fn has_symlink_in_path_bytes(
6300        &mut self,
6301        work_tree: &Path,
6302        rel_path: &[u8],
6303        precompose_unicode: bool,
6304        ignorecase: bool,
6305    ) -> bool {
6306        use crate::worktree_scan::{ancestor_kind, AncestorKind};
6307
6308        let Some(dir_len) = rel_path.iter().rposition(|&b| b == b'/') else {
6309            return false;
6310        };
6311        let dir = &rel_path[..dir_len];
6312        if let Some((blocked, is_symlink)) = &self.blocked {
6313            if path_within_dir(dir, blocked) {
6314                return *is_symlink;
6315            }
6316        }
6317        let verified = common_dir_prefix_len(dir, &self.real_dirs);
6318        self.real_dirs.truncate(verified);
6319        #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
6320        if !precompose_unicode
6321            && !ignorecase
6322            && dir[verified..]
6323                .split(|&b| b == b'/')
6324                .filter(|component| !component.is_empty())
6325                .nth(1)
6326                .is_some()
6327        {
6328            let chain = match &mut self.chain {
6329                Some(chain) if chain.work_tree() == work_tree => chain,
6330                slot => slot.insert(crate::worktree_scan::RealDirChain::open(work_tree)),
6331            };
6332            #[cfg(test)]
6333            {
6334                self.fs_queries += 1;
6335            }
6336            if chain.confirms(dir) {
6337                self.real_dirs.clear();
6338                self.real_dirs.extend_from_slice(dir);
6339                return false;
6340            }
6341        }
6342        let mut start = verified;
6343        while start < dir.len() {
6344            if dir[start] == b'/' {
6345                start += 1;
6346                continue;
6347            }
6348            let end = dir[start..]
6349                .iter()
6350                .position(|&b| b == b'/')
6351                .map_or(dir.len(), |i| start + i);
6352            let prefix = &dir[..end];
6353            let abs =
6354                index_entry_worktree_abs_bytes(work_tree, prefix, precompose_unicode, ignorecase);
6355            #[cfg(test)]
6356            {
6357                self.fs_queries += 1;
6358            }
6359            match ancestor_kind(&abs) {
6360                AncestorKind::Directory => {
6361                    let known = self.real_dirs.len();
6362                    self.real_dirs.extend_from_slice(&prefix[known..]);
6363                }
6364                AncestorKind::Symlink => {
6365                    self.blocked = Some((prefix.to_vec(), true));
6366                    return true;
6367                }
6368                AncestorKind::Other => {
6369                    self.blocked = Some((prefix.to_vec(), false));
6370                    return false;
6371                }
6372            }
6373            start = end;
6374        }
6375        false
6376    }
6377}
6378
6379/// Whether an index entry's cached mtime is at or after the on-disk index file's mtime.
6380///
6381/// Git labels such entries racy: stat data can look clean even though the worktree changed
6382/// within the same second the index was written. Callers pass the index file mtime sampled when
6383/// the index was read (before any rewrite). When `index_mtime` is `None`, returns `false`.
6384#[must_use]
6385pub fn entry_is_racy(ie: &IndexEntry, index_mtime: Option<(u32, u32)>) -> bool {
6386    entry_is_racy_with_policy(ie, index_mtime, crate::index::StatMatchPolicy::default())
6387}
6388
6389/// Like [`entry_is_racy`], honoring `core.usenanosec` for sub-second racy detection.
6390///
6391/// Sub-second racy detection applies only when stat equality also compares the fractions
6392/// (`core.usenanosec` with `core.checkstat=default`); otherwise a same-second, same-size edit
6393/// would be neither racy nor a stat mismatch.
6394#[must_use]
6395pub fn entry_is_racy_with_policy(
6396    ie: &IndexEntry,
6397    index_mtime: Option<(u32, u32)>,
6398    policy: crate::index::StatMatchPolicy,
6399) -> bool {
6400    let Some((index_mtime_sec, index_mtime_nsec)) = index_mtime else {
6401        return false;
6402    };
6403    if index_mtime_sec == 0 {
6404        return false;
6405    }
6406    if policy.use_nanosec && policy.check_stat {
6407        index_mtime_sec < ie.mtime_sec
6408            || (index_mtime_sec == ie.mtime_sec && index_mtime_nsec <= ie.mtime_nsec)
6409    } else {
6410        index_mtime_sec <= ie.mtime_sec
6411    }
6412}
6413
6414/// Clear racily-clean cache lines on entries whose blob no longer matches the worktree.
6415///
6416/// Before Git writes the index, it walks stage-0 file and symlink entries and sets the cached
6417/// size to zero when stat data still matches the worktree but content does not match the
6418/// recorded OID and the entry timestamp is racy relative to `index_mtime`. That forces the
6419/// next diff or status pass to re-hash the path instead of trusting stat alone.
6420///
6421/// Skip-worktree, assume-unchanged, and intent-to-add entries are left unchanged, and so are
6422/// entries this process just hashed from the worktree to their current OID (Git skips its
6423/// `CE_UPTODATE` entries the same way). When `index_mtime` is `None` (no prior on-disk index),
6424/// nothing is smudged.
6425///
6426/// Content equality uses `git_dir` config and worktree attributes with [`hash_worktree_file`]
6427/// so the repository hash algorithm and clean (CRLF/filter) conversion match index OIDs.
6428///
6429/// Returns `true` when at least one entry was smudged.
6430pub fn smudge_racily_clean_entries(
6431    odb: &Odb,
6432    git_dir: &Path,
6433    index: &mut Index,
6434    work_tree: &Path,
6435    index_mtime: Option<(u32, u32)>,
6436    config: Option<&ConfigSet>,
6437    filter_process: Option<&crate::filter_process::FilterProcessState>,
6438) -> bool {
6439    use crate::crlf;
6440    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
6441
6442    let Some(index_mtime) = index_mtime else {
6443        return false;
6444    };
6445
6446    let config = config.cloned().unwrap_or_else(|| {
6447        ConfigSet::load(
6448            &crate::environment::Environment::empty(),
6449            Some(git_dir),
6450            true,
6451        )
6452        .unwrap_or_default()
6453    });
6454    let conv = crlf::ConversionConfig::from_config(&config);
6455    let attrs = crlf::load_gitattributes(work_tree);
6456    let verified = std::mem::take(&mut index.content_verified);
6457
6458    let mut changed = false;
6459    for ie in &mut index.entries {
6460        let marked_verified = ie.take_content_verified();
6461        if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
6462            continue;
6463        }
6464        if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
6465            continue;
6466        }
6467        if ie.size == 0 {
6468            continue;
6469        }
6470        if !entry_is_racy(ie, Some(index_mtime)) {
6471            continue;
6472        }
6473        if marked_verified || verified.get(&ie.path) == Some(&ie.oid) {
6474            continue;
6475        }
6476        let Ok(path) = std::str::from_utf8(&ie.path) else {
6477            continue;
6478        };
6479        let abs = work_tree.join(path);
6480        let Ok(meta) = fs::symlink_metadata(&abs) else {
6481            continue;
6482        };
6483        if !stat_matches(ie, &meta) {
6484            continue;
6485        }
6486        let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
6487        let content_matches = hash_worktree_file(
6488            odb,
6489            &abs,
6490            &meta,
6491            &conv,
6492            &file_attrs,
6493            path,
6494            Some(ie),
6495            filter_process,
6496        )
6497        .map(|oid| oid == ie.oid)
6498        .unwrap_or(false);
6499        if content_matches {
6500            continue;
6501        }
6502        ie.size = 0;
6503        changed = true;
6504    }
6505    changed
6506}
6507/// Quick stat check: does the index entry's cached stat data match the file?
6508/// Returns true when the file at `ie`'s path differs from the index entry (mode or blob).
6509///
6510/// Used by commands such as `git mv` to detect "dirty" paths under sparse checkout.
6511/// Symlinks and submodules are compared in a Git-compatible way.
6512///
6513/// `ignore_submodule_untracked` mirrors [`diff_index_to_worktree`]'s same flag for gitlinks.
6514pub fn worktree_differs_from_index_entry(
6515    odb: &Odb,
6516    repository_git_dir: &Path,
6517    work_tree: &Path,
6518    ie: &IndexEntry,
6519    ignore_submodule_untracked: bool,
6520    config: Option<&ConfigSet>,
6521    filter_process: Option<&crate::filter_process::FilterProcessState>,
6522) -> Result<bool> {
6523    WorktreeEntryComparer::new(repository_git_dir, work_tree, config).differs(
6524        odb,
6525        ie,
6526        ignore_submodule_untracked,
6527        filter_process,
6528    )
6529}
6530
6531/// Repeated [`worktree_differs_from_index_entry`] checks against one worktree.
6532///
6533/// Loads config-derived settings and the root `.gitattributes` once instead of per entry.
6534pub(crate) struct WorktreeEntryComparer<'a> {
6535    work_tree: &'a Path,
6536    config: ConfigSet,
6537    mode_policy: crate::index::WorktreeModePolicy,
6538    precompose_unicode: bool,
6539    ignorecase: bool,
6540    conv: std::cell::OnceCell<crate::crlf::ConversionConfig>,
6541    attrs: std::cell::OnceCell<Vec<crate::crlf::AttrRule>>,
6542}
6543
6544impl<'a> WorktreeEntryComparer<'a> {
6545    /// Capture settings for `work_tree`; `config` falls back to the repository config files.
6546    pub(crate) fn new(
6547        repository_git_dir: &Path,
6548        work_tree: &'a Path,
6549        config: Option<&ConfigSet>,
6550    ) -> Self {
6551        let config = config.cloned().unwrap_or_else(|| {
6552            ConfigSet::load(
6553                &crate::environment::Environment::empty(),
6554                Some(repository_git_dir),
6555                true,
6556            )
6557            .unwrap_or_else(|_| ConfigSet::new())
6558        });
6559        let flag =
6560            |key: &str, default: bool| config.get_bool(key).and_then(|r| r.ok()).unwrap_or(default);
6561        Self {
6562            work_tree,
6563            mode_policy: crate::index::WorktreeModePolicy::from_config(&config),
6564            precompose_unicode: flag("core.precomposeunicode", false),
6565            ignorecase: flag("core.ignorecase", false),
6566            config,
6567            conv: std::cell::OnceCell::new(),
6568            attrs: std::cell::OnceCell::new(),
6569        }
6570    }
6571
6572    /// Same contract as [`worktree_differs_from_index_entry`].
6573    pub(crate) fn differs(
6574        &self,
6575        odb: &Odb,
6576        ie: &IndexEntry,
6577        ignore_submodule_untracked: bool,
6578        filter_process: Option<&crate::filter_process::FilterProcessState>,
6579    ) -> Result<bool> {
6580        use crate::crlf;
6581
6582        let work_tree = self.work_tree;
6583        let path_stored = crate::repo_relpath::storage_string(&ie.path);
6584        let path_str_ref = crate::repo_relpath::as_utf8_for_matching(&path_stored);
6585        let file_path = index_entry_worktree_abs_bytes(
6586            work_tree,
6587            &ie.path,
6588            self.precompose_unicode,
6589            self.ignorecase,
6590        );
6591
6592        if ie.mode == 0o160000 {
6593            let sub_head_oid = read_submodule_head(&file_path);
6594            let ref_matches = match sub_head_oid {
6595                Some(oid) => oid == ie.oid,
6596                None => uninitialized_gitlink_worktree_matches_index(&file_path),
6597            };
6598            let mut flags = submodule_porcelain_flags(work_tree, path_str_ref.as_ref(), ie.oid);
6599            if ignore_submodule_untracked {
6600                flags.untracked = false;
6601            }
6602            return Ok(!ref_matches || flags.modified || flags.untracked);
6603        }
6604
6605        let meta = match fs::symlink_metadata(&file_path) {
6606            Ok(m) => m,
6607            Err(e)
6608                if e.kind() == std::io::ErrorKind::NotFound
6609                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
6610            {
6611                return Ok(true);
6612            }
6613            Err(e) => return Err(Error::Io(e)),
6614        };
6615
6616        if meta.is_dir() {
6617            return Ok(true);
6618        }
6619
6620        if !self
6621            .mode_policy
6622            .worktree_mode_matches(mode_from_metadata(&meta), ie.mode)
6623        {
6624            return Ok(true);
6625        }
6626
6627        let conv = self
6628            .conv
6629            .get_or_init(|| crlf::ConversionConfig::from_config(&self.config));
6630        let attrs = self
6631            .attrs
6632            .get_or_init(|| crlf::load_gitattributes(work_tree));
6633        let file_attrs = crlf::get_file_attrs(attrs, path_str_ref.as_ref(), false, &self.config);
6634        let worktree_oid = hash_worktree_file(
6635            odb,
6636            &file_path,
6637            &meta,
6638            conv,
6639            &file_attrs,
6640            path_str_ref.as_ref(),
6641            Some(ie),
6642            filter_process,
6643        )?;
6644
6645        let mut eff_oid = worktree_oid;
6646        if eff_oid != ie.oid {
6647            if let Ok(raw) = fs::read(&file_path) {
6648                let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
6649                if raw_oid == ie.oid {
6650                    eff_oid = ie.oid;
6651                }
6652            }
6653        }
6654
6655        Ok(eff_oid != ie.oid)
6656    }
6657}
6658
6659/// Inputs for [`classify_worktree_entry_for_add`].
6660pub struct WorktreeAddRefreshParams<'a> {
6661    /// Object database used to verify blob OIDs when stat data is inconclusive.
6662    pub odb: &'a Odb,
6663    /// Index entry being compared.
6664    pub ie: &'a IndexEntry,
6665    /// `lstat` metadata for the worktree path.
6666    pub meta: &'a fs::Metadata,
6667    /// Absolute worktree path to the file.
6668    pub abs_path: &'a Path,
6669    /// Repository-relative path string.
6670    pub rel_path: &'a str,
6671    /// CRLF / filter conversion settings.
6672    pub conv: &'a crate::crlf::ConversionConfig,
6673    /// Per-path attribute state.
6674    pub file_attrs: &'a crate::crlf::FileAttrs,
6675    /// On-disk index mtime when the index was loaded; `None` skips racy checks.
6676    pub index_mtime: Option<(u32, u32)>,
6677    /// Mode that would be written, after [`crate::index::WorktreeModePolicy`] against `ie.mode`
6678    /// and any `--chmod` hint.
6679    pub staged_mode: u32,
6680    /// Repository-scoped filter-process registry (when staging through a [`crate::repo::Repository`]).
6681    pub filter_process: Option<&'a crate::filter_process::FilterProcessState>,
6682}
6683
6684/// Outcome of comparing a tracked index entry to its worktree path for staging refresh.
6685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6686pub enum WorktreeAddRefresh {
6687    /// Index already reflects the worktree; caller may skip staging.
6688    UpToDate,
6689    /// Executable bit differs while blob content is unchanged.
6690    ModeOnly {
6691        /// Mode to store in the index.
6692        mode: u32,
6693    },
6694    /// Blob OID and mode are unchanged; only cached stat fields should be refreshed.
6695    StatOnly,
6696    /// Content, type, or mode/blob mismatch requires a full restage (read, convert, hash).
6697    NeedsRestage,
6698}
6699
6700/// Whether stat-only fast paths may skip re-hashing during staging.
6701#[must_use]
6702pub(crate) fn index_stat_cache_trustworthy(index_mtime: Option<(u32, u32)>) -> bool {
6703    match index_mtime {
6704        None => false,
6705        Some((0, _)) => false,
6706        Some(_) => true,
6707    }
6708}
6709
6710/// When true, after worktree metadata has been read and matches the index entry, blob content
6711/// hashing may be skipped (Git `diff-files` / `update-index --refresh` style).
6712///
6713/// Does not permit skipping worktree observation; fsmonitor-valid entries use a separate path.
6714/// Independent of `core.filemode`: callers compare the executable bit separately.
6715///
6716/// A zero cached size is trusted only for the empty blob; any other blob with size zero was
6717/// smudged by a racy index write and must be re-hashed (Git `ce_match_stat_basic`).
6718#[must_use]
6719pub(crate) fn index_entry_skips_worktree_content_hash_after_stat_match(
6720    ie: &IndexEntry,
6721    index_mtime: Option<(u32, u32)>,
6722    stat_policy: crate::index::StatMatchPolicy,
6723) -> bool {
6724    if ie.stat_cache_unset() || (ie.size == 0 && !ie.oid.is_empty_blob()) {
6725        return false;
6726    }
6727    if !index_stat_cache_trustworthy(index_mtime) {
6728        return false;
6729    }
6730    !entry_is_racy_with_policy(ie, index_mtime, stat_policy)
6731}
6732
6733/// Decide whether staging must re-read and re-hash a tracked path.
6734///
6735/// Mirrors the fast paths in [`diff_index_to_worktree`] and Git's `refresh_index` /
6736/// `add_file_to_index`: trust matching stat when the entry is not racy and
6737/// `index_stat_cache_trustworthy` allows it; re-hash when stat matches but the entry is racy
6738/// or when stat differs; apply CRLF-aware hashing like staging.
6739///
6740/// Returns [`WorktreeAddRefresh::UpToDate`] when the index entry already matches the worktree and
6741/// no restaging is required; other variants describe mode/stat-only updates or a full restage.
6742///
6743/// # Errors
6744///
6745/// Returns [`Error::Io`] when the worktree file cannot be read for content verification.
6746pub fn classify_worktree_entry_for_add(
6747    params: &WorktreeAddRefreshParams<'_>,
6748) -> Result<WorktreeAddRefresh> {
6749    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
6750
6751    let ie = params.ie;
6752    let meta = params.meta;
6753    let staged_mode = params.staged_mode;
6754
6755    if ie.intent_to_add() {
6756        return Ok(WorktreeAddRefresh::NeedsRestage);
6757    }
6758
6759    let racy = entry_is_racy(ie, params.index_mtime);
6760    let stat_same = stat_matches(ie, meta);
6761    let trust_stat = index_stat_cache_trustworthy(params.index_mtime);
6762
6763    if ie.mode == MODE_SYMLINK {
6764        // With `core.symlinks=false` the policy keeps a plain file holding the link target as
6765        // a symlink entry, so it is compared by content like a real link.
6766        if !meta.file_type().is_symlink() && staged_mode != MODE_SYMLINK {
6767            return Ok(WorktreeAddRefresh::NeedsRestage);
6768        }
6769        if stat_same && !racy && trust_stat && staged_mode == ie.mode {
6770            return Ok(WorktreeAddRefresh::UpToDate);
6771        }
6772        if stat_same && !racy && trust_stat && staged_mode != ie.mode {
6773            return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
6774        }
6775        return worktree_effective_oid_matches_index(params);
6776    }
6777
6778    if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE {
6779        return Ok(WorktreeAddRefresh::NeedsRestage);
6780    }
6781
6782    if meta.file_type().is_symlink() || meta.is_dir() {
6783        return Ok(WorktreeAddRefresh::NeedsRestage);
6784    }
6785
6786    if stat_same && !racy && trust_stat {
6787        if staged_mode == ie.mode {
6788            return Ok(WorktreeAddRefresh::UpToDate);
6789        }
6790        return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
6791    }
6792
6793    worktree_effective_oid_matches_index(params)
6794}
6795
6796fn worktree_effective_oid_matches_index(
6797    params: &WorktreeAddRefreshParams<'_>,
6798) -> Result<WorktreeAddRefresh> {
6799    let WorktreeAddRefreshParams {
6800        odb,
6801        ie,
6802        meta,
6803        abs_path,
6804        rel_path,
6805        conv,
6806        file_attrs,
6807        staged_mode,
6808        ..
6809    } = params;
6810    let worktree_oid = hash_worktree_file(
6811        odb,
6812        abs_path,
6813        meta,
6814        conv,
6815        file_attrs,
6816        rel_path,
6817        Some(ie),
6818        params.filter_process,
6819    )?;
6820    let mut eff_oid = worktree_oid;
6821    if eff_oid != ie.oid {
6822        if let Ok(raw) = fs::read(abs_path) {
6823            let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
6824            if raw_oid == ie.oid {
6825                eff_oid = ie.oid;
6826            }
6827        }
6828    }
6829    if eff_oid != ie.oid {
6830        return Ok(WorktreeAddRefresh::NeedsRestage);
6831    }
6832    if *staged_mode != ie.mode {
6833        return Ok(WorktreeAddRefresh::ModeOnly { mode: *staged_mode });
6834    }
6835    if stat_matches(ie, meta) {
6836        Ok(WorktreeAddRefresh::UpToDate)
6837    } else {
6838        Ok(WorktreeAddRefresh::StatOnly)
6839    }
6840}
6841
6842/// Whether worktree metadata matches cached index stat data (Git `ie_match_stat` / `match_stat_data`).
6843#[must_use]
6844pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
6845    stat_matches_with_policy(ie, meta, crate::index::StatMatchPolicy::default())
6846}
6847
6848/// Whether compact worktree stat from a directory scan matches the index row.
6849pub(crate) fn stat_matches_blob_stat(
6850    ie: &IndexEntry,
6851    stat: &crate::worktree_scan::BlobDiskStat,
6852    policy: crate::index::StatMatchPolicy,
6853) -> bool {
6854    stat.fields.matches_index_entry(ie, policy)
6855}
6856
6857/// Like [`stat_matches`], honoring `core.trustctime`, `core.checkstat`, and `core.usenanosec`.
6858#[must_use]
6859pub fn stat_matches_with_policy(
6860    ie: &IndexEntry,
6861    meta: &fs::Metadata,
6862    policy: crate::index::StatMatchPolicy,
6863) -> bool {
6864    if crate::index::munge_index_size(meta.len()) != ie.size {
6865        return false;
6866    }
6867    #[cfg(unix)]
6868    {
6869        use std::os::unix::fs::MetadataExt;
6870        if meta.mtime() as u32 != ie.mtime_sec {
6871            return false;
6872        }
6873        if policy.use_nanosec && policy.check_stat && meta.mtime_nsec() as u32 != ie.mtime_nsec {
6874            return false;
6875        }
6876        if policy.trust_ctime && policy.check_stat {
6877            if meta.ctime() as u32 != ie.ctime_sec {
6878                return false;
6879            }
6880            if policy.use_nanosec && meta.ctime_nsec() as u32 != ie.ctime_nsec {
6881                return false;
6882            }
6883        }
6884        if policy.check_stat && meta.ino() as u32 != ie.ino {
6885            return false;
6886        }
6887    }
6888    #[cfg(not(unix))]
6889    {
6890        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = crate::index::metadata_times(meta);
6891        if mtime_sec != ie.mtime_sec {
6892            return false;
6893        }
6894        if policy.use_nanosec && policy.check_stat && mtime_nsec != ie.mtime_nsec {
6895            return false;
6896        }
6897        if policy.trust_ctime && policy.check_stat {
6898            if ctime_sec != ie.ctime_sec {
6899                return false;
6900            }
6901            if policy.use_nanosec && ctime_nsec != ie.ctime_nsec {
6902                return false;
6903            }
6904        }
6905    }
6906    true
6907}
6908
6909/// Refresh cached stat data for stage-0 file/symlink entries whose worktree content still matches
6910/// the recorded OID but whose on-disk stat went stale.
6911///
6912/// This mirrors Git's `refresh_index` / `refresh_cache_ent`: an entry is only marked clean (stat
6913/// adopted from the worktree) after its content is re-verified against the index OID. A genuinely
6914/// modified entry keeps its stale stat so `diff-files` / `status` continue to report it. Operations
6915/// that rewrite the worktree (`status`, `reset --mixed`, `stash`) call this before writing the
6916/// index so a subsequent `git diff-files` sees refreshed entries as clean.
6917///
6918/// Gitlinks, sparse (`skip_worktree`), `assume_unchanged` and intent-to-add entries are skipped.
6919/// The blob comparison is a raw-content hash, so a CRLF-smudged match is conservatively missed
6920/// (the entry simply stays stat-dirty and is re-hashed next time — never the reverse).
6921///
6922/// `index_mtime` is the on-disk index file's `(mtime_sec, mtime_nsec)` (see
6923/// `entry_is_racy` / Git `is_racy_timestamp`); pass `None` when unknown — racy detection is
6924/// then skipped, which is conservative for tree-built indexes whose zeroed stat never matches.
6925///
6926/// `stat_parallel_threads` overrides parallel stat workers (`Some(1)` forces serial); when `None`,
6927/// honors `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
6928///
6929/// Returns `Ok(true)` when at least one entry was refreshed or invalidated, so callers can write
6930/// the index opportunistically (Git only persists a refresh that changed something).
6931pub fn refresh_index_stat_content_verified(
6932    odb: &Odb,
6933    git_dir: &Path,
6934    index: &mut Index,
6935    work_tree: &Path,
6936    index_mtime: Option<(u32, u32)>,
6937    config: Option<&ConfigSet>,
6938    _stat_parallel_threads: Option<usize>,
6939) -> Result<bool> {
6940    let _ = _stat_parallel_threads;
6941    refresh_index_stat_content_verified_with_rules(
6942        odb,
6943        git_dir,
6944        index,
6945        work_tree,
6946        index_mtime,
6947        config,
6948        _stat_parallel_threads,
6949        None,
6950    )
6951}
6952
6953/// Like [`refresh_index_stat_content_verified`], resolving attributes and conversion through a
6954/// shared [`crate::worktree_rules::WorktreeRules`] when one is supplied (its config snapshot then
6955/// takes precedence over `config`).
6956///
6957/// # Errors
6958///
6959/// Propagates I/O errors from the directory-grouped worktree scan.
6960#[allow(clippy::too_many_arguments)]
6961pub fn refresh_index_stat_content_verified_with_rules(
6962    odb: &Odb,
6963    git_dir: &Path,
6964    index: &mut Index,
6965    work_tree: &Path,
6966    index_mtime: Option<(u32, u32)>,
6967    config: Option<&ConfigSet>,
6968    stat_parallel_threads: Option<usize>,
6969    worktree_rules: Option<&std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
6970) -> Result<bool> {
6971    use crate::config::ConfigSet;
6972    use crate::crlf;
6973    use crate::worktree_scan::{
6974        collect_blob_lookup_batches, group_blob_entries_by_dir, visit_blob_lookup_batches,
6975        BlobDiskLookup, BlobScanSparseMode, WorktreeBlobScanOptions,
6976    };
6977
6978    let index_mtime = index_mtime_for_diff(index, index_mtime);
6979    let rules_lock = worktree_rules
6980        .map(|shared| {
6981            shared
6982                .lock()
6983                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
6984        })
6985        .transpose()?;
6986    let config = rules_lock
6987        .as_ref()
6988        .map(|r| r.config().clone())
6989        .unwrap_or_else(|| {
6990            config.cloned().unwrap_or_else(|| {
6991                ConfigSet::load(
6992                    &crate::environment::Environment::empty(),
6993                    Some(git_dir),
6994                    true,
6995                )
6996                .unwrap_or_default()
6997            })
6998        });
6999    let preload_index = config
7000        .get_bool("core.preloadIndex")
7001        .and_then(|r| r.ok())
7002        .unwrap_or(true);
7003    let blob_scan = WorktreeBlobScanOptions {
7004        preload_index,
7005        stat_parallel_threads,
7006    };
7007    let conv = rules_lock
7008        .as_ref()
7009        .map(|r| r.conversion().clone())
7010        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
7011    let legacy_attrs = if rules_lock.is_none() {
7012        Some(crlf::load_gitattributes_parsed(work_tree))
7013    } else {
7014        None
7015    };
7016    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
7017        if let Some(rules) = rules_lock.as_ref() {
7018            rules.file_attrs(path, false)
7019        } else if let Some(parsed) = legacy_attrs.as_ref() {
7020            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
7021        } else {
7022            crlf::FileAttrs::default()
7023        }
7024    };
7025    let precompose_unicode = config
7026        .get_bool("core.precomposeunicode")
7027        .and_then(|r| r.ok())
7028        .unwrap_or(false);
7029    let ignorecase = config
7030        .get_bool("core.ignorecase")
7031        .and_then(|r| r.ok())
7032        .unwrap_or(false);
7033    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
7034
7035    let blob_dirs = group_blob_entries_by_dir(index, false);
7036    let work_tree_owned = work_tree.to_path_buf();
7037    let dir_abs = move |dir: &[u8]| {
7038        if dir.is_empty() {
7039            work_tree_owned.clone()
7040        } else {
7041            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
7042        }
7043    };
7044    let work_tree_for_files = work_tree.to_path_buf();
7045    let file_abs = move |rel: &[u8]| {
7046        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
7047    };
7048    let mut changed = false;
7049    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
7050    let thread_policy = crate::parallel::ThreadPolicy::default();
7051    // Symlink ancestor detection must run even when core.symlinks is false.
7052    let refresh_batches = collect_blob_lookup_batches(
7053        index,
7054        &blob_dirs,
7055        blob_scan,
7056        &thread_policy,
7057        &config,
7058        dir_abs,
7059        file_abs,
7060        || {
7061            let mut dir_symlinks = SymlinkDirCache::default();
7062            move |rel_path: &[u8]| {
7063                dir_symlinks.has_symlink_in_path_bytes(
7064                    work_tree,
7065                    rel_path,
7066                    precompose_unicode,
7067                    ignorecase,
7068                )
7069            }
7070        },
7071        BlobScanSparseMode::StatRefresh {
7072            stat_policy,
7073            index_mtime,
7074        },
7075    )?;
7076    let par_opts = refresh_hash_parallel_options(&thread_policy, &config, stat_parallel_threads);
7077    let refresh_checks = refresh_content_checks(
7078        odb,
7079        index,
7080        &refresh_batches,
7081        index_mtime,
7082        stat_policy,
7083        &|_, _, _| true,
7084        &WorktreeHashContext {
7085            work_tree,
7086            precompose_unicode,
7087            ignorecase,
7088            conv: &conv,
7089            file_attrs_for: &file_attrs_for,
7090            filter_process: filter_fp,
7091        },
7092        par_opts,
7093        None,
7094    )?;
7095    visit_blob_lookup_batches(refresh_batches, |entry_index, lookup| {
7096        let BlobDiskLookup::Present(stat) = lookup else {
7097            return Ok(());
7098        };
7099        let content_matches = RefreshContent::matches(
7100            refresh_checks.get(entry_index),
7101            &index.entries[entry_index].oid,
7102        );
7103        let refresh = refresh_index_blob_stat_from_scan(
7104            &mut index.entries[entry_index],
7105            &stat,
7106            index_mtime,
7107            stat_policy,
7108            content_matches,
7109        );
7110        if refresh.index_changed {
7111            changed = true;
7112        }
7113        if refresh.content_verified {
7114            let path = index.entries[entry_index].path.clone();
7115            let oid = index.entries[entry_index].oid;
7116            index.mark_content_verified(&path, oid);
7117        }
7118        Ok(())
7119    })?;
7120    Ok(changed)
7121}
7122
7123/// Result of refreshing one index entry's cached stat during a worktree scan.
7124struct BlobStatRefreshOutcome {
7125    /// Whether the in-memory index entry was updated (stat fields or invalidation).
7126    index_changed: bool,
7127    /// Blob content was read and matches the recorded OID (skip a second hash in the same pass).
7128    content_verified: bool,
7129}
7130
7131/// Whether the stat refresh may adopt worktree stat data for `ie` at all.
7132fn refresh_index_entry_eligible(ie: &IndexEntry) -> bool {
7133    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
7134    ie.stage() == 0
7135        && !ie.skip_worktree()
7136        && !ie.assume_unchanged()
7137        && !ie.intent_to_add()
7138        && matches!(ie.mode, MODE_REGULAR | MODE_EXECUTABLE | MODE_SYMLINK)
7139}
7140
7141/// Whether refreshing `ie` from the scanned `stat` must first prove by content that the
7142/// worktree still holds `ie.oid`: the stat data changed, or it matches but is racy.
7143fn refresh_needs_content_check(
7144    ie: &IndexEntry,
7145    stat: &crate::worktree_scan::BlobDiskStat,
7146    index_mtime: Option<(u32, u32)>,
7147    stat_policy: crate::index::StatMatchPolicy,
7148) -> bool {
7149    refresh_index_entry_eligible(ie)
7150        && !stat.is_dir
7151        && (!stat_matches_blob_stat(ie, stat, stat_policy)
7152            || entry_is_racy_with_policy(ie, index_mtime, stat_policy))
7153}
7154
7155/// Whether a worktree diff with `refresh_index_stat_in_pass` refreshes `ie` from `stat`.
7156///
7157/// Status leaves out entries whose stat data already decides the answer: clean and not racy,
7158/// or a changed size that [`status_size_change_proves_modified`] accepts (reported as modified
7159/// without reading the file). A size change under a non-identity clean conversion is refreshed:
7160/// when the cleaned content still matches, the entry adopts the new stat, so later scans trust it.
7161pub(crate) fn worktree_diff_refreshes_entry(
7162    ie: &IndexEntry,
7163    stat: &crate::worktree_scan::BlobDiskStat,
7164    index_mtime: Option<(u32, u32)>,
7165    stat_policy: crate::index::StatMatchPolicy,
7166    for_status: bool,
7167    conversion_is_identity: impl FnOnce() -> bool,
7168) -> bool {
7169    if !for_status {
7170        return true;
7171    }
7172    if stat_matches_blob_stat(ie, stat, stat_policy) {
7173        return entry_is_racy_with_policy(ie, index_mtime, stat_policy);
7174    }
7175    !status_size_change_proves_modified(
7176        ie,
7177        stat.worktree_size(),
7178        for_status,
7179        conversion_is_identity,
7180    )
7181}
7182
7183/// Pool options for the stat refresh's content checks.
7184///
7185/// `thread_policy` and config pick the worker count; `stat_parallel_threads` caps it the same
7186/// way it caps the stat scan (`Some(0)` or `Some(1)` keeps hashing on the calling thread).
7187/// There is no byte threshold: opening each file, not hashing its bytes, dominates for the
7188/// small files a checkout leaves racy.
7189fn refresh_hash_parallel_options(
7190    thread_policy: &crate::parallel::ThreadPolicy,
7191    config: &ConfigSet,
7192    stat_parallel_threads: Option<usize>,
7193) -> crate::parallel::ParallelOptions {
7194    let policy = crate::parallel::ThreadPolicy {
7195        min_items_override: thread_policy.min_items_override.or(Some(0)),
7196        min_total_bytes_override: thread_policy.min_total_bytes_override.or(Some(0)),
7197        ..*thread_policy
7198    };
7199    let mut par_opts = policy.resolve(crate::parallel::ParallelOp::IndexRefresh, config);
7200    if let Some(cap) = stat_parallel_threads {
7201        par_opts.threads = par_opts
7202            .threads
7203            .min(std::num::NonZeroUsize::new(cap).unwrap_or(std::num::NonZeroUsize::MIN));
7204    }
7205    par_opts.min_items = 0;
7206    par_opts.min_total_bytes = None;
7207    par_opts
7208}
7209
7210/// Worktree layout and attribute context for hashing index entries during a scan.
7211struct WorktreeHashContext<'a> {
7212    work_tree: &'a Path,
7213    precompose_unicode: bool,
7214    ignorecase: bool,
7215    conv: &'a crate::crlf::ConversionConfig,
7216    file_attrs_for: &'a dyn Fn(&str) -> crate::crlf::FileAttrs,
7217    filter_process: Option<&'a crate::filter_process::FilterProcessState>,
7218}
7219
7220/// Run every content check the stat refresh needs before the serial visit of `batches`.
7221///
7222/// Racily clean entries are the common case right after a checkout writes the index: every
7223/// file then shares the index's timestamp second. Hashing them here, in parallel when
7224/// `par_opts` and the total size allow it, keeps that first scan from reading files one by one.
7225///
7226/// Returns a check for each scanned entry that `include` (given the entry index) admits and
7227/// [`refresh_needs_content_check`] selects. Read and filter failures are
7228/// [`RefreshContent::Failed`], so those entries stay unverified.
7229#[allow(clippy::too_many_arguments)]
7230fn refresh_content_checks(
7231    odb: &Odb,
7232    index: &Index,
7233    batches: &[Vec<(usize, crate::worktree_scan::BlobDiskLookup)>],
7234    index_mtime: Option<(u32, u32)>,
7235    stat_policy: crate::index::StatMatchPolicy,
7236    include: &dyn Fn(usize, &IndexEntry, &crate::worktree_scan::BlobDiskStat) -> bool,
7237    ctx: &WorktreeHashContext<'_>,
7238    par_opts: crate::parallel::ParallelOptions,
7239    cancel_watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
7240) -> Result<RefreshChecks> {
7241    use crate::progress::{poll_watch, CANCEL_POLL_STRIDE};
7242    use crate::worktree_scan::BlobDiskLookup;
7243
7244    /// Attributes are resolved on the calling thread: the shared rules are not `Sync`.
7245    struct ContentCheck {
7246        entry_index: usize,
7247        worktree_mode: u32,
7248        file_attrs: crate::crlf::FileAttrs,
7249    }
7250
7251    let mut jobs = Vec::new();
7252    let mut total_bytes = 0usize;
7253    let mut cancel_stride = 0usize;
7254    for (entry_index, lookup) in batches.iter().flatten() {
7255        cancel_stride = cancel_stride.wrapping_add(1);
7256        if cancel_stride.is_multiple_of(CANCEL_POLL_STRIDE) {
7257            if let Some(w) = cancel_watch {
7258                poll_watch(w)?;
7259            }
7260        }
7261        let BlobDiskLookup::Present(stat) = lookup else {
7262            continue;
7263        };
7264        let ie = &index.entries[*entry_index];
7265        if !refresh_needs_content_check(ie, stat, index_mtime, stat_policy)
7266            || !include(*entry_index, ie, stat)
7267        {
7268            continue;
7269        }
7270        total_bytes = total_bytes.saturating_add(ie.size as usize);
7271        jobs.push(ContentCheck {
7272            entry_index: *entry_index,
7273            worktree_mode: stat.git_mode,
7274            file_attrs: (ctx.file_attrs_for)(&crate::repo_relpath::storage_string(&ie.path)),
7275        });
7276    }
7277    if jobs.is_empty() {
7278        return Ok(RefreshChecks::default());
7279    }
7280    let mut checks = vec![None; index.entries.len()];
7281    let (work_tree, precompose_unicode, ignorecase) =
7282        (ctx.work_tree, ctx.precompose_unicode, ctx.ignorecase);
7283    let (conv, filter_process) = (ctx.conv, ctx.filter_process);
7284    let entries = index.entries.as_slice();
7285    let watch_for_hash = cancel_watch.cloned();
7286    let hash_one = |job: &ContentCheck| -> Result<RefreshContent> {
7287        if job.entry_index.is_multiple_of(CANCEL_POLL_STRIDE) {
7288            if let Some(w) = watch_for_hash.as_ref() {
7289                poll_watch(w)?;
7290            }
7291        }
7292        let ie = &entries[job.entry_index];
7293        let file_path =
7294            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
7295        Ok(RefreshContent::from(hash_worktree_file_with_mode(
7296            odb,
7297            &file_path,
7298            job.worktree_mode,
7299            conv,
7300            &job.file_attrs,
7301            &crate::repo_relpath::storage_string(&ie.path),
7302            Some(ie),
7303            filter_process,
7304        )))
7305    };
7306    let matches = if par_opts.threads.get() > 1 && jobs.len() > 1 {
7307        crate::parallel::try_par_map_force(&jobs, par_opts, hash_one).map_err(|e| match e {
7308            crate::parallel::ParallelHashError::Task(err) => err,
7309        })?
7310    } else {
7311        let mut out = Vec::with_capacity(jobs.len());
7312        let mut serial_stride = 0usize;
7313        for job in &jobs {
7314            serial_stride = serial_stride.wrapping_add(1);
7315            if serial_stride.is_multiple_of(CANCEL_POLL_STRIDE) {
7316                if let Some(w) = cancel_watch {
7317                    poll_watch(w)?;
7318                }
7319            }
7320            out.push(hash_one(job)?);
7321        }
7322        out
7323    };
7324    for (job, content) in jobs.iter().zip(matches) {
7325        checks[job.entry_index] = Some(content);
7326    }
7327    Ok(RefreshChecks(checks))
7328}
7329
7330/// Results of [`refresh_content_checks`], by index entry position.
7331///
7332/// Empty when no entry needed a check (a clean, non-racy work tree), so the common case
7333/// allocates nothing.
7334#[derive(Default)]
7335struct RefreshChecks(Vec<Option<RefreshContent>>);
7336
7337impl RefreshChecks {
7338    /// The check that ran for the entry at `entry_index`, if any.
7339    fn get(&self, entry_index: usize) -> Option<RefreshContent> {
7340        self.0.get(entry_index).copied().flatten()
7341    }
7342}
7343
7344/// What the stat refresh learned by hashing one entry's worktree file.
7345#[derive(Clone, Copy)]
7346enum RefreshContent {
7347    /// The file could not be read or clean-converted.
7348    Failed,
7349    /// The cleaned file hashes to this blob id.
7350    Hashed(ObjectId),
7351}
7352
7353impl From<Result<ObjectId>> for RefreshContent {
7354    fn from(hashed: Result<ObjectId>) -> Self {
7355        hashed.map_or(Self::Failed, Self::Hashed)
7356    }
7357}
7358
7359impl RefreshContent {
7360    /// Whether `check` proved the worktree still holds `oid`.
7361    fn matches(check: Option<Self>, oid: &ObjectId) -> bool {
7362        matches!(check, Some(Self::Hashed(hashed)) if hashed == *oid)
7363    }
7364
7365    /// Whether `check` proved the worktree holds other content than `oid`.
7366    fn differs(check: Option<Self>, oid: &ObjectId) -> bool {
7367        matches!(check, Some(Self::Hashed(hashed)) if hashed != *oid)
7368    }
7369}
7370
7371/// Adopt the scanned `stat` for `ie` where that cannot hide a worktree change.
7372///
7373/// `content_matches` is the [`refresh_content_checks`] result for this entry; it is only
7374/// consulted when [`refresh_needs_content_check`] holds. A racy entry whose content no longer
7375/// matches has its stat cache invalidated so later scans re-hash it.
7376fn refresh_index_blob_stat_from_scan(
7377    ie: &mut IndexEntry,
7378    stat: &crate::worktree_scan::BlobDiskStat,
7379    index_mtime: Option<(u32, u32)>,
7380    stat_policy: crate::index::StatMatchPolicy,
7381    content_matches: bool,
7382) -> BlobStatRefreshOutcome {
7383    const UNCHANGED: BlobStatRefreshOutcome = BlobStatRefreshOutcome {
7384        index_changed: false,
7385        content_verified: false,
7386    };
7387    if !refresh_index_entry_eligible(ie) || stat.is_dir {
7388        return UNCHANGED;
7389    }
7390    let cached = crate::index::IndexStatFields::of_entry(ie);
7391    let stat_same = cached.equivalent_to(&stat.fields, stat_policy);
7392    let stat_matches = stat_matches_blob_stat(ie, stat, stat_policy);
7393    if !stat_matches || entry_is_racy_with_policy(ie, index_mtime, stat_policy) {
7394        if !content_matches {
7395            if stat_matches {
7396                invalidate_index_stat_cache(ie);
7397                return BlobStatRefreshOutcome {
7398                    index_changed: true,
7399                    content_verified: false,
7400                };
7401            }
7402            return UNCHANGED;
7403        }
7404        if !stat_same {
7405            stat.fields.store_in(ie);
7406        }
7407        return BlobStatRefreshOutcome {
7408            index_changed: !stat_same,
7409            content_verified: true,
7410        };
7411    }
7412    if stat_same {
7413        return BlobStatRefreshOutcome {
7414            index_changed: false,
7415            content_verified: true,
7416        };
7417    }
7418    stat.fields.store_in(ie);
7419    BlobStatRefreshOutcome {
7420        index_changed: true,
7421        content_verified: false,
7422    }
7423}
7424
7425/// Whether path-only checkout can skip writing a blob because the worktree is up to date.
7426///
7427/// Matching stat data is trusted unless the entry is racy relative to the on-disk index mtime;
7428/// then the worktree content (after clean/filter conversion) must hash to `expected_oid`
7429/// ([`hash_worktree_file`]). `config` is an optional preloaded configuration.
7430///
7431/// Returns `Ok(true)` when the caller should skip rewriting the path.
7432///
7433/// # Errors
7434///
7435/// Propagates I/O or hashing failures from [`hash_worktree_file`].
7436#[allow(clippy::too_many_arguments)]
7437pub(crate) fn path_checkout_skip_blob_write_when_up_to_date_with_config(
7438    odb: &Odb,
7439    git_dir: &Path,
7440    work_tree: &Path,
7441    index: &Index,
7442    entry: &IndexEntry,
7443    expected_oid: &ObjectId,
7444    expected_mode: u32,
7445    rel_path: &str,
7446    filter_process: Option<&crate::filter_process::FilterProcessState>,
7447    preloaded_config: Option<&crate::config::ConfigSet>,
7448) -> Result<bool> {
7449    use crate::config::ConfigSet;
7450    use crate::crlf;
7451    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR};
7452
7453    if entry.oid != *expected_oid || entry.mode != expected_mode {
7454        return Ok(false);
7455    }
7456    if expected_mode != MODE_REGULAR && expected_mode != MODE_EXECUTABLE {
7457        return Ok(false);
7458    }
7459    let abs_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
7460    let meta = match fs::symlink_metadata(&abs_path) {
7461        Ok(m) => m,
7462        Err(_) => return Ok(false),
7463    };
7464    if !meta.is_file() || !stat_matches(entry, &meta) {
7465        return Ok(false);
7466    }
7467    let owned_config;
7468    let config = match preloaded_config {
7469        Some(cfg) => cfg,
7470        None => {
7471            owned_config = ConfigSet::load(
7472                &crate::environment::Environment::empty(),
7473                Some(git_dir),
7474                true,
7475            )
7476            .unwrap_or_default();
7477            &owned_config
7478        }
7479    };
7480    if !crate::index::WorktreeModePolicy::from_config(config)
7481        .worktree_mode_matches(mode_from_metadata(&meta), expected_mode)
7482    {
7483        return Ok(false);
7484    }
7485    let index_mtime = index_mtime_for_diff(index, None);
7486    if !entry_is_racy(entry, index_mtime) {
7487        return Ok(true);
7488    }
7489    let conv = crlf::ConversionConfig::from_config(config);
7490    let attrs = crlf::load_gitattributes(work_tree);
7491    let file_attrs = crlf::get_file_attrs(&attrs, rel_path, false, config);
7492    let wt_oid = hash_worktree_file(
7493        odb,
7494        &abs_path,
7495        &meta,
7496        &conv,
7497        &file_attrs,
7498        rel_path,
7499        Some(entry),
7500        filter_process,
7501    )?;
7502    Ok(wt_oid == *expected_oid)
7503}
7504
7505/// Clear cached stat fields so the next diff/status pass re-reads the work tree.
7506fn invalidate_index_stat_cache(ie: &mut IndexEntry) {
7507    ie.ctime_sec = 0;
7508    ie.ctime_nsec = 0;
7509    ie.mtime_sec = 0;
7510    ie.mtime_nsec = 0;
7511    ie.dev = 0;
7512    ie.ino = 0;
7513    ie.size = 0;
7514}
7515
7516/// Read worktree content for diffing, honoring Git tree modes.
7517///
7518/// For [`MODE_SYMLINK`] entries backed by a symlink on disk this returns the link target path
7519/// bytes (via [`read_link`](std::fs::read_link)), not the contents of the followed file. A
7520/// symlink entry checked out as a plain file (`core.symlinks=false`) already holds the target,
7521/// so it is read like other modes, with a plain [`read`](std::fs::read) of the path.
7522///
7523/// # Errors
7524///
7525/// Returns [`std::io::Error`] when the path cannot be read.
7526pub fn read_worktree_bytes_for_mode(path: &Path, mode: u32) -> std::io::Result<Vec<u8>> {
7527    if mode == MODE_SYMLINK && fs::symlink_metadata(path)?.file_type().is_symlink() {
7528        let target = fs::read_link(path)?;
7529        return Ok(symlink_target_bytes(&target));
7530    }
7531    fs::read(path)
7532}
7533
7534/// Read worktree file bytes in Git (repository) form for line diffs against index or tree blobs.
7535///
7536/// Applies the same `.gitattributes`, filter, and line-ending rules as
7537/// [`diff_index_to_worktree`]. Pass the stage-0 [`IndexEntry`] when available so
7538/// `autocrlf` index-guard normalization matches status and raw diff collection.
7539///
7540/// # Errors
7541///
7542/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
7543pub fn read_worktree_side_bytes_for_diff(
7544    odb: &Odb,
7545    work_tree: &Path,
7546    rel_path: &str,
7547    mode: u32,
7548    config: &crate::config::ConfigSet,
7549    index_entry: Option<&IndexEntry>,
7550    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
7551) -> Result<Vec<u8>> {
7552    Ok(load_worktree_side_for_diff(
7553        odb,
7554        work_tree,
7555        rel_path,
7556        mode,
7557        config,
7558        index_entry,
7559        worktree_rules,
7560        false,
7561    )?
7562    .0
7563    .data)
7564}
7565
7566/// Load worktree bytes for diffing, optionally skipping reads above `core.bigFileThreshold`.
7567///
7568/// Returns empty [`super::structured::SideBytes`] with `binary = true` when the file is not read.
7569/// When `force_read_content` is true, large files are read and binary payload bytes are retained
7570/// (for `GIT binary patch` / `git apply`).
7571///
7572/// # Errors
7573///
7574/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
7575#[allow(clippy::too_many_arguments)]
7576pub fn load_worktree_side_for_diff(
7577    odb: &Odb,
7578    work_tree: &Path,
7579    rel_path: &str,
7580    mode: u32,
7581    config: &crate::config::ConfigSet,
7582    index_entry: Option<&IndexEntry>,
7583    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
7584    force_read_content: bool,
7585) -> Result<(super::structured::SideBytes, bool)> {
7586    use super::tree_content::{side_bytes_after_blob_read, treat_side_as_binary_without_reading};
7587    let file_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
7588    let rel_match = crate::repo_relpath::as_utf8_for_matching(rel_path);
7589    let meta = fs::symlink_metadata(&file_path).map_err(|source| {
7590        crate::worktree::map_worktree_io(
7591            rel_match.as_ref(),
7592            crate::worktree::WorktreePathOperation::Metadata,
7593            source,
7594        )
7595    })?;
7596    if mode == MODE_SYMLINK && meta.file_type().is_symlink() {
7597        let target = fs::read_link(&file_path).map_err(|source| {
7598            crate::worktree::map_worktree_io(
7599                rel_match.as_ref(),
7600                crate::worktree::WorktreePathOperation::Read,
7601                source,
7602            )
7603        })?;
7604        return Ok((
7605            super::structured::SideBytes::from_bytes(symlink_target_bytes(&target)),
7606            false,
7607        ));
7608    }
7609    if !force_read_content
7610        && treat_side_as_binary_without_reading(odb, meta.len(), Some(rel_match.as_ref()), false)?
7611    {
7612        return Ok((super::structured::SideBytes::from_bytes(Vec::new()), true));
7613    }
7614    let data = if let Some(rules) = worktree_rules {
7615        worktree_blob_bytes(
7616            odb,
7617            &file_path,
7618            &meta,
7619            rules.conversion(),
7620            &rules.file_attrs(rel_match.as_ref(), meta.is_dir()),
7621            rel_match.as_ref(),
7622            index_entry,
7623            Some(rules.filter_process()),
7624        )?
7625    } else {
7626        let conv = crate::crlf::ConversionConfig::from_config(config);
7627        let attrs = crate::crlf::load_gitattributes(work_tree);
7628        let file_attrs =
7629            crate::crlf::get_file_attrs(&attrs, rel_match.as_ref(), meta.is_dir(), config);
7630        worktree_blob_bytes(
7631            odb,
7632            &file_path,
7633            &meta,
7634            &conv,
7635            &file_attrs,
7636            rel_match.as_ref(),
7637            index_entry,
7638            None,
7639        )?
7640    };
7641    Ok(side_bytes_after_blob_read(data, force_read_content))
7642}
7643
7644/// Symlink target as the byte string Git hashes for the blob OID.
7645///
7646/// On Unix the raw `OsStr` bytes are used verbatim. On Windows `OsStr` is WTF-8
7647/// and has no stable byte view, so the lossy UTF-8 form is used (symlink targets
7648/// in Git trees are UTF-8 in practice).
7649pub(crate) fn symlink_target_bytes(target: &Path) -> Vec<u8> {
7650    #[cfg(unix)]
7651    {
7652        use std::os::unix::ffi::OsStrExt as _;
7653        target.as_os_str().as_bytes().to_vec()
7654    }
7655    #[cfg(not(unix))]
7656    {
7657        target.as_os_str().to_string_lossy().as_bytes().to_vec()
7658    }
7659}
7660
7661/// A worktree file's blob content: normalized bytes, or an open file that is hashed or
7662/// stored as a stream.
7663///
7664/// Regular files of at least the caller's size threshold whose bytes can be stored without clean
7665/// conversion ([`crate::crlf::worktree_allows_raw_byte_stream`], including binary files under
7666/// `core.autocrlf`) are streamed so memory use does not depend on the file size.
7667pub(crate) enum WorktreeBlobContent {
7668    /// Content after the clean conversion (filters, encoding, line endings).
7669    Bytes(Vec<u8>),
7670    /// Unconverted big file, positioned at its start.
7671    Stream {
7672        /// Open handle on the worktree file.
7673        file: fs::File,
7674        /// File size when it was opened.
7675        size: u64,
7676    },
7677}
7678
7679impl WorktreeBlobContent {
7680    /// Object id of the content, without storing it.
7681    pub(crate) fn hash(self, odb: &Odb) -> Result<ObjectId> {
7682        match self {
7683            Self::Bytes(data) => odb.hash(ObjectKind::Blob, &data),
7684            Self::Stream { mut file, size } => odb.hash_stream(ObjectKind::Blob, size, &mut file),
7685        }
7686    }
7687
7688    /// Store the content as a blob and return its id.
7689    pub(crate) fn store(self, odb: &Odb, options: crate::odb::WriteOptions) -> Result<ObjectId> {
7690        match self {
7691            Self::Bytes(data) => odb.write_with_options(ObjectKind::Blob, &data, options),
7692            Self::Stream { mut file, size } => {
7693                odb.write_stream(ObjectKind::Blob, size, &mut file, options)
7694            }
7695        }
7696    }
7697
7698    /// [`Self::store`] with `options` when given, otherwise [`Self::hash`].
7699    fn object_id(self, odb: &Odb, store: Option<crate::odb::WriteOptions>) -> Result<ObjectId> {
7700        match store {
7701            Some(options) => self.store(odb, options),
7702            None => self.hash(odb),
7703        }
7704    }
7705}
7706
7707/// Read and clean-convert an open regular worktree file of `size` bytes.
7708#[allow(clippy::too_many_arguments)]
7709fn read_regular_worktree_bytes(
7710    odb: &Odb,
7711    mut file: fs::File,
7712    size: u64,
7713    conv: &crate::crlf::ConversionConfig,
7714    file_attrs: &crate::crlf::FileAttrs,
7715    rel_path: &str,
7716    index_entry: Option<&IndexEntry>,
7717    filter_process: Option<&crate::filter_process::FilterProcessState>,
7718) -> Result<Vec<u8>> {
7719    let needs_index_blob =
7720        index_entry.is_some_and(|_| crate::crlf::clean_uses_autocrlf_index_guard(file_attrs, conv));
7721    let prior_blob: Option<Vec<u8>> = if needs_index_blob {
7722        index_entry
7723            .filter(|e| e.oid != zero_oid())
7724            .and_then(|e| odb.read(&e.oid).ok().map(|o| o.data))
7725    } else {
7726        None
7727    };
7728    let mut raw = Vec::with_capacity(usize::try_from(size).unwrap_or_default());
7729    std::io::Read::read_to_end(&mut file, &mut raw).map_err(|source| {
7730        crate::worktree::map_worktree_io(
7731            rel_path,
7732            crate::worktree::WorktreePathOperation::Read,
7733            source,
7734        )
7735    })?;
7736    #[cfg(test)]
7737    odb.hot_path_metrics().record_blob_content_read();
7738    let opts = crate::crlf::ConvertToGitOpts {
7739        index_blob: prior_blob.as_deref(),
7740        renormalize: false,
7741        check_safecrlf: false,
7742        filter_process,
7743        command_runner: None,
7744    };
7745    Ok(crate::crlf::convert_to_git_owned(
7746        raw, rel_path, conv, file_attrs, opts,
7747    )?)
7748}
7749
7750fn open_with_size(path: &Path, rel_path: &str) -> Result<(fs::File, u64)> {
7751    let file = fs::File::open(path).map_err(|source| {
7752        crate::worktree::map_worktree_io(
7753            rel_path,
7754            crate::worktree::WorktreePathOperation::Read,
7755            source,
7756        )
7757    })?;
7758    let size = file
7759        .metadata()
7760        .map_err(|source| {
7761            crate::worktree::map_worktree_io(
7762                rel_path,
7763                crate::worktree::WorktreePathOperation::Metadata,
7764                source,
7765            )
7766        })?
7767        .len();
7768    Ok((file, size))
7769}
7770
7771/// Open a regular worktree file and clean-convert it, or hand it out as a stream when it is
7772/// at least `stream_from` bytes and its clean conversion is the identity.
7773#[allow(clippy::too_many_arguments)]
7774fn read_regular_worktree_blob(
7775    odb: &Odb,
7776    path: &Path,
7777    conv: &crate::crlf::ConversionConfig,
7778    file_attrs: &crate::crlf::FileAttrs,
7779    rel_path: &str,
7780    index_entry: Option<&IndexEntry>,
7781    filter_process: Option<&crate::filter_process::FilterProcessState>,
7782    stream_from: u64,
7783) -> Result<WorktreeBlobContent> {
7784    let (mut file, size) = open_with_size(path, rel_path)?;
7785    if size >= stream_from
7786        && crate::crlf::worktree_allows_raw_byte_stream(
7787            conv,
7788            file_attrs,
7789            index_entry,
7790            &mut file,
7791            size,
7792        )?
7793    {
7794        #[cfg(test)]
7795        odb.hot_path_metrics().record_blob_content_read();
7796        return Ok(WorktreeBlobContent::Stream { file, size });
7797    }
7798    read_regular_worktree_bytes(
7799        odb,
7800        file,
7801        size,
7802        conv,
7803        file_attrs,
7804        rel_path,
7805        index_entry,
7806        filter_process,
7807    )
7808    .map(WorktreeBlobContent::Bytes)
7809}
7810
7811/// Blob content for a worktree path: symlink target, empty for a directory, or the
7812/// clean-converted (or streamed, see [`WorktreeBlobContent`]) file content.
7813#[allow(clippy::too_many_arguments)]
7814pub(crate) fn worktree_blob_content(
7815    odb: &Odb,
7816    path: &Path,
7817    meta: &fs::Metadata,
7818    conv: &crate::crlf::ConversionConfig,
7819    file_attrs: &crate::crlf::FileAttrs,
7820    rel_path: &str,
7821    index_entry: Option<&IndexEntry>,
7822    filter_process: Option<&crate::filter_process::FilterProcessState>,
7823) -> Result<WorktreeBlobContent> {
7824    worktree_blob_content_from(
7825        odb,
7826        path,
7827        meta,
7828        conv,
7829        file_attrs,
7830        rel_path,
7831        index_entry,
7832        filter_process,
7833        hash_stream_threshold(odb)?,
7834    )
7835}
7836
7837/// [`worktree_blob_content`], streaming unconverted regular files from `stream_from` bytes.
7838#[allow(clippy::too_many_arguments)]
7839fn worktree_blob_content_from(
7840    odb: &Odb,
7841    path: &Path,
7842    meta: &fs::Metadata,
7843    conv: &crate::crlf::ConversionConfig,
7844    file_attrs: &crate::crlf::FileAttrs,
7845    rel_path: &str,
7846    index_entry: Option<&IndexEntry>,
7847    filter_process: Option<&crate::filter_process::FilterProcessState>,
7848    stream_from: u64,
7849) -> Result<WorktreeBlobContent> {
7850    if meta.file_type().is_symlink() {
7851        let target = fs::read_link(path).map_err(|source| {
7852            crate::worktree::map_worktree_io(
7853                rel_path,
7854                crate::worktree::WorktreePathOperation::Read,
7855                source,
7856            )
7857        })?;
7858        return Ok(WorktreeBlobContent::Bytes(symlink_target_bytes(&target)));
7859    }
7860    if meta.is_dir() {
7861        return Ok(WorktreeBlobContent::Bytes(Vec::new()));
7862    }
7863    read_regular_worktree_blob(
7864        odb,
7865        path,
7866        conv,
7867        file_attrs,
7868        rel_path,
7869        index_entry,
7870        filter_process,
7871        stream_from,
7872    )
7873}
7874
7875/// Like [`worktree_blob_content`], always returning the bytes (for callers that need the
7876/// content itself, such as line diffs).
7877#[allow(clippy::too_many_arguments)]
7878pub(crate) fn worktree_blob_bytes(
7879    odb: &Odb,
7880    path: &Path,
7881    meta: &fs::Metadata,
7882    conv: &crate::crlf::ConversionConfig,
7883    file_attrs: &crate::crlf::FileAttrs,
7884    rel_path: &str,
7885    index_entry: Option<&IndexEntry>,
7886    filter_process: Option<&crate::filter_process::FilterProcessState>,
7887) -> Result<Vec<u8>> {
7888    if meta.file_type().is_symlink() {
7889        let target = fs::read_link(path).map_err(|source| {
7890            crate::worktree::map_worktree_io(
7891                rel_path,
7892                crate::worktree::WorktreePathOperation::Read,
7893                source,
7894            )
7895        })?;
7896        return Ok(symlink_target_bytes(&target));
7897    }
7898    if meta.is_dir() {
7899        return Ok(Vec::new());
7900    }
7901    let (file, size) = open_with_size(path, rel_path)?;
7902    read_regular_worktree_bytes(
7903        odb,
7904        file,
7905        size,
7906        conv,
7907        file_attrs,
7908        rel_path,
7909        index_entry,
7910        filter_process,
7911    )
7912}
7913
7914#[allow(clippy::too_many_arguments)]
7915fn worktree_file_oid(
7916    odb: &Odb,
7917    path: &Path,
7918    meta: &fs::Metadata,
7919    conv: &crate::crlf::ConversionConfig,
7920    file_attrs: &crate::crlf::FileAttrs,
7921    rel_path: &str,
7922    index_entry: Option<&IndexEntry>,
7923    materialize: bool,
7924    filter_process: Option<&crate::filter_process::FilterProcessState>,
7925) -> Result<ObjectId> {
7926    let stream_from = if materialize {
7927        odb.big_file_threshold()?
7928    } else {
7929        hash_stream_threshold(odb)?
7930    };
7931    worktree_blob_content_from(
7932        odb,
7933        path,
7934        meta,
7935        conv,
7936        file_attrs,
7937        rel_path,
7938        index_entry,
7939        filter_process,
7940        stream_from,
7941    )?
7942    .object_id(odb, materialize.then(|| odb.bulk_stage_write_options()))
7943}
7944
7945/// Hash normalized worktree bytes for a path (does not write to the object database).
7946///
7947/// Files that need no clean conversion are hashed as a stream, without reading them into
7948/// memory, from 1 MiB (or `core.bigFileThreshold` when that is smaller).
7949///
7950/// # Errors
7951///
7952/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
7953/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
7954/// stored or hashed unfiltered in that case.
7955#[allow(clippy::too_many_arguments)]
7956pub fn hash_worktree_file(
7957    odb: &Odb,
7958    path: &Path,
7959    meta: &fs::Metadata,
7960    conv: &crate::crlf::ConversionConfig,
7961    file_attrs: &crate::crlf::FileAttrs,
7962    rel_path: &str,
7963    index_entry: Option<&IndexEntry>,
7964    filter_process: Option<&crate::filter_process::FilterProcessState>,
7965) -> Result<ObjectId> {
7966    worktree_file_oid(
7967        odb,
7968        path,
7969        meta,
7970        conv,
7971        file_attrs,
7972        rel_path,
7973        index_entry,
7974        false,
7975        filter_process,
7976    )
7977}
7978
7979/// Work-tree files at least this large are hashed as a stream (when their content goes into
7980/// Git unchanged) even below `core.bigFileThreshold`, which only governs how blobs are stored.
7981const STREAMED_HASH_MIN_SIZE: u64 = 1024 * 1024;
7982
7983/// Size from which an unconverted work-tree file is hashed as a stream rather than read whole.
7984fn hash_stream_threshold(odb: &Odb) -> Result<u64> {
7985    Ok(odb.big_file_threshold()?.min(STREAMED_HASH_MIN_SIZE))
7986}
7987
7988/// Like [`hash_worktree_file`], for callers that already know the worktree file type from a
7989/// directory scan (`worktree_mode` is the Git mode derived from that stat), so no metadata
7990/// probe is needed.
7991#[allow(clippy::too_many_arguments)]
7992pub(crate) fn hash_worktree_file_with_mode(
7993    odb: &Odb,
7994    path: &Path,
7995    worktree_mode: u32,
7996    conv: &crate::crlf::ConversionConfig,
7997    file_attrs: &crate::crlf::FileAttrs,
7998    rel_path: &str,
7999    index_entry: Option<&IndexEntry>,
8000    filter_process: Option<&crate::filter_process::FilterProcessState>,
8001) -> Result<ObjectId> {
8002    if worktree_mode == MODE_SYMLINK {
8003        let data = read_worktree_bytes_for_mode(path, worktree_mode)?;
8004        return odb.hash(ObjectKind::Blob, &data);
8005    }
8006    read_regular_worktree_blob(
8007        odb,
8008        path,
8009        conv,
8010        file_attrs,
8011        rel_path,
8012        index_entry,
8013        filter_process,
8014        hash_stream_threshold(odb)?,
8015    )?
8016    .hash(odb)
8017}
8018
8019/// Read, normalize (CRLF/attributes), and store a worktree blob once.
8020///
8021/// # Errors
8022///
8023/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
8024/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
8025/// stored or hashed unfiltered in that case.
8026#[allow(clippy::too_many_arguments)]
8027pub fn materialize_worktree_blob(
8028    odb: &Odb,
8029    path: &Path,
8030    meta: &fs::Metadata,
8031    conv: &crate::crlf::ConversionConfig,
8032    file_attrs: &crate::crlf::FileAttrs,
8033    rel_path: &str,
8034    index_entry: Option<&IndexEntry>,
8035    filter_process: Option<&crate::filter_process::FilterProcessState>,
8036) -> Result<ObjectId> {
8037    worktree_file_oid(
8038        odb,
8039        path,
8040        meta,
8041        conv,
8042        file_attrs,
8043        rel_path,
8044        index_entry,
8045        true,
8046        filter_process,
8047    )
8048}
8049
8050/// Derive a Git file mode from filesystem metadata.
8051pub fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
8052    if meta.file_type().is_symlink() {
8053        0o120000
8054    } else {
8055        #[cfg(unix)]
8056        {
8057            if meta.mode() & 0o111 != 0 {
8058                return 0o100755;
8059            }
8060        }
8061        0o100644
8062    }
8063}
8064
8065/// Compare a tree against the working tree.
8066///
8067/// Shows changes from `tree_oid` to the current working directory state.
8068/// Files tracked in the index but not in the tree are shown as Added.
8069/// Files in the tree but missing from the working tree are shown as Deleted.
8070///
8071/// # Parameters
8072///
8073/// - `odb` — object database.
8074/// - `tree_oid` — the tree to compare against (`None` for empty tree).
8075/// - `work_tree` — path to the working tree root.
8076/// - `index` — current index (used to discover new tracked files not in tree).
8077///
8078/// # Errors
8079///
8080/// Returns errors from ODB reads or I/O.
8081pub fn diff_tree_to_worktree(
8082    odb: &Odb,
8083    tree_oid: Option<&ObjectId>,
8084    work_tree: &Path,
8085    index: &Index,
8086) -> Result<Vec<DiffEntry>> {
8087    diff_tree_to_worktree_with_git_dir(
8088        odb,
8089        tree_oid,
8090        work_tree,
8091        &work_tree.join(".git"),
8092        index,
8093        None,
8094    )
8095}
8096
8097/// Like [`diff_tree_to_worktree`], but loads config from `repository_git_dir` (linked worktrees / gitfiles).
8098///
8099/// Pass `config` when the caller already holds a repository config snapshot.
8100pub fn diff_tree_to_worktree_with_git_dir(
8101    odb: &Odb,
8102    tree_oid: Option<&ObjectId>,
8103    work_tree: &Path,
8104    repository_git_dir: &Path,
8105    index: &Index,
8106    config: Option<&ConfigSet>,
8107) -> Result<Vec<DiffEntry>> {
8108    diff_tree_to_worktree_with_git_dir_and_rules(
8109        odb,
8110        tree_oid,
8111        work_tree,
8112        repository_git_dir,
8113        index,
8114        config,
8115        None,
8116    )
8117}
8118
8119/// Like [`diff_tree_to_worktree_with_git_dir`], with optional shared [`crate::worktree_rules::WorktreeRules`].
8120pub fn diff_tree_to_worktree_with_git_dir_and_rules(
8121    odb: &Odb,
8122    tree_oid: Option<&ObjectId>,
8123    work_tree: &Path,
8124    repository_git_dir: &Path,
8125    index: &Index,
8126    config: Option<&ConfigSet>,
8127    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
8128) -> Result<Vec<DiffEntry>> {
8129    use crate::config::ConfigSet;
8130    use crate::crlf;
8131
8132    if worktree_rules.is_none() {
8133        if let Some(tree_oid) = tree_oid {
8134            if index.cache_tree_root.as_ref() == Some(tree_oid)
8135                && crate::write_tree::cache_tree_fully_valid(odb, index.cache_tree.as_ref())
8136            {
8137                let mut idx = index.clone();
8138                let options = DiffIndexToWorktreeOptions {
8139                    repository_git_dir: Some(repository_git_dir.to_path_buf()),
8140                    refresh_index_stat_in_pass: false,
8141                    // Tree↔worktree raw lines use a null worktree OID when content differs; the
8142                    // status fast path (`for_status`) uses the same null OID for size-only drift
8143                    // without hashing (see `status_size_change_proves_modified`).
8144                    for_status: false,
8145                    config: config.cloned().map(std::sync::Arc::new),
8146                    ..DiffIndexToWorktreeOptions::default()
8147                };
8148                let (entries, _, _) = diff_index_to_worktree_inner(
8149                    odb,
8150                    &mut idx,
8151                    work_tree,
8152                    repository_git_dir,
8153                    options,
8154                    false,
8155                    None,
8156                )?;
8157                return Ok(entries);
8158            }
8159        }
8160    }
8161
8162    let config = worktree_rules
8163        .map(|r| r.config().clone())
8164        .unwrap_or_else(|| {
8165            config.cloned().unwrap_or_else(|| {
8166                ConfigSet::load(
8167                    &crate::environment::Environment::empty(),
8168                    Some(repository_git_dir),
8169                    true,
8170                )
8171                .unwrap_or_else(|_| ConfigSet::new())
8172            })
8173        });
8174    let conv = worktree_rules
8175        .map(|r| r.conversion().clone())
8176        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
8177    let legacy_attrs = if worktree_rules.is_none() {
8178        Some(crlf::load_gitattributes_parsed(work_tree))
8179    } else {
8180        None
8181    };
8182    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
8183        if let Some(rules) = worktree_rules {
8184            rules.file_attrs(path, false)
8185        } else if let Some(parsed) = legacy_attrs.as_ref() {
8186            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
8187        } else {
8188            crlf::FileAttrs::default()
8189        }
8190    };
8191    let filter_fp = worktree_rules.map(|r| r.filter_process());
8192
8193    let sparse_index = index.has_sparse_directory_placeholders();
8194    // Flatten the tree into a BTreeMap keyed by path
8195    let tree_flat = match tree_oid {
8196        Some(oid) if sparse_index => flatten_tree_with_index(odb, oid, "", Some(index))?,
8197        Some(oid) => flatten_tree(odb, oid, "")?,
8198        None => Vec::new(),
8199    };
8200    let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
8201        tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
8202
8203    // Build index lookup: path → &IndexEntry (stage 0 only)
8204    let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
8205        std::collections::BTreeMap::new();
8206    let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
8207    let mut stage0_paths: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
8208    for ie in &index.entries {
8209        if ie.stage() != 0 {
8210            continue;
8211        }
8212        let path = String::from_utf8_lossy(&ie.path).to_string();
8213        index_entries.insert(&ie.path, ie);
8214        index_paths.insert(path);
8215        stage0_paths.insert(ie.path.clone());
8216    }
8217
8218    // Paths with only unmerged stages (1–3) and no stage 0 — `git diff <rev>` must still list them
8219    // so combined `diff --cc` conflict hunks can be emitted (`t4108-apply-threeway`).
8220    let mut unmerged_only_paths: std::collections::BTreeSet<String> =
8221        std::collections::BTreeSet::new();
8222    for ie in &index.entries {
8223        if !(1..=3).contains(&ie.stage()) {
8224            continue;
8225        }
8226        if stage0_paths.contains(&ie.path) {
8227            continue;
8228        }
8229        unmerged_only_paths.insert(String::from_utf8_lossy(&ie.path).into_owned());
8230    }
8231
8232    // Union of tree paths + index paths
8233    let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
8234    all_paths.extend(tree_map.keys().cloned());
8235    all_paths.extend(index_paths.iter().cloned());
8236    all_paths.extend(unmerged_only_paths.iter().cloned());
8237
8238    let index_mtime = index_mtime_for_diff(index, None);
8239    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
8240    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
8241    let mut result = Vec::new();
8242
8243    for path in &all_paths {
8244        if index_entries
8245            .get(path.as_bytes())
8246            .is_some_and(|ie| ie.skip_worktree())
8247        {
8248            // Sparse checkout: `git diff <rev>` does not report tree↔worktree drift for
8249            // skip-worktree paths (they are outside the sparse cone). Matches t7012 stash flow.
8250            continue;
8251        }
8252        if index_entries
8253            .get(path.as_bytes())
8254            .is_some_and(|ie| ie.assume_unchanged())
8255        {
8256            continue;
8257        }
8258
8259        let tree_entry = tree_map.get(path.as_str());
8260
8261        // Gitlink entries (submodules) — compare HEAD commit, not file content.
8262        let is_gitlink = tree_entry.is_some_and(|te| te.mode == 0o160000)
8263            || index_entries
8264                .get(path.as_bytes())
8265                .is_some_and(|ie| ie.mode == 0o160000);
8266        if is_gitlink {
8267            let sub_dir = work_tree.join(path);
8268            let index_gitlink_oid = index_entries
8269                .get(path.as_bytes())
8270                .filter(|ie| ie.mode == 0o160000)
8271                .map(|ie| ie.oid);
8272            match (tree_entry, index_gitlink_oid) {
8273                (Some(te), _) => {
8274                    let sub_head = read_submodule_head_oid(&sub_dir);
8275                    // A gitlink whose worktree directory no longer exists is a deleted submodule:
8276                    // Git's `diff-lib.c` reports it as a deletion (status `D`, new mode 000000) and
8277                    // `--submodule` renders the `(submodule deleted)` summary (t4041 #46).
8278                    if sub_head.is_none() && !sub_dir.exists() {
8279                        result.push(DiffEntry {
8280                            status: DiffStatus::Deleted,
8281                            old_path: Some(path.clone()),
8282                            new_path: Some(path.clone()),
8283                            old_mode: FileMode::new(te.mode),
8284                            new_mode: FileMode::ABSENT,
8285                            old_oid: te.oid,
8286                            new_oid: zero_oid(),
8287                            score: None,
8288                        });
8289                        continue;
8290                    }
8291                    let ref_matches = if let Some(head) = sub_head {
8292                        head == te.oid
8293                    } else {
8294                        uninitialized_gitlink_worktree_matches_index(&sub_dir)
8295                    };
8296                    let index_matches_tree = index_gitlink_oid.is_some_and(|oid| oid == te.oid);
8297                    // Default tree↔worktree diff matches Git: untracked-only paths inside a
8298                    // checked-out submodule do not make the gitlink differ (see `git diff <tree>`).
8299                    let sub_flags = submodule_porcelain_flags(work_tree, path, te.oid);
8300                    let dirty_while_aligned =
8301                        index_matches_tree && ref_matches && sub_flags.modified;
8302                    if !ref_matches || dirty_while_aligned {
8303                        // Raw `git diff <tree>` lines use a null OID on the worktree side when the
8304                        // checked-out submodule HEAD differs from the tree's gitlink; patch output
8305                        // still resolves the real commit from the submodule directory.
8306                        let new_oid = if !ref_matches {
8307                            sub_head.unwrap_or_else(zero_oid)
8308                        } else {
8309                            zero_oid()
8310                        };
8311                        result.push(DiffEntry {
8312                            status: DiffStatus::Modified,
8313                            old_path: Some(path.clone()),
8314                            new_path: Some(path.clone()),
8315                            old_mode: FileMode::new(te.mode),
8316                            new_mode: FileMode::new(te.mode),
8317                            old_oid: te.oid,
8318                            new_oid,
8319                            score: None,
8320                        });
8321                    }
8322                }
8323                (None, Some(idx_oid)) => {
8324                    // Gitlink staged in the index but absent from the tree: a new submodule. Git
8325                    // reports it as an addition (status `A`) with the index gitlink on the new side,
8326                    // so `--submodule` renders `(new submodule)` (t4041 #46). The patch renderer
8327                    // resolves the real commit from the index OID / submodule HEAD.
8328                    let new_oid = read_submodule_head_oid(&sub_dir).unwrap_or(idx_oid);
8329                    result.push(DiffEntry {
8330                        status: DiffStatus::Added,
8331                        old_path: Some(path.clone()),
8332                        new_path: Some(path.clone()),
8333                        old_mode: FileMode::ABSENT,
8334                        new_mode: FileMode::new(0o160000),
8335                        old_oid: zero_oid(),
8336                        new_oid,
8337                        score: None,
8338                    });
8339                }
8340                (None, None) => {}
8341            }
8342            continue;
8343        }
8344
8345        let file_path = work_tree.join(path);
8346
8347        let wt_meta = match fs::symlink_metadata(&file_path) {
8348            Ok(m) => Some(m),
8349            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
8350            Err(e) => return Err(Error::Io(e)),
8351        };
8352
8353        if unmerged_only_paths.contains(path) {
8354            if let (Some(te), Some(meta)) = (tree_entry, wt_meta.as_ref()) {
8355                let tree_mode = crate::index::canon_mode(te.mode);
8356                let file_attrs = file_attrs_for(path);
8357                let wt_oid = hash_worktree_file(
8358                    odb,
8359                    &file_path,
8360                    meta,
8361                    &conv,
8362                    &file_attrs,
8363                    path,
8364                    None,
8365                    filter_fp,
8366                )?;
8367                let wt_mode =
8368                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(tree_mode));
8369                if wt_oid != te.oid || wt_mode != tree_mode {
8370                    result.push(DiffEntry {
8371                        status: DiffStatus::for_content_change(tree_mode, wt_mode),
8372                        old_path: Some(path.clone()),
8373                        new_path: Some(path.clone()),
8374                        old_mode: FileMode::new(tree_mode),
8375                        new_mode: FileMode::new(wt_mode),
8376                        old_oid: te.oid,
8377                        new_oid: wt_oid,
8378                        score: None,
8379                    });
8380                }
8381            }
8382            continue;
8383        }
8384
8385        match (tree_entry, wt_meta) {
8386            (Some(te), Some(ref meta)) => {
8387                let tree_mode = crate::index::canon_mode(te.mode);
8388                let Some(ie) = index_entries.get(path.as_bytes()) else {
8389                    continue;
8390                };
8391                let wt_mode =
8392                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(ie.mode));
8393
8394                let index_matches_tree = ie.oid == te.oid && ie.mode == tree_mode;
8395
8396                // Fully clean: index matches `HEAD`, worktree matches index, stat cache fresh.
8397                let stat_trusted = stat_matches_with_policy(ie, meta, stat_policy)
8398                    && !entry_is_racy_with_policy(ie, index_mtime, stat_policy);
8399                if index_matches_tree && stat_trusted && wt_mode == tree_mode {
8400                    continue;
8401                }
8402
8403                let file_attrs = file_attrs_for(path);
8404                let idx_ent = index_entries.get(path.as_bytes()).copied();
8405
8406                // Staged mode (same blob as `HEAD`, different mode recorded in the index) with
8407                // the worktree still matching the index. `core.filemode=false` does not discard
8408                // an explicit index chmod vs HEAD. Once the worktree drifts from the index, the
8409                // diff must describe the worktree side, so fall through.
8410                if ie.oid == te.oid && ie.mode != tree_mode && stat_trusted && wt_mode == ie.mode {
8411                    result.push(DiffEntry {
8412                        status: DiffStatus::for_content_change(tree_mode, ie.mode),
8413                        old_path: Some(path.clone()),
8414                        new_path: Some(path.clone()),
8415                        old_mode: FileMode::new(tree_mode),
8416                        new_mode: FileMode::new(ie.mode),
8417                        old_oid: te.oid,
8418                        new_oid: te.oid,
8419                        score: None,
8420                    });
8421                    continue;
8422                }
8423
8424                // Index still matches `HEAD`: only unstaged worktree drift (content and/or
8425                // worktree-only exec bit when `update-index` was not run — t4049 harness).
8426                if index_matches_tree {
8427                    let wt_oid = hash_worktree_file(
8428                        odb,
8429                        &file_path,
8430                        meta,
8431                        &conv,
8432                        &file_attrs,
8433                        path,
8434                        None,
8435                        filter_fp,
8436                    )?;
8437                    if wt_oid != te.oid {
8438                        result.push(DiffEntry {
8439                            status: DiffStatus::for_content_change(tree_mode, wt_mode),
8440                            old_path: Some(path.clone()),
8441                            new_path: Some(path.clone()),
8442                            old_mode: FileMode::new(tree_mode),
8443                            new_mode: FileMode::new(wt_mode),
8444                            old_oid: te.oid,
8445                            // Raw `git diff <tree>` uses a null OID on the worktree side (patch
8446                            // output resolves content from the working tree).
8447                            new_oid: zero_oid(),
8448                            score: None,
8449                        });
8450                    } else if wt_mode != tree_mode {
8451                        result.push(DiffEntry {
8452                            status: DiffStatus::for_content_change(tree_mode, wt_mode),
8453                            old_path: Some(path.clone()),
8454                            new_path: Some(path.clone()),
8455                            old_mode: FileMode::new(tree_mode),
8456                            new_mode: FileMode::new(wt_mode),
8457                            old_oid: te.oid,
8458                            new_oid: te.oid,
8459                            score: None,
8460                        });
8461                    }
8462                    continue;
8463                }
8464
8465                // Staged content (and possibly mode): `git diff <rev>` is tree vs working tree.
8466                let wt_oid = hash_worktree_file(
8467                    odb,
8468                    &file_path,
8469                    meta,
8470                    &conv,
8471                    &file_attrs,
8472                    path,
8473                    idx_ent,
8474                    filter_fp,
8475                )?;
8476                if wt_oid != te.oid || wt_mode != tree_mode {
8477                    let new_oid = if wt_oid != te.oid { zero_oid() } else { te.oid };
8478                    result.push(DiffEntry {
8479                        status: DiffStatus::for_content_change(tree_mode, wt_mode),
8480                        old_path: Some(path.clone()),
8481                        new_path: Some(path.clone()),
8482                        old_mode: FileMode::new(tree_mode),
8483                        new_mode: FileMode::new(wt_mode),
8484                        old_oid: te.oid,
8485                        new_oid,
8486                        score: None,
8487                    });
8488                }
8489            }
8490            (Some(te), None) => {
8491                let tree_mode = crate::index::canon_mode(te.mode);
8492                // In tree but missing from worktree
8493                result.push(DiffEntry {
8494                    status: DiffStatus::Deleted,
8495                    old_path: Some(path.clone()),
8496                    new_path: None,
8497                    old_mode: FileMode::new(tree_mode),
8498                    new_mode: FileMode::ABSENT,
8499                    old_oid: te.oid,
8500                    new_oid: zero_oid(),
8501                    score: None,
8502                });
8503            }
8504            (None, Some(ref meta)) => {
8505                // In index but not in tree, and exists in worktree
8506                let file_attrs = file_attrs_for(path);
8507                let idx_ent = index_entries.get(path.as_bytes()).copied();
8508                let wt_oid = materialize_worktree_blob(
8509                    odb,
8510                    &file_path,
8511                    meta,
8512                    &conv,
8513                    &file_attrs,
8514                    path,
8515                    idx_ent,
8516                    filter_fp,
8517                )?;
8518                let wt_mode = mode_from_metadata(meta);
8519                result.push(DiffEntry {
8520                    status: DiffStatus::Added,
8521                    old_path: None,
8522                    new_path: Some(path.clone()),
8523                    old_mode: FileMode::ABSENT,
8524                    new_mode: FileMode::new(wt_mode),
8525                    old_oid: zero_oid(),
8526                    new_oid: wt_oid,
8527                    score: None,
8528                });
8529            }
8530            (None, None) => {
8531                // Tracked in index but neither in tree nor worktree — skip
8532            }
8533        }
8534    }
8535
8536    sort_entries_by_path(&mut result);
8537    Ok(result)
8538}
8539
8540// ── Rename detection ────────────────────────────────────────────────
8541
8542fn read_added_entry_bytes(
8543    odb: &Odb,
8544    entry: &DiffEntry,
8545    work_root: Option<&Path>,
8546) -> Option<Vec<u8>> {
8547    if entry.new_oid != zero_oid() {
8548        if let Ok(obj) = odb.read(&entry.new_oid) {
8549            return Some(obj.data);
8550        }
8551    }
8552    let path = entry.new_path.as_deref()?;
8553    let root = work_root?;
8554    fs::read(root.join(path)).ok()
8555}
8556
8557/// Detect renames by pairing Deleted and Added entries with similar content.
8558///
8559/// `threshold` is the minimum similarity percentage (0–100) for a pair to
8560/// be considered a rename (Git's default is 50%).  The function reads blob
8561/// content from the ODB to compute a line-level similarity score.
8562///
8563/// Exact-OID matches are always 100% similar regardless of content.
8564///
8565/// When `work_root` is set, added entries whose `new_oid` is the zero placeholder (as in
8566/// uncached `diff-index` when the work tree diverged from the index) load content from disk
8567/// under that root instead of the object database.
8568#[allow(clippy::expect_used)]
8569pub fn detect_renames(
8570    odb: &Odb,
8571    work_root: Option<&Path>,
8572    entries: Vec<DiffEntry>,
8573    threshold: u32,
8574) -> Vec<DiffEntry> {
8575    let config = ConfigSet::new();
8576    let policy = ThreadPolicy::default();
8577    detect_renames_with_policy(odb, work_root, entries, threshold, &config, &policy, None)
8578        .expect("rename detection")
8579}
8580
8581#[inline]
8582fn poll_rename_cancel(
8583    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
8584) -> crate::error::Result<()> {
8585    if let Some(watch) = cancel_watch {
8586        crate::progress::poll_watch(watch)?;
8587    }
8588    Ok(())
8589}
8590
8591/// Like [`detect_renames`], but honors `index.threads` / `--threads` via `thread_policy`.
8592///
8593/// A list without both an added and a deleted entry has nothing to pair. When its paths
8594/// are also unique and in [`sort_entries_by_path`] order, as tree diffs return them, the
8595/// list comes back unchanged without reading blobs or allocating.
8596///
8597/// # Errors
8598///
8599/// Returns object database errors from reading candidate blobs, and the cancellation
8600/// error when `cancel_watch` is set.
8601pub fn detect_renames_with_policy(
8602    odb: &Odb,
8603    work_root: Option<&Path>,
8604    entries: Vec<DiffEntry>,
8605    threshold: u32,
8606    config: &ConfigSet,
8607    thread_policy: &ThreadPolicy,
8608    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
8609) -> Result<Vec<DiffEntry>> {
8610    poll_rename_cancel(cancel_watch)?;
8611    let has_deleted = entries.iter().any(|e| e.status == DiffStatus::Deleted);
8612    let has_added = entries.iter().any(|e| e.status == DiffStatus::Added);
8613    if (!has_deleted || !has_added) && entries_strictly_sorted_by_path(&entries) {
8614        // With unique sorted paths, regrouping and re-sorting would rebuild the same list.
8615        return Ok(entries);
8616    }
8617    // Split entries into deleted, added, and others.
8618    let mut deleted: Vec<DiffEntry> = Vec::new();
8619    let mut added: Vec<DiffEntry> = Vec::new();
8620    let mut others: Vec<DiffEntry> = Vec::new();
8621
8622    for entry in entries {
8623        match entry.status {
8624            DiffStatus::Deleted => deleted.push(entry),
8625            DiffStatus::Added => added.push(entry),
8626            _ => others.push(entry),
8627        }
8628    }
8629
8630    if deleted.is_empty() || added.is_empty() {
8631        // Nothing to pair — return original order.
8632        let mut result = others;
8633        result.extend(deleted);
8634        result.extend(added);
8635        sort_entries_by_path(&mut result);
8636        return Ok(result);
8637    }
8638
8639    fn is_regularish_mode(mode: FileMode) -> bool {
8640        mode == FileMode::REGULAR || mode == FileMode::EXECUTABLE
8641    }
8642
8643    fn same_path_same_blob(del: &DiffEntry, add: &DiffEntry) -> bool {
8644        del.old_path == add.new_path && del.old_oid == add.new_oid && del.old_mode == add.new_mode
8645    }
8646
8647    fn sort_rename_scores(
8648        scores: &mut [(u32, usize, usize)],
8649        deleted: &[DiffEntry],
8650        added: &[DiffEntry],
8651    ) {
8652        scores.sort_by(|a, b| {
8653            let a_noop = same_path_same_blob(&deleted[a.1], &added[a.2]);
8654            let b_noop = same_path_same_blob(&deleted[b.1], &added[b.2]);
8655            let a_same = same_basename(&deleted[a.1], &added[a.2]);
8656            let b_same = same_basename(&deleted[b.1], &added[b.2]);
8657            a_noop
8658                .cmp(&b_noop)
8659                .then_with(|| b_same.cmp(&a_same))
8660                .then_with(|| b.0.cmp(&a.0))
8661        });
8662    }
8663
8664    fn apply_rename_scores(
8665        scores: &[(u32, usize, usize)],
8666        deleted: &[DiffEntry],
8667        added: &[DiffEntry],
8668        used_deleted: &mut [bool],
8669        used_added: &mut [bool],
8670        renames: &mut Vec<DiffEntry>,
8671    ) {
8672        for (score, di, ai) in scores {
8673            if used_deleted[*di] || used_added[*ai] {
8674                continue;
8675            }
8676            used_deleted[*di] = true;
8677            used_added[*ai] = true;
8678
8679            let del = &deleted[*di];
8680            let add = &added[*ai];
8681            if same_path_same_blob(del, add) {
8682                continue;
8683            }
8684
8685            renames.push(DiffEntry {
8686                status: DiffStatus::Renamed,
8687                old_path: del.old_path.clone(),
8688                new_path: add.new_path.clone(),
8689                old_mode: del.old_mode,
8690                new_mode: add.new_mode,
8691                old_oid: del.old_oid,
8692                new_oid: add.new_oid,
8693                score: Some(*score),
8694            });
8695        }
8696    }
8697
8698    let mut used_deleted = vec![false; deleted.len()];
8699    let mut used_added = vec![false; added.len()];
8700    let mut renames: Vec<DiffEntry> = Vec::new();
8701
8702    // Git always runs exact rename detection, regardless of renameLimit.
8703    let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
8704    for (di, del) in deleted.iter().enumerate() {
8705        poll_rename_cancel(cancel_watch)?;
8706        for (ai, add) in added.iter().enumerate() {
8707            if del.old_oid == add.new_oid {
8708                exact_scores.push((100, di, ai));
8709            }
8710        }
8711    }
8712    sort_rename_scores(&mut exact_scores, &deleted, &added);
8713    apply_rename_scores(
8714        &exact_scores,
8715        &deleted,
8716        &added,
8717        &mut used_deleted,
8718        &mut used_added,
8719        &mut renames,
8720    );
8721
8722    let remaining_deleted: Vec<usize> = used_deleted
8723        .iter()
8724        .enumerate()
8725        .filter_map(|(i, used)| (!*used).then_some(i))
8726        .collect();
8727    let remaining_added: Vec<usize> = used_added
8728        .iter()
8729        .enumerate()
8730        .filter_map(|(i, used)| (!*used).then_some(i))
8731        .collect();
8732
8733    let rename_limit = parse_rename_limit(config);
8734    if !remaining_deleted.is_empty()
8735        && !remaining_added.is_empty()
8736        && !inexact_matrix_exceeds_limit(
8737            remaining_added.len(),
8738            remaining_deleted.len(),
8739            rename_limit,
8740        )
8741    {
8742        let par_opts = diff_rename_parallel_options(config, thread_policy);
8743
8744        let mut prefetch_entries: Vec<DiffEntry> =
8745            Vec::with_capacity(remaining_deleted.len() + remaining_added.len());
8746        for &di in &remaining_deleted {
8747            prefetch_entries.push(deleted[di].clone());
8748        }
8749        for &ai in &remaining_added {
8750            prefetch_entries.push(added[ai].clone());
8751        }
8752        super::tree_content::prefetch_tree_diff_blobs(odb, &prefetch_entries)?;
8753
8754        let deleted_sizes: Vec<Option<usize>> = remaining_deleted
8755            .iter()
8756            .map(|&di| blob_payload_size(odb, &deleted[di].old_oid))
8757            .collect();
8758        let added_sizes: Vec<Option<usize>> = remaining_added
8759            .iter()
8760            .map(|&ai| added_entry_payload_size(odb, &added[ai], work_root))
8761            .collect();
8762
8763        let mut load_deleted = vec![false; remaining_deleted.len()];
8764        let mut load_added = vec![false; remaining_added.len()];
8765        let mut candidate_pairs: Vec<(usize, usize)> = Vec::new();
8766        for (row, &di) in remaining_deleted.iter().enumerate() {
8767            poll_rename_cancel(cancel_watch)?;
8768            let del = &deleted[di];
8769            if !is_regularish_mode(del.old_mode) {
8770                continue;
8771            }
8772            let Some(ds) = deleted_sizes[row] else {
8773                continue;
8774            };
8775            for (col, &ai) in remaining_added.iter().enumerate() {
8776                let add = &added[ai];
8777                if !is_regularish_mode(add.new_mode) {
8778                    continue;
8779                }
8780                let Some(asz) = added_sizes[col] else {
8781                    continue;
8782                };
8783                if rename_pair_size_reachable(ds, asz, threshold) {
8784                    load_deleted[row] = true;
8785                    load_added[col] = true;
8786                    candidate_pairs.push((row, col));
8787                }
8788            }
8789        }
8790
8791        if !candidate_pairs.is_empty() {
8792            let loads_needed = load_deleted.iter().filter(|&&b| b).count()
8793                + load_added.iter().filter(|&&b| b).count();
8794            let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
8795                if !load_deleted[i] {
8796                    return None;
8797                }
8798                odb.read(&deleted[remaining_deleted[i]].old_oid)
8799                    .ok()
8800                    .map(|obj| obj.data)
8801            };
8802            let load_added_blob = |i: usize| -> Option<Vec<u8>> {
8803                if !load_added[i] {
8804                    return None;
8805                }
8806                read_added_entry_bytes(odb, &added[remaining_added[i]], work_root)
8807            };
8808            let (deleted_contents, added_contents) = if loads_needed <= 32 {
8809                (
8810                    (0..remaining_deleted.len())
8811                        .map(load_deleted_blob)
8812                        .collect(),
8813                    (0..remaining_added.len()).map(load_added_blob).collect(),
8814                )
8815            } else {
8816                (
8817                    parallel_load_optional_blobs(
8818                        odb,
8819                        remaining_deleted.len(),
8820                        par_opts,
8821                        0,
8822                        load_deleted_blob,
8823                    ),
8824                    parallel_load_optional_blobs(
8825                        odb,
8826                        remaining_added.len(),
8827                        par_opts,
8828                        0,
8829                        load_added_blob,
8830                    ),
8831                )
8832            };
8833
8834            let est_table_bytes: usize = deleted_contents
8835                .iter()
8836                .chain(added_contents.iter())
8837                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
8838                .sum();
8839            let build_deleted_table = |row: usize| -> std::result::Result<
8840                Option<RenameLineTable>,
8841                std::convert::Infallible,
8842            > {
8843                Ok(deleted_contents[row]
8844                    .as_ref()
8845                    .map(|d| RenameLineTable::from_blob(d)))
8846            };
8847            let build_added_table = |col: usize| -> std::result::Result<
8848                Option<RenameLineTable>,
8849                std::convert::Infallible,
8850            > {
8851                Ok(added_contents[col]
8852                    .as_ref()
8853                    .map(|d| RenameLineTable::from_blob(d)))
8854            };
8855            let deleted_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
8856                (0..remaining_deleted.len())
8857                    .map(|row| build_deleted_table(row).unwrap())
8858                    .collect()
8859            } else {
8860                parallel_map_indices(
8861                    odb,
8862                    remaining_deleted.len(),
8863                    par_opts,
8864                    est_table_bytes,
8865                    build_deleted_table,
8866                )
8867                .expect("deleted rename line tables")
8868            };
8869            let added_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
8870                (0..remaining_added.len())
8871                    .map(|col| build_added_table(col).unwrap())
8872                    .collect()
8873            } else {
8874                parallel_map_indices(
8875                    odb,
8876                    remaining_added.len(),
8877                    par_opts,
8878                    est_table_bytes,
8879                    build_added_table,
8880                )
8881                .expect("added rename line tables")
8882            };
8883
8884            let mut inexact_scores: Vec<(u32, usize, usize)> = Vec::new();
8885            for (row, col) in candidate_pairs {
8886                poll_rename_cancel(cancel_watch)?;
8887                let di = remaining_deleted[row];
8888                let ai = remaining_added[col];
8889                let (Some(old_table), Some(new_table)) = (&deleted_tables[row], &added_tables[col])
8890                else {
8891                    continue;
8892                };
8893                let score = RenameLineTable::similarity_score(old_table, new_table);
8894                if score >= threshold {
8895                    inexact_scores.push((score, di, ai));
8896                }
8897            }
8898            sort_rename_scores(&mut inexact_scores, &deleted, &added);
8899            apply_rename_scores(
8900                &inexact_scores,
8901                &deleted,
8902                &added,
8903                &mut used_deleted,
8904                &mut used_added,
8905                &mut renames,
8906            );
8907        }
8908    }
8909
8910    // Collect unmatched entries.
8911    let mut result = others;
8912    result.extend(renames);
8913    for (i, entry) in deleted.into_iter().enumerate() {
8914        if !used_deleted[i] {
8915            result.push(entry);
8916        }
8917    }
8918    for (i, entry) in added.into_iter().enumerate() {
8919        if !used_added[i] {
8920            result.push(entry);
8921        }
8922    }
8923
8924    result = prune_break_deletions_obviated_by_renames(result);
8925    sort_entries_by_path(&mut result);
8926    Ok(result)
8927}
8928
8929/// Detect copies among diff entries.
8930///
8931/// This first runs rename detection (pairing Deleted+Added), then for any
8932/// remaining Added entries, looks for copy sources.
8933///
8934/// - `find_copies_harder` = false: only Modified entries are copy source candidates.
8935/// - `find_copies_harder` = true: also examine unmodified files from `source_tree_entries`.
8936///
8937/// `source_tree_entries` should be a list of (path, mode, oid) from the source tree;
8938/// used when `find_copies_harder` is true to consider unmodified files as copy sources.
8939/// # Errors
8940///
8941/// Returns [`Error::HashCollision`] when hashing worktree blob content for copy detection
8942/// would produce a known-bad SHA-1 object id.
8943pub fn detect_copies(
8944    odb: &Odb,
8945    work_root: Option<&Path>,
8946    entries: Vec<DiffEntry>,
8947    threshold: u32,
8948    find_copies_harder: bool,
8949    source_tree_entries: &[(String, FileMode, ObjectId)],
8950) -> Result<Vec<DiffEntry>> {
8951    let config = ConfigSet::new();
8952    let policy = ThreadPolicy::default();
8953    detect_copies_with_policy(
8954        odb,
8955        work_root,
8956        entries,
8957        threshold,
8958        find_copies_harder,
8959        source_tree_entries,
8960        &config,
8961        &policy,
8962    )
8963}
8964
8965/// Like [`detect_copies`], but honors parallel thread policy and rename limits.
8966///
8967/// # Errors
8968///
8969/// Same as [`detect_copies`].
8970#[expect(clippy::too_many_arguments)]
8971pub fn detect_copies_with_policy(
8972    odb: &Odb,
8973    work_root: Option<&Path>,
8974    entries: Vec<DiffEntry>,
8975    threshold: u32,
8976    find_copies_harder: bool,
8977    source_tree_entries: &[(String, FileMode, ObjectId)],
8978    config: &ConfigSet,
8979    thread_policy: &ThreadPolicy,
8980) -> Result<Vec<DiffEntry>> {
8981    use std::collections::{HashMap, HashSet};
8982
8983    // Separate entries by status.
8984    let mut deleted: Vec<DiffEntry> = Vec::new();
8985    let mut added: Vec<DiffEntry> = Vec::new();
8986    let mut others: Vec<DiffEntry> = Vec::new();
8987
8988    for entry in entries {
8989        match entry.status {
8990            DiffStatus::Deleted => deleted.push(entry),
8991            DiffStatus::Added => added.push(entry),
8992            _ => others.push(entry),
8993        }
8994    }
8995
8996    // Build source candidates: deleted files, modified files, and optionally tree entries.
8997    // Track which sources are from deleted files (can become renames).
8998    let mut sources: Vec<(String, ObjectId, bool)> = Vec::new(); // (path, oid, is_deleted)
8999    let mut deleted_source_idx: HashMap<String, usize> = HashMap::new();
9000
9001    for entry in &deleted {
9002        if let Some(ref path) = entry.old_path {
9003            deleted_source_idx.insert(path.clone(), sources.len());
9004            sources.push((path.clone(), entry.old_oid, true));
9005        }
9006    }
9007
9008    // Modified and type-changed files are candidates for `-C` (e.g. symlink rewrite leaves the
9009    // old blob available as a copy source for another path; see `t4008-diff-break-rewrite`).
9010    for entry in &others {
9011        if matches!(entry.status, DiffStatus::Modified | DiffStatus::TypeChanged) {
9012            if let Some(ref old_path) = entry.old_path {
9013                if !sources.iter().any(|(p, _, _)| p == old_path) {
9014                    sources.push((old_path.clone(), entry.old_oid, false));
9015                }
9016            }
9017        }
9018    }
9019
9020    // With find_copies_harder, add all source tree entries.
9021    if find_copies_harder {
9022        for (path, _mode, oid) in source_tree_entries {
9023            if !sources.iter().any(|(p, _, _)| p == path) {
9024                sources.push((path.clone(), *oid, false));
9025            }
9026        }
9027    }
9028
9029    // Modes of every source, for the old side of copy and rename entries: the deleted and
9030    // modified entries carry their own, and `source_tree_entries` the rest.
9031    let mut source_modes: Vec<(String, FileMode, ObjectId)> = source_tree_entries.to_vec();
9032    for entry in deleted.iter().chain(&others) {
9033        if let Some(ref path) = entry.old_path {
9034            if !source_modes.iter().any(|(p, _, _)| p == path) {
9035                source_modes.push((path.clone(), entry.old_mode, entry.old_oid));
9036            }
9037        }
9038    }
9039
9040    if sources.is_empty() {
9041        let mut result = others;
9042        result.extend(deleted);
9043        result.extend(added);
9044        sort_entries_by_path(&mut result);
9045        return Ok(result);
9046    }
9047
9048    let par_opts = diff_rename_parallel_options(config, thread_policy);
9049    let rename_limit = parse_rename_limit(config);
9050
9051    fn apply_copy_score_assignments(
9052        scores: &[(u32, usize, usize)],
9053        sources: &[(String, ObjectId, bool)],
9054        added: &[DiffEntry],
9055        source_tree_entries: &[(String, FileMode, ObjectId)],
9056        used_added2: &mut [bool],
9057        renamed_deleted: &mut HashSet<usize>,
9058        result_entries: &mut Vec<DiffEntry>,
9059    ) {
9060        let mut used_added = vec![false; added.len()];
9061        let mut source_to_added: HashMap<usize, Vec<(usize, u32)>> = HashMap::new();
9062        for &(score, si, ai) in scores {
9063            if used_added[ai] {
9064                continue;
9065            }
9066            used_added[ai] = true;
9067            source_to_added.entry(si).or_default().push((ai, score));
9068        }
9069
9070        for (&si, assignments_for_src) in &source_to_added {
9071            let (_, _, is_deleted) = &sources[si];
9072            if *is_deleted && !assignments_for_src.is_empty() {
9073                let rename_ai = assignments_for_src
9074                    .iter()
9075                    .max_by_key(|(ai, _score)| added[*ai].path().to_string())
9076                    .map(|(ai, _)| *ai);
9077
9078                for &(ai, score) in assignments_for_src {
9079                    let (ref src_path, _, _) = sources[si];
9080                    let add = &added[ai];
9081                    let src_mode = source_tree_entries
9082                        .iter()
9083                        .find(|(p, _, _)| p == src_path)
9084                        .map_or(add.old_mode, |(_, m, _)| *m);
9085
9086                    let is_rename = Some(ai) == rename_ai;
9087                    result_entries.push(DiffEntry {
9088                        status: if is_rename {
9089                            DiffStatus::Renamed
9090                        } else {
9091                            DiffStatus::Copied
9092                        },
9093                        old_path: Some(src_path.clone()),
9094                        new_path: add.new_path.clone(),
9095                        old_mode: src_mode,
9096                        new_mode: add.new_mode,
9097                        old_oid: sources[si].1,
9098                        new_oid: add.new_oid,
9099                        score: Some(score),
9100                    });
9101                    used_added2[ai] = true;
9102                }
9103                renamed_deleted.insert(si);
9104            } else {
9105                for &(ai, score) in assignments_for_src {
9106                    let (ref src_path, _, _) = sources[si];
9107                    let add = &added[ai];
9108                    let src_mode = source_tree_entries
9109                        .iter()
9110                        .find(|(p, _, _)| p == src_path)
9111                        .map_or(add.old_mode, |(_, m, _)| *m);
9112
9113                    result_entries.push(DiffEntry {
9114                        status: DiffStatus::Copied,
9115                        old_path: Some(src_path.clone()),
9116                        new_path: add.new_path.clone(),
9117                        old_mode: src_mode,
9118                        new_mode: add.new_mode,
9119                        old_oid: sources[si].1,
9120                        new_oid: add.new_oid,
9121                        score: Some(score),
9122                    });
9123                    used_added2[ai] = true;
9124                }
9125            }
9126        }
9127    }
9128
9129    fn added_entry_oid(
9130        odb: &Odb,
9131        add: &DiffEntry,
9132        added_contents: &[Option<Vec<u8>>],
9133        ai: usize,
9134    ) -> Result<ObjectId> {
9135        if add.new_oid != zero_oid() {
9136            Ok(add.new_oid)
9137        } else if let Some(ref data) = added_contents[ai] {
9138            odb.hash(ObjectKind::Blob, data)
9139        } else {
9140            Ok(zero_oid())
9141        }
9142    }
9143
9144    let mut result_entries: Vec<DiffEntry> = Vec::new();
9145    let mut renamed_deleted: HashSet<usize> = HashSet::new();
9146    let mut used_added2 = vec![false; added.len()];
9147    let mut source_contents = vec![None; sources.len()];
9148
9149    if !added.is_empty() {
9150        let added_contents = parallel_load_optional_blobs(odb, added.len(), par_opts, 0, |ai| {
9151            read_added_entry_bytes(odb, &added[ai], work_root)
9152        });
9153
9154        // Git always scores exact OID copy/rename pairs before applying renameLimit.
9155        let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
9156        for (si, (src_path, src_oid, _)) in sources.iter().enumerate() {
9157            for (ai, add) in added.iter().enumerate() {
9158                if add.new_path.as_deref() == Some(src_path.as_str()) {
9159                    continue;
9160                }
9161                let add_oid = added_entry_oid(odb, add, &added_contents, ai)?;
9162                if *src_oid == add_oid && add_oid != zero_oid() {
9163                    exact_scores.push((100, si, ai));
9164                }
9165            }
9166        }
9167        exact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
9168        apply_copy_score_assignments(
9169            &exact_scores,
9170            &sources,
9171            &added,
9172            &source_modes,
9173            &mut used_added2,
9174            &mut renamed_deleted,
9175            &mut result_entries,
9176        );
9177
9178        let remaining_added: Vec<usize> = used_added2
9179            .iter()
9180            .enumerate()
9181            .filter_map(|(i, used)| (!*used).then_some(i))
9182            .collect();
9183        let remaining_source_count = sources
9184            .iter()
9185            .enumerate()
9186            .filter(|(si, (_, _, is_deleted))| !*is_deleted || !renamed_deleted.contains(si))
9187            .count();
9188
9189        let skip_inexact = remaining_added.is_empty()
9190            || inexact_matrix_exceeds_limit(
9191                remaining_added.len(),
9192                remaining_source_count,
9193                rename_limit,
9194            );
9195
9196        if !skip_inexact {
9197            source_contents = parallel_load_optional_blobs(odb, sources.len(), par_opts, 0, |si| {
9198                odb.read(&sources[si].1).ok().map(|obj| obj.data)
9199            });
9200
9201            let est_bytes: usize = source_contents
9202                .iter()
9203                .chain(added_contents.iter())
9204                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
9205                .sum();
9206
9207            let source_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
9208                odb,
9209                sources.len(),
9210                par_opts,
9211                est_bytes,
9212                |si| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
9213                    Ok(source_contents[si]
9214                        .as_ref()
9215                        .map(|d| RenameLineTable::from_blob(d)))
9216                },
9217            )
9218            .expect("copy source line tables");
9219            let added_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
9220                odb,
9221                added.len(),
9222                par_opts,
9223                est_bytes,
9224                |ai| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
9225                    Ok(added_contents[ai]
9226                        .as_ref()
9227                        .map(|d| RenameLineTable::from_blob(d)))
9228                },
9229            )
9230            .expect("copy added line tables");
9231
9232            let score_rows: Vec<Vec<(u32, usize, usize)>> = parallel_map_indices(
9233                odb,
9234                sources.len(),
9235                par_opts,
9236                est_bytes,
9237                |si| -> std::result::Result<Vec<(u32, usize, usize)>, std::convert::Infallible> {
9238                    if renamed_deleted.contains(&si) {
9239                        return Ok(Vec::new());
9240                    }
9241                    let (src_path, _, _) = &sources[si];
9242                    let mut row: Vec<(u32, usize, usize)> = Vec::new();
9243                    for &ai in &remaining_added {
9244                        let add = &added[ai];
9245                        if add.new_path.as_deref() == Some(src_path.as_str()) {
9246                            continue;
9247                        }
9248                        let score = match (&source_tables[si], &added_tables[ai]) {
9249                            (Some(old_table), Some(new_table)) => {
9250                                RenameLineTable::similarity_score(old_table, new_table)
9251                            }
9252                            _ => 0,
9253                        };
9254                        if score >= threshold {
9255                            row.push((score, si, ai));
9256                        }
9257                    }
9258                    Ok(row)
9259                },
9260            )
9261            .expect("copy score rows");
9262            let mut inexact_scores: Vec<(u32, usize, usize)> =
9263                score_rows.into_iter().flatten().collect();
9264            inexact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
9265            apply_copy_score_assignments(
9266                &inexact_scores,
9267                &sources,
9268                &added,
9269                &source_modes,
9270                &mut used_added2,
9271                &mut renamed_deleted,
9272                &mut result_entries,
9273            );
9274        }
9275    }
9276
9277    // Keep deleted entries that weren't consumed by a rename.
9278    for entry in deleted.into_iter() {
9279        if let Some(ref path) = entry.old_path {
9280            if let Some(&si) = deleted_source_idx.get(path) {
9281                if renamed_deleted.contains(&si) {
9282                    // This deletion was consumed by a rename; skip it.
9283                    continue;
9284                }
9285            }
9286        }
9287        result_entries.push(entry);
9288    }
9289
9290    let mut result = others;
9291    result.extend(result_entries);
9292    // Keep unmatched added entries.
9293    for (i, entry) in added.into_iter().enumerate() {
9294        if !used_added2[i] {
9295            result.push(entry);
9296        }
9297    }
9298
9299    // Like `git diff -C`, only created paths become copies: a modification is a copy
9300    // destination only after `-B` splits it into a deletion and an addition, which
9301    // `postprocess_diff_entries` does before calling this.
9302    let mut final_result = result;
9303    sort_entries_by_path(&mut final_result);
9304    Ok(final_result)
9305}
9306
9307/// Options for Git-style rename, copy, and break-rewrite post-processing on tree diffs.
9308#[derive(Debug, Clone, Copy)]
9309pub struct DiffPostprocessOptions {
9310    /// When set, `--break-rewrites` threshold on Git's internal 0–[`GIT_DIFF_MAX_SCORE`] scale
9311    /// (`None` = off).
9312    pub break_score: Option<u64>,
9313    /// `-M` similarity threshold (0–100); `100` disables rename detection.
9314    pub rename_threshold: u32,
9315    /// `-C` copy threshold (0–100); `None` skips copy detection.
9316    pub copy_threshold: Option<u32>,
9317    /// `-C --find-copies-harder`: treat unmodified source-tree paths as copy sources.
9318    pub find_copies_harder: bool,
9319    /// When break-rewrite runs on tree↔worktree diffs, fill `new_oid` from worktree bytes for
9320    /// `--raw` output (matches `git diff <tree> -B`; leave false for index↔worktree / diff-files).
9321    pub materialize_break_worktree_new_oid: bool,
9322}
9323
9324impl Default for DiffPostprocessOptions {
9325    fn default() -> Self {
9326        Self {
9327            break_score: None,
9328            rename_threshold: 100,
9329            copy_threshold: None,
9330            find_copies_harder: false,
9331            materialize_break_worktree_new_oid: false,
9332        }
9333    }
9334}
9335
9336/// Index and git directory used to read canonical (clean) worktree bytes during break-rewrite
9337/// post-processing on tree↔worktree diffs.
9338#[derive(Debug, Clone, Copy)]
9339pub struct DiffPostprocessWorktree<'a> {
9340    /// Git directory for config and attributes (linked worktrees / gitfiles).
9341    pub repository_git_dir: &'a Path,
9342    /// Current index; stage-0 entries supply autocrlf index-guard blobs when needed.
9343    pub index: &'a Index,
9344}
9345
9346fn blob_bytes(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
9347    Ok(odb.read(oid)?.data)
9348}
9349
9350/// Bytes on the "new" side of a diff entry (ODB blob, or worktree file when `new_oid` is null).
9351///
9352/// When `worktree` is set, worktree paths are normalized with the same attribute/filter rules as
9353/// [`diff_tree_to_worktree`]; otherwise raw filesystem bytes are read.
9354fn read_new_side_bytes(
9355    odb: &Odb,
9356    entry: &DiffEntry,
9357    work_root: Option<&Path>,
9358    worktree: Option<&DiffPostprocessWorktree<'_>>,
9359    config: &ConfigSet,
9360) -> Result<Vec<u8>> {
9361    if entry.new_oid != zero_oid() {
9362        return blob_bytes(odb, &entry.new_oid);
9363    }
9364    let path = entry.new_path.as_deref().ok_or_else(|| {
9365        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
9366    })?;
9367    let root = work_root.ok_or_else(|| {
9368        Error::ObjectNotFound(format!(
9369            "worktree bytes required for {} but no work_root was provided",
9370            path
9371        ))
9372    })?;
9373    if let Some(wctx) = worktree {
9374        let mode = entry.new_mode.bits();
9375        let index_entry = wctx.index.get(path.as_bytes(), 0);
9376        return read_worktree_side_bytes_for_diff(odb, root, path, mode, config, index_entry, None);
9377    }
9378    fs::read(root.join(path)).map_err(Error::Io)
9379}
9380
9381/// True when clean-smudged worktree bytes differ from the raw on-disk file (e.g. `eol=lf` + CRLF).
9382fn worktree_side_differs_after_normalize(
9383    odb: &Odb,
9384    entry: &DiffEntry,
9385    work_root: &Path,
9386    worktree: &DiffPostprocessWorktree<'_>,
9387    config: &ConfigSet,
9388) -> Result<bool> {
9389    let path = entry.new_path.as_deref().ok_or_else(|| {
9390        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
9391    })?;
9392    let raw = fs::read(work_root.join(path)).map_err(Error::Io)?;
9393    let canonical = read_new_side_bytes(odb, entry, Some(work_root), Some(worktree), config)?;
9394    Ok(raw != canonical)
9395}
9396
9397fn split_broken_modifications(
9398    odb: &Odb,
9399    work_root: Option<&Path>,
9400    entries: Vec<DiffEntry>,
9401    break_score: u64,
9402    worktree: Option<&DiffPostprocessWorktree<'_>>,
9403    config: &ConfigSet,
9404) -> Result<Vec<DiffEntry>> {
9405    let mut out = Vec::with_capacity(entries.len());
9406    for e in entries {
9407        if e.status != DiffStatus::Modified {
9408            out.push(e);
9409            continue;
9410        }
9411        let old = blob_bytes(odb, &e.old_oid)?;
9412        let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
9413        if should_break_rewrite_pair(&old, &new, break_score) {
9414            let path = e.path().to_owned();
9415            out.push(DiffEntry {
9416                status: DiffStatus::Deleted,
9417                old_path: Some(path.clone()),
9418                new_path: None,
9419                old_mode: e.old_mode,
9420                new_mode: FileMode::ABSENT,
9421                old_oid: e.old_oid,
9422                new_oid: zero_oid(),
9423                score: None,
9424            });
9425            out.push(DiffEntry {
9426                status: DiffStatus::Added,
9427                old_path: None,
9428                new_path: Some(path),
9429                old_mode: FileMode::ABSENT,
9430                new_mode: e.new_mode,
9431                old_oid: zero_oid(),
9432                new_oid: e.new_oid,
9433                score: None,
9434            });
9435        } else {
9436            out.push(e);
9437        }
9438    }
9439    Ok(out)
9440}
9441
9442fn annotate_broken_modifications(
9443    odb: &Odb,
9444    work_root: Option<&Path>,
9445    entries: Vec<DiffEntry>,
9446    break_score: u64,
9447    worktree: Option<&DiffPostprocessWorktree<'_>>,
9448    config: &ConfigSet,
9449    materialize_worktree_oid: bool,
9450) -> Result<Vec<DiffEntry>> {
9451    let mut out = Vec::with_capacity(entries.len());
9452    for mut e in entries {
9453        if e.status == DiffStatus::Modified {
9454            let old = blob_bytes(odb, &e.old_oid)?;
9455            let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
9456            if should_break_rewrite_pair(&old, &new, break_score) {
9457                // `git diff-files -B` keeps plain `M` when the worktree side is still null.
9458                // `git diff <tree> -B` materializes the clean OID; `M###` appears only when
9459                // attributes/filters changed the bytes used for break scoring (e.g. `eol=lf`).
9460                let show_score = if e.new_oid != zero_oid() {
9461                    true
9462                } else if materialize_worktree_oid {
9463                    match (work_root, worktree) {
9464                        (Some(root), Some(wctx)) => {
9465                            worktree_side_differs_after_normalize(odb, &e, root, wctx, config)?
9466                        }
9467                        _ => false,
9468                    }
9469                } else {
9470                    false
9471                };
9472                if show_score {
9473                    e.score = rewrite_dissimilarity_index_percent(&old, &new);
9474                }
9475            }
9476        }
9477        out.push(e);
9478    }
9479    Ok(out)
9480}
9481
9482fn materialize_break_worktree_new_oids(
9483    odb: &Odb,
9484    work_root: Option<&Path>,
9485    entries: &mut [DiffEntry],
9486    worktree: Option<&DiffPostprocessWorktree<'_>>,
9487    config: &ConfigSet,
9488) -> Result<()> {
9489    for e in entries {
9490        if e.new_oid != zero_oid() {
9491            continue;
9492        }
9493        if !matches!(e.status, DiffStatus::Modified | DiffStatus::Added) {
9494            continue;
9495        }
9496        let bytes = read_new_side_bytes(odb, e, work_root, worktree, config)?;
9497        e.new_oid = odb.hash(ObjectKind::Blob, &bytes)?;
9498    }
9499    Ok(())
9500}
9501
9502/// After break-rewrite splits a modified path into delete+add, rename detection may pair the add
9503/// with another deletion (`g` → `f`). Git drops the unused delete at the rename target path.
9504fn prune_break_deletions_obviated_by_renames(entries: Vec<DiffEntry>) -> Vec<DiffEntry> {
9505    use std::collections::HashSet;
9506    let rename_targets: HashSet<String> = entries
9507        .iter()
9508        .filter(|e| e.status == DiffStatus::Renamed)
9509        .filter_map(|e| e.new_path.clone())
9510        .collect();
9511    if rename_targets.is_empty() {
9512        return entries;
9513    }
9514    entries
9515        .into_iter()
9516        .filter(|e| {
9517            if e.status != DiffStatus::Deleted {
9518                return true;
9519            }
9520            e.old_path
9521                .as_ref()
9522                .is_none_or(|p| !rename_targets.contains(p))
9523        })
9524        .collect()
9525}
9526
9527/// Apply break-rewrite, rename, and copy detection to a flat tree or index diff.
9528///
9529/// `source_tree_oid` supplies `--find-copies-harder` candidates; `work_root` is used when added
9530/// entries carry a zero OID (index↔worktree). Pass `worktree` when break-rewrite runs on a
9531/// tree↔worktree diff so worktree-side bytes honor attributes and filters.
9532///
9533/// # Errors
9534///
9535/// Propagates object read failures while evaluating break scores.
9536#[allow(clippy::too_many_arguments)]
9537pub fn postprocess_diff_entries(
9538    odb: &Odb,
9539    entries: Vec<DiffEntry>,
9540    source_tree_oid: Option<&ObjectId>,
9541    work_root: Option<&Path>,
9542    worktree: Option<&DiffPostprocessWorktree<'_>>,
9543    config: &ConfigSet,
9544    thread_policy: &ThreadPolicy,
9545    opts: &DiffPostprocessOptions,
9546) -> Result<Vec<DiffEntry>> {
9547    let mut entries = entries;
9548    if let Some(break_score) = opts.break_score {
9549        let rename_or_copy = opts.rename_threshold < 100 || opts.copy_threshold.is_some();
9550        entries = if rename_or_copy {
9551            split_broken_modifications(odb, work_root, entries, break_score, worktree, config)?
9552        } else {
9553            annotate_broken_modifications(
9554                odb,
9555                work_root,
9556                entries,
9557                break_score,
9558                worktree,
9559                config,
9560                opts.materialize_break_worktree_new_oid,
9561            )?
9562        };
9563        if opts.materialize_break_worktree_new_oid {
9564            materialize_break_worktree_new_oids(odb, work_root, &mut entries, worktree, config)?;
9565        }
9566    }
9567
9568    let source_tree_entries = source_tree_oid
9569        .map(|t| flatten_tree_entries(odb, t))
9570        .transpose()?
9571        .unwrap_or_default();
9572
9573    if opts.copy_threshold.is_some() || opts.find_copies_harder {
9574        let threshold = opts.copy_threshold.unwrap_or(opts.rename_threshold.min(99));
9575        detect_copies_with_policy(
9576            odb,
9577            work_root,
9578            entries,
9579            threshold,
9580            opts.find_copies_harder,
9581            &source_tree_entries,
9582            config,
9583            thread_policy,
9584        )
9585    } else if opts.rename_threshold < 100 {
9586        detect_renames_with_policy(
9587            odb,
9588            work_root,
9589            entries,
9590            opts.rename_threshold,
9591            config,
9592            thread_policy,
9593            None,
9594        )
9595    } else {
9596        Ok(entries)
9597    }
9598}
9599
9600/// Depth-first list of `(path, mode, oid)` blob entries in a tree (for copy detection).
9601pub fn flatten_tree_entries(
9602    odb: &Odb,
9603    tree_oid: &ObjectId,
9604) -> Result<Vec<(String, FileMode, ObjectId)>> {
9605    fn walk(
9606        odb: &Odb,
9607        tree_oid: &ObjectId,
9608        prefix: &str,
9609        out: &mut Vec<(String, FileMode, ObjectId)>,
9610    ) -> Result<()> {
9611        let obj = odb.read(tree_oid)?;
9612        let oid_len = odb.hash_algo().len();
9613        for e in TreeRefIter::with_oid_len(&obj.data, oid_len) {
9614            let e = e?;
9615            let name = String::from_utf8_lossy(e.name);
9616            let path = if prefix.is_empty() {
9617                name.to_string()
9618            } else {
9619                format!("{prefix}/{name}")
9620            };
9621            if e.mode == 0o040000 {
9622                walk(odb, &e.oid, &path, out)?;
9623            } else {
9624                out.push((path, FileMode::new(e.mode), e.oid));
9625            }
9626        }
9627        Ok(())
9628    }
9629    let mut out = Vec::new();
9630    walk(odb, tree_oid, "", &mut out)?;
9631    Ok(out)
9632}
9633
9634/// Apply Git-style rename and optional copy detection for index↔worktree diffs.
9635///
9636/// When `copies` is true (Git `diff.renames` / `status.renames` set to `copy`/`copies`),
9637/// runs copy detection, which also pairs deleted sources with one rename and any additional
9638/// destinations as copies.
9639///
9640/// # Errors
9641///
9642/// Propagates errors from reading the `head_tree` object from `odb`.
9643#[allow(clippy::too_many_arguments)]
9644pub fn status_apply_rename_copy_detection(
9645    odb: &Odb,
9646    unstaged_raw: Vec<DiffEntry>,
9647    threshold: u32,
9648    copies: bool,
9649    head_tree: Option<&ObjectId>,
9650    config: &ConfigSet,
9651    thread_policy: &ThreadPolicy,
9652    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
9653) -> Result<Vec<DiffEntry>> {
9654    if !copies {
9655        return detect_renames_with_policy(
9656            odb,
9657            None,
9658            unstaged_raw,
9659            threshold,
9660            config,
9661            thread_policy,
9662            cancel_watch,
9663        );
9664    }
9665    let source_tree_entries: Vec<(String, FileMode, ObjectId)> = match head_tree {
9666        Some(oid) => flatten_tree(odb, oid, "")?
9667            .into_iter()
9668            .map(|e| (e.path, FileMode::new(e.mode), e.oid))
9669            .collect(),
9670        None => Vec::new(),
9671    };
9672    detect_copies_with_policy(
9673        odb,
9674        None,
9675        unstaged_raw,
9676        threshold,
9677        false,
9678        &source_tree_entries,
9679        config,
9680        thread_policy,
9681    )
9682}
9683
9684/// Check if two entries share the same filename (basename).
9685fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
9686    let old = del.old_path.as_deref().unwrap_or("");
9687    let new = add.new_path.as_deref().unwrap_or("");
9688    let old_base = old.rsplit('/').next().unwrap_or(old);
9689    let new_base = new.rsplit('/').next().unwrap_or(new);
9690    old_base == new_base && !old_base.is_empty()
9691}
9692
9693const RENAME_SCORE_MAX: u32 = 100;
9694
9695/// Uncompressed blob size without inflating object bodies (for rename pre-filtering).
9696fn blob_payload_size(odb: &Odb, oid: &ObjectId) -> Option<usize> {
9697    if oid.is_zero() {
9698        return None;
9699    }
9700    odb.read_info(oid).ok().map(|info| info.size as usize)
9701}
9702
9703/// Payload size for an added diff side (ODB blob or worktree bytes when not stored yet).
9704fn added_entry_payload_size(odb: &Odb, add: &DiffEntry, work_root: Option<&Path>) -> Option<usize> {
9705    if !add.new_oid.is_zero() {
9706        if let Some(size) = blob_payload_size(odb, &add.new_oid) {
9707            return Some(size);
9708        }
9709    }
9710    read_added_entry_bytes(odb, add, work_root).map(|data| data.len())
9711}
9712
9713/// Git `diffcore-delta` size gate: skip pairs that cannot reach `minimum_score`.
9714fn rename_pair_size_reachable(src_size: usize, dst_size: usize, minimum_score: u32) -> bool {
9715    let max_size = src_size.max(dst_size);
9716    let min_size = src_size.min(dst_size);
9717    if max_size == 0 {
9718        return minimum_score <= RENAME_SCORE_MAX;
9719    }
9720    (max_size - min_size).saturating_mul(RENAME_SCORE_MAX as usize)
9721        <= max_size.saturating_mul((RENAME_SCORE_MAX - minimum_score) as usize)
9722}
9723
9724/// Compute a similarity percentage (0–100) between two byte slices.
9725///
9726/// Matches Git rename detection: span-hash intersection (`hash_chars` in diffcore-delta),
9727/// `score = copied_bytes * 100 / max(src_size, dst_size)` on raw blob sizes.
9728fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
9729    RenameLineTable::similarity_score(
9730        &RenameLineTable::from_blob(old),
9731        &RenameLineTable::from_blob(new),
9732    )
9733}
9734
9735/// Compute rename/copy similarity percentage (0–100) between two byte slices.
9736///
9737/// Uses Git `diffcore-delta` span hashing (`hash_chars`): 64-byte or newline-bounded
9738/// chunks, CR skipped before LF in text blobs, `score = copied * 100 / max(len)`.
9739#[must_use]
9740pub fn rename_similarity_score(old: &[u8], new: &[u8]) -> u32 {
9741    compute_similarity(old, new)
9742}
9743
9744// ── Output formatting ───────────────────────────────────────────────
9745
9746/// Format a diff entry in Git's raw diff format.
9747///
9748/// Example: `:100644 100644 abc1234... def5678... M\tfile.txt`
9749pub fn format_raw(entry: &DiffEntry) -> String {
9750    let path = match entry.status {
9751        DiffStatus::Renamed | DiffStatus::Copied => {
9752            format!(
9753                "{}\t{}",
9754                entry.old_path.as_deref().unwrap_or(""),
9755                entry.new_path.as_deref().unwrap_or("")
9756            )
9757        }
9758        _ => entry.path().to_owned(),
9759    };
9760
9761    let status_str = match (entry.status, entry.score) {
9762        (DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
9763        (DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
9764        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
9765        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
9766        _ => entry.status.letter().to_string(),
9767    };
9768
9769    let (old_hex, new_hex) = raw_oid_hex_pair(&entry.old_oid, &entry.new_oid);
9770    format!(
9771        ":{} {} {} {} {}\t{}",
9772        entry.old_mode, entry.new_mode, old_hex, new_hex, status_str, path
9773    )
9774}
9775
9776/// Render a diff entry's `(old, new)` OIDs as full hex for `--raw` output,
9777/// widening any null OID to the repository's hash width.
9778///
9779/// Diff entries store null OIDs at SHA-1 width regardless of the repository
9780/// algorithm, but `git diff --raw` prints them at the real hash width (64
9781/// zeros in a SHA-256 repo). A diff entry never has both sides null, so the
9782/// width is taken from whichever side carries a real object.
9783fn raw_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
9784    let width = if !old.is_zero() {
9785        old.algo().hex_len()
9786    } else if !new.is_zero() {
9787        new.algo().hex_len()
9788    } else {
9789        old.algo().hex_len()
9790    };
9791    let render = |oid: &ObjectId| {
9792        if oid.is_zero() {
9793            "0".repeat(width)
9794        } else {
9795            oid.to_hex()
9796        }
9797    };
9798    (render(old), render(new))
9799}
9800
9801/// Format a diff entry with abbreviated OIDs.
9802pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
9803    let ellipsis = if crate::environment::Environment::empty()
9804        .var("GIT_PRINT_SHA1_ELLIPSIS")
9805        .as_deref()
9806        == Some("yes")
9807    {
9808        "..."
9809    } else {
9810        ""
9811    };
9812    let old_hex = format!("{}", entry.old_oid);
9813    let new_hex = format!("{}", entry.new_oid);
9814    let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
9815    let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
9816
9817    // Renames/copies carry a similarity score and a `<old>\t<new>` path pair.
9818    let path = match entry.status {
9819        DiffStatus::Renamed | DiffStatus::Copied => format!(
9820            "{}\t{}",
9821            entry.old_path.as_deref().unwrap_or(""),
9822            entry.new_path.as_deref().unwrap_or("")
9823        ),
9824        _ => entry.path().to_owned(),
9825    };
9826    let status_str = match (entry.status, entry.score) {
9827        (DiffStatus::Renamed, Some(s)) => format!("R{s:03}"),
9828        (DiffStatus::Copied, Some(s)) => format!("C{s:03}"),
9829        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
9830        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
9831        _ => entry.status.letter().to_string(),
9832    };
9833
9834    format!(
9835        ":{} {} {}{} {}{} {}\t{}",
9836        entry.old_mode,
9837        entry.new_mode,
9838        old_abbrev,
9839        ellipsis,
9840        new_abbrev,
9841        ellipsis,
9842        status_str,
9843        path
9844    )
9845}
9846
9847/// Generate a unified diff patch for two blobs.
9848///
9849/// # Parameters
9850///
9851/// - `old_content` — the old file content (empty for added files).
9852/// - `new_content` — the new file content (empty for deleted files).
9853/// - `old_path` — display path for the old side.
9854/// - `new_path` — display path for the new side.
9855/// - `context_lines` — number of context lines around changes (default: 3).
9856/// - Inter-hunk context defaults to `0` (see [`unified_diff_with_prefix`]).
9857///
9858/// # Returns
9859///
9860/// The unified diff as a string.
9861pub fn unified_diff(
9862    old_content: &str,
9863    new_content: &str,
9864    old_path: &str,
9865    new_path: &str,
9866    context_lines: usize,
9867    indent_heuristic: bool,
9868    quote_path_fully: bool,
9869) -> String {
9870    unified_diff_with_prefix(
9871        old_content,
9872        new_content,
9873        old_path,
9874        new_path,
9875        context_lines,
9876        0,
9877        "a/",
9878        "b/",
9879        indent_heuristic,
9880        quote_path_fully,
9881    )
9882}
9883
9884/// Same as `unified_diff` but with configurable source/destination prefixes.
9885///
9886/// `inter_hunk_context` is Git's `--inter-hunk-context`: adjacent hunks merge when
9887/// the unchanged gap between them is at most `2 * context_lines + inter_hunk_context` lines.
9888#[allow(clippy::too_many_arguments)] // Mirrors Git-style unified diff parameters.
9889pub fn unified_diff_with_prefix(
9890    old_content: &str,
9891    new_content: &str,
9892    old_path: &str,
9893    new_path: &str,
9894    context_lines: usize,
9895    inter_hunk_context: usize,
9896    src_prefix: &str,
9897    dst_prefix: &str,
9898    indent_heuristic: bool,
9899    quote_path_fully: bool,
9900) -> String {
9901    unified_diff_with_prefix_and_funcname(
9902        old_content,
9903        new_content,
9904        old_path,
9905        new_path,
9906        context_lines,
9907        inter_hunk_context,
9908        src_prefix,
9909        dst_prefix,
9910        None,
9911        indent_heuristic,
9912        quote_path_fully,
9913    )
9914}
9915
9916/// Same as [`unified_diff_with_prefix`] with optional custom hunk-header
9917/// function-name matching.
9918#[allow(clippy::too_many_arguments)]
9919pub fn unified_diff_with_prefix_and_funcname(
9920    old_content: &str,
9921    new_content: &str,
9922    old_path: &str,
9923    new_path: &str,
9924    context_lines: usize,
9925    inter_hunk_context: usize,
9926    src_prefix: &str,
9927    dst_prefix: &str,
9928    funcname_matcher: Option<&FuncnameMatcher>,
9929    indent_heuristic: bool,
9930    quote_path_fully: bool,
9931) -> String {
9932    unified_diff_with_prefix_and_funcname_and_algorithm(
9933        old_content,
9934        new_content,
9935        old_path,
9936        new_path,
9937        context_lines,
9938        inter_hunk_context,
9939        src_prefix,
9940        dst_prefix,
9941        funcname_matcher,
9942        similar::Algorithm::Myers,
9943        false,
9944        false,
9945        indent_heuristic,
9946        quote_path_fully,
9947    )
9948}
9949
9950/// Same as [`unified_diff_with_prefix_and_funcname_and_algorithm`], streaming to `out`.
9951///
9952/// # Errors
9953///
9954/// Returns I/O errors from `out`.
9955#[allow(clippy::too_many_arguments)]
9956pub fn write_unified_diff_with_prefix_and_funcname_and_algorithm<W: std::io::Write>(
9957    out: &mut W,
9958    old_content: &str,
9959    new_content: &str,
9960    old_path: &str,
9961    new_path: &str,
9962    context_lines: usize,
9963    inter_hunk_context: usize,
9964    src_prefix: &str,
9965    dst_prefix: &str,
9966    funcname_matcher: Option<&FuncnameMatcher>,
9967    algorithm: similar::Algorithm,
9968    function_context: bool,
9969    use_git_histogram: bool,
9970    indent_heuristic: bool,
9971    quote_path_fully: bool,
9972) -> Result<()> {
9973    if function_context {
9974        let patch = unified_diff_with_function_context(
9975            old_content,
9976            new_content,
9977            old_path,
9978            new_path,
9979            context_lines,
9980            inter_hunk_context,
9981            src_prefix,
9982            dst_prefix,
9983            funcname_matcher,
9984            algorithm,
9985            indent_heuristic,
9986            quote_path_fully,
9987        );
9988        out.write_all(patch.as_bytes())?;
9989        return Ok(());
9990    }
9991
9992    if use_git_histogram {
9993        let patch = unified_diff_histogram_with_prefix_and_funcname_indent(
9994            old_content,
9995            new_content,
9996            old_path,
9997            new_path,
9998            context_lines,
9999            inter_hunk_context,
10000            src_prefix,
10001            dst_prefix,
10002            funcname_matcher,
10003            indent_heuristic,
10004            quote_path_fully,
10005        );
10006        out.write_all(patch.as_bytes())?;
10007        return Ok(());
10008    }
10009
10010    if algorithm == similar::Algorithm::Myers && !function_context {
10011        let patch = unified_diff_git_myers_with_prefix_and_funcname(
10012            old_content,
10013            new_content,
10014            old_path,
10015            new_path,
10016            context_lines,
10017            inter_hunk_context,
10018            src_prefix,
10019            dst_prefix,
10020            funcname_matcher,
10021            indent_heuristic,
10022            quote_path_fully,
10023        );
10024        out.write_all(patch.as_bytes())?;
10025        return Ok(());
10026    }
10027
10028    if algorithm == similar::Algorithm::Lcs && !function_context {
10029        let patch = unified_diff_git_minimal_with_prefix_and_funcname(
10030            old_content,
10031            new_content,
10032            old_path,
10033            new_path,
10034            context_lines,
10035            inter_hunk_context,
10036            src_prefix,
10037            dst_prefix,
10038            funcname_matcher,
10039            indent_heuristic,
10040            quote_path_fully,
10041        );
10042        out.write_all(patch.as_bytes())?;
10043        return Ok(());
10044    }
10045
10046    if algorithm == similar::Algorithm::Patience && !function_context {
10047        let patch = unified_diff_git_patience_with_prefix_and_funcname(
10048            old_content,
10049            new_content,
10050            old_path,
10051            new_path,
10052            context_lines,
10053            inter_hunk_context,
10054            src_prefix,
10055            dst_prefix,
10056            funcname_matcher,
10057            indent_heuristic,
10058            quote_path_fully,
10059        );
10060        out.write_all(patch.as_bytes())?;
10061        return Ok(());
10062    }
10063
10064    use crate::quote_path::format_diff_path_with_prefix;
10065    use similar::{udiff::UnifiedDiffHunk, TextDiff};
10066
10067    let diff = TextDiff::configure()
10068        .algorithm(algorithm)
10069        .diff_lines(old_content, new_content);
10070    let compacted_ops = diff_indent_heuristic::diff_lines_ops_compacted(
10071        old_content,
10072        new_content,
10073        algorithm,
10074        indent_heuristic,
10075    );
10076
10077    if old_path == "/dev/null" {
10078        out.write_all(b"--- /dev/null\n")?;
10079    } else if src_prefix.is_empty() {
10080        writeln!(out, "--- {old_path}")?;
10081    } else {
10082        out.write_all(b"--- ")?;
10083        out.write_all(
10084            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
10085        )?;
10086        out.write_all(b"\n")?;
10087    }
10088    if new_path == "/dev/null" {
10089        out.write_all(b"+++ /dev/null\n")?;
10090    } else if dst_prefix.is_empty() {
10091        writeln!(out, "+++ {new_path}")?;
10092    } else {
10093        out.write_all(b"+++ ")?;
10094        out.write_all(
10095            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
10096        )?;
10097        out.write_all(b"\n")?;
10098    }
10099
10100    let old_lines: Vec<&str> = old_content.lines().collect();
10101    let max_common_gap = context_lines
10102        .saturating_mul(2)
10103        .saturating_add(inter_hunk_context);
10104    let op_groups = group_diff_ops_gap(compacted_ops, context_lines, max_common_gap);
10105
10106    let mut hunk_buf = String::new();
10107    let mut eof_annot = UnifiedDiffNoNewlineAnnotator::new(old_content, new_content);
10108    for ops in op_groups {
10109        if ops.is_empty() {
10110            continue;
10111        }
10112        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
10113        hunk_buf.clear();
10114        use std::fmt::Write as FmtWrite;
10115        let _ = FmtWrite::write_fmt(&mut hunk_buf, format_args!("{hunk}"));
10116        let mut fragment = hunk_buf.clone();
10117        if let Some(first_newline) = hunk_buf.find('\n') {
10118            let header_line = &hunk_buf[..first_newline];
10119            let rest = &hunk_buf[first_newline..];
10120            if let Some(func_ctx) =
10121                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
10122            {
10123                fragment.clear();
10124                fragment.push_str(header_line);
10125                fragment.push(' ');
10126                fragment.push_str(&func_ctx);
10127                fragment.push_str(rest);
10128            }
10129        }
10130        eof_annot.write_hunk(out, &fragment)?;
10131    }
10132    Ok(())
10133}
10134
10135/// Same as [`unified_diff_with_prefix_and_funcname`] but allows callers to
10136/// choose the line diff algorithm used for hunk generation.
10137///
10138/// When `function_context` is true (`git diff -W`), hunks are expanded to
10139/// whole logical functions using the same rules as Git's `XDL_EMIT_FUNCCONTEXT`.
10140#[allow(clippy::too_many_arguments)]
10141pub fn unified_diff_with_prefix_and_funcname_and_algorithm(
10142    old_content: &str,
10143    new_content: &str,
10144    old_path: &str,
10145    new_path: &str,
10146    context_lines: usize,
10147    inter_hunk_context: usize,
10148    src_prefix: &str,
10149    dst_prefix: &str,
10150    funcname_matcher: Option<&FuncnameMatcher>,
10151    algorithm: similar::Algorithm,
10152    function_context: bool,
10153    use_git_histogram: bool,
10154    indent_heuristic: bool,
10155    quote_path_fully: bool,
10156) -> String {
10157    let mut output = Vec::new();
10158    if write_unified_diff_with_prefix_and_funcname_and_algorithm(
10159        &mut output,
10160        old_content,
10161        new_content,
10162        old_path,
10163        new_path,
10164        context_lines,
10165        inter_hunk_context,
10166        src_prefix,
10167        dst_prefix,
10168        funcname_matcher,
10169        algorithm,
10170        function_context,
10171        use_git_histogram,
10172        indent_heuristic,
10173        quote_path_fully,
10174    )
10175    .is_err()
10176    {
10177        output.clear();
10178    }
10179    String::from_utf8_lossy(&output).into_owned()
10180}
10181
10182/// Group diff ops into hunks like Git's `xdl_get_hunk`: two changes merge into one hunk while
10183/// the run of unchanged lines between them is at most `max_common_gap`
10184/// (`2 * context + inter_hunk_context`), and each hunk keeps at most `context` unchanged lines
10185/// at its edges. Unlike `similar::group_diff_ops`, the split threshold is decoupled from the
10186/// edge context so odd gap limits group exactly like Git.
10187pub(crate) fn group_diff_ops_gap(
10188    mut ops: Vec<similar::DiffOp>,
10189    context: usize,
10190    max_common_gap: usize,
10191) -> Vec<Vec<similar::DiffOp>> {
10192    use similar::DiffOp;
10193    if ops.is_empty() {
10194        return vec![];
10195    }
10196
10197    let mut pending_group = Vec::new();
10198    let mut rv = Vec::new();
10199
10200    if let Some(DiffOp::Equal {
10201        old_index,
10202        new_index,
10203        len,
10204    }) = ops.first_mut()
10205    {
10206        let offset = (*len).saturating_sub(context);
10207        *old_index += offset;
10208        *new_index += offset;
10209        *len -= offset;
10210    }
10211
10212    if let Some(DiffOp::Equal { len, .. }) = ops.last_mut() {
10213        *len -= (*len).saturating_sub(context);
10214    }
10215
10216    for op in ops.into_iter() {
10217        if let DiffOp::Equal {
10218            old_index,
10219            new_index,
10220            len,
10221        } = op
10222        {
10223            // End the current group and start a new one whenever the unchanged
10224            // run is too long to fuse the surrounding changes.
10225            if len > max_common_gap {
10226                pending_group.push(DiffOp::Equal {
10227                    old_index,
10228                    new_index,
10229                    len: context,
10230                });
10231                rv.push(pending_group);
10232                let offset = len.saturating_sub(context);
10233                pending_group = vec![DiffOp::Equal {
10234                    old_index: old_index + offset,
10235                    new_index: new_index + offset,
10236                    len: len - offset,
10237                }];
10238                continue;
10239            }
10240        }
10241        pending_group.push(op);
10242    }
10243
10244    match &pending_group[..] {
10245        &[] | &[similar::DiffOp::Equal { .. }] => {}
10246        _ => rv.push(pending_group),
10247    }
10248
10249    rv
10250}
10251#[expect(clippy::too_many_arguments)]
10252/// `git diff -W`: expand each hunk to include full function bodies (see Git `xemit.c`).
10253fn unified_diff_with_function_context(
10254    old_content: &str,
10255    new_content: &str,
10256    old_path: &str,
10257    new_path: &str,
10258    context_lines: usize,
10259    inter_hunk_context: usize,
10260    src_prefix: &str,
10261    dst_prefix: &str,
10262    funcname_matcher: Option<&FuncnameMatcher>,
10263    algorithm: similar::Algorithm,
10264    indent_heuristic: bool,
10265    quote_path_fully: bool,
10266) -> String {
10267    use crate::quote_path::format_diff_path_with_prefix;
10268    use similar::{udiff::UnifiedDiffHunk, TextDiff};
10269
10270    let diff = TextDiff::configure()
10271        .algorithm(algorithm)
10272        .diff_lines(old_content, new_content);
10273
10274    let old_lines: Vec<&str> = old_content.lines().collect();
10275    let new_lines: Vec<&str> = new_content.lines().collect();
10276    let n_old = old_lines.len();
10277    let n_new = new_lines.len();
10278
10279    // Group changes the way Git's xdl_get_hunk does: merge while the unchanged
10280    // gap is at most `2*context + inter_hunk_context`. `similar::group_diff_ops`
10281    // splits only at gap > `2*radius`, which over-merges changes in *different*
10282    // functions (e.g. a leading insertion and a body change) into one hunk and
10283    // then over-expands the function context (t4015 #136). Use the gap-correct
10284    // grouping (same as the non-function-context path).
10285    let max_common_gap = context_lines
10286        .saturating_mul(2)
10287        .saturating_add(inter_hunk_context);
10288    let all_ops = diff.ops().to_vec();
10289    let op_groups = group_diff_ops_gap(all_ops.clone(), context_lines, max_common_gap);
10290
10291    let mut ranges: Vec<(usize, usize, usize, usize)> = Vec::new();
10292
10293    for ops in op_groups {
10294        if ops.is_empty() {
10295            continue;
10296        }
10297        let i1_anchor = func_context_old_anchor(&ops, n_old);
10298        let i1_end = hunk_old_change_end_exclusive(&ops);
10299        let skip_preimage_pull =
10300            append_with_whole_function_added(&ops, n_old, n_new, &new_lines, funcname_matcher);
10301        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
10302        let hunk_str = format!("{hunk}");
10303        let header_line = hunk_str
10304            .lines()
10305            .next()
10306            .unwrap_or("")
10307            .trim_end_matches(['\r', '\n']);
10308        let Some((base_s1, _base_e1, _base_s2, _base_e2)) =
10309            parse_unified_hunk_header_ranges(header_line)
10310        else {
10311            continue;
10312        };
10313
10314        let ctx = context_lines;
10315        let (s1, e1, s2, e2) = if skip_preimage_pull {
10316            let s = n_old.saturating_sub(ctx);
10317            let s2 = map_old_line_to_new(&all_ops, s, n_new).min(n_new);
10318            (s, n_old, s2, n_new)
10319        } else {
10320            let mut s1 = base_s1.saturating_sub(ctx);
10321            let mut s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
10322
10323            let base_pre_s1 = i1_anchor.saturating_sub(ctx);
10324            if base_pre_s1 < s1 {
10325                s1 = base_pre_s1;
10326                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
10327            }
10328
10329            let fs1 = expand_func_pre_start(s1, i1_anchor, n_old, &old_lines, funcname_matcher);
10330            if fs1 < s1 {
10331                s1 = fs1;
10332                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
10333            }
10334
10335            // `i1_end` is the exclusive end of the changed region; its post-image
10336            // context is `ctx` lines (Git's `xche->i1 + xche->chg1 + lctx`). The
10337            // hunk's `base_e1` already includes the group's trailing context, so
10338            // use the change end + ctx directly to avoid double-counting context
10339            // (which over-extended the hunk and merged separate functions — t4015 #136).
10340            let mut e1 = (i1_end + ctx).min(n_old);
10341            let mut e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
10342            let fe1 = expand_func_post_end(e1, i1_end, n_old, &old_lines, funcname_matcher);
10343            if fe1 > e1 {
10344                e1 = fe1;
10345                e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
10346            }
10347            (s1, e1, s2, e2)
10348        };
10349
10350        ranges.push((s1, e1, s2, e2));
10351    }
10352
10353    // Merge ranges whose function-context expansion made them overlap on the old
10354    // side (Git emits a single hunk in that case).
10355    ranges.sort_by_key(|r| (r.0, r.2));
10356    let mut merged: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(ranges.len());
10357    for (s1, e1, s2, e2) in ranges {
10358        if let Some(last) = merged.last_mut() {
10359            if s1 < last.1 {
10360                last.1 = last.1.max(e1);
10361                last.3 = last.3.max(e2);
10362                continue;
10363            }
10364        }
10365        merged.push((s1, e1, s2, e2));
10366    }
10367    let ranges = merged;
10368
10369    let mut output = String::new();
10370    if old_path == "/dev/null" {
10371        output.push_str("--- /dev/null\n");
10372    } else if src_prefix.is_empty() {
10373        output.push_str(&format!("--- {old_path}\n"));
10374    } else {
10375        output.push_str("--- ");
10376        output.push_str(&format_diff_path_with_prefix(
10377            src_prefix,
10378            old_path,
10379            quote_path_fully,
10380        ));
10381        output.push('\n');
10382    }
10383    if new_path == "/dev/null" {
10384        output.push_str("+++ /dev/null\n");
10385    } else if dst_prefix.is_empty() {
10386        output.push_str(&format!("+++ {new_path}\n"));
10387    } else {
10388        output.push_str("+++ ");
10389        output.push_str(&format_diff_path_with_prefix(
10390            dst_prefix,
10391            new_path,
10392            quote_path_fully,
10393        ));
10394        output.push('\n');
10395    }
10396
10397    for (s1, e1, s2, e2) in ranges {
10398        if s1 >= e1 && s2 >= e2 {
10399            continue;
10400        }
10401        let old_seg =
10402            line_slice_for_diff_with_eof_nl(&old_lines, s1, e1, old_content.ends_with('\n'));
10403        let new_seg =
10404            line_slice_for_diff_with_eof_nl(&new_lines, s2, e2, new_content.ends_with('\n'));
10405        let inner_ctx = old_seg.lines().count().max(new_seg.lines().count()).max(1);
10406        let piece = unified_diff_with_prefix_and_funcname_and_algorithm(
10407            &old_seg,
10408            &new_seg,
10409            old_path,
10410            new_path,
10411            inner_ctx,
10412            0,
10413            src_prefix,
10414            dst_prefix,
10415            funcname_matcher,
10416            algorithm,
10417            false,
10418            false,
10419            indent_heuristic,
10420            quote_path_fully,
10421        );
10422        let shifted = shift_unified_hunk_headers_to_full_file(&piece, s1, s2);
10423        let with_func =
10424            enrich_unified_hunk_headers_funcname(&shifted, &old_lines, funcname_matcher);
10425        for line in with_func.lines() {
10426            if line.starts_with("--- ") || line.starts_with("+++ ") {
10427                continue;
10428            }
10429            output.push_str(line);
10430            output.push('\n');
10431        }
10432    }
10433
10434    output
10435}
10436
10437/// `piece` is a unified diff for a slice of the file; hunk headers use 1-based
10438/// coordinates relative to that slice. Shift them by `delta_old` / `delta_new`
10439/// (0-based offsets of the slice in the full file) so the combined patch applies
10440/// to the whole file.
10441fn shift_unified_hunk_headers_to_full_file(
10442    patch: &str,
10443    delta_old: usize,
10444    delta_new: usize,
10445) -> String {
10446    if delta_old == 0 && delta_new == 0 {
10447        return patch.to_owned();
10448    }
10449    let mut out = String::with_capacity(patch.len());
10450    for line in patch.lines() {
10451        if let Some(shifted) = shift_one_unified_hunk_header(line, delta_old, delta_new) {
10452            out.push_str(&shifted);
10453        } else {
10454            out.push_str(line);
10455        }
10456        out.push('\n');
10457    }
10458    out
10459}
10460
10461fn shift_one_unified_hunk_header(line: &str, delta_old: usize, delta_new: usize) -> Option<String> {
10462    let rest = line.strip_prefix("@@ ")?;
10463    let (old_chunk, after_plus) = rest.split_once(" +")?;
10464    let old_spec = old_chunk.strip_prefix('-')?;
10465    let (new_spec, suffix) = after_plus.split_once(" @@")?;
10466    let shifted_old = shift_unified_range_spec(old_spec, delta_old)?;
10467    let shifted_new = shift_unified_range_spec(new_spec, delta_new)?;
10468    Some(format!("@@ -{shifted_old} +{shifted_new} @@{suffix}"))
10469}
10470
10471fn shift_unified_range_spec(spec: &str, delta: usize) -> Option<String> {
10472    let spec = spec.trim();
10473    if let Some((start_s, count_s)) = spec.split_once(',') {
10474        let start: usize = start_s.parse().ok()?;
10475        let count: usize = count_s.parse().ok()?;
10476        Some(format!("{},{}", start.saturating_add(delta), count))
10477    } else {
10478        let start: usize = spec.parse().ok()?;
10479        Some(format!("{}", start.saturating_add(delta)))
10480    }
10481}
10482
10483/// Re-attach `@@ ... @@ <funcname>` using full-file line indices (inner diffs use slices).
10484fn enrich_unified_hunk_headers_funcname(
10485    patch: &str,
10486    full_old_lines: &[&str],
10487    funcname_matcher: Option<&FuncnameMatcher>,
10488) -> String {
10489    let mut out = String::with_capacity(patch.len());
10490    for line in patch.lines() {
10491        if let Some(fixed) = enrich_one_hunk_header_funcname(line, full_old_lines, funcname_matcher)
10492        {
10493            out.push_str(&fixed);
10494        } else {
10495            out.push_str(line);
10496        }
10497        out.push('\n');
10498    }
10499    out
10500}
10501
10502fn enrich_one_hunk_header_funcname(
10503    line: &str,
10504    full_old_lines: &[&str],
10505    funcname_matcher: Option<&FuncnameMatcher>,
10506) -> Option<String> {
10507    let after_at = line.strip_prefix("@@ ")?;
10508    let idx = after_at.find(" @@")?;
10509    let mid = after_at[..idx].trim();
10510    let tail = after_at[idx + 3..].trim_start();
10511    let header_for_parse = format!("@@ {mid} @@");
10512    let func = extract_function_context(&header_for_parse, full_old_lines, funcname_matcher, None);
10513    Some(if let Some(f) = func {
10514        format!("@@ {mid} @@ {f}")
10515    } else if !tail.is_empty() {
10516        format!("@@ {mid} @@ {tail}")
10517    } else {
10518        format!("@@ {mid} @@")
10519    })
10520}
10521
10522fn line_slice_for_diff_with_eof_nl(
10523    lines: &[&str],
10524    start: usize,
10525    end: usize,
10526    full_file_ends_with_newline: bool,
10527) -> String {
10528    if start >= end {
10529        return String::new();
10530    }
10531    let mut s = lines[start..end].join("\n");
10532    let slice_is_suffix_of_file = end == lines.len();
10533    let need_trailing_nl = if slice_is_suffix_of_file {
10534        full_file_ends_with_newline
10535    } else {
10536        true
10537    };
10538    if need_trailing_nl && !s.ends_with('\n') {
10539        s.push('\n');
10540    }
10541    s
10542}
10543
10544/// Map a 0-based old line index to the corresponding 0-based new line index using the full-file
10545/// diff ops (Git aligns context across deletions/insertions).
10546fn map_old_line_to_new(ops: &[similar::DiffOp], old_line: usize, n_new: usize) -> usize {
10547    use similar::DiffOp;
10548    let mut n = 0usize;
10549    for op in ops {
10550        match *op {
10551            DiffOp::Equal {
10552                old_index,
10553                new_index,
10554                len,
10555            } => {
10556                if old_index + len <= old_line {
10557                    n = new_index + len;
10558                    continue;
10559                }
10560                if old_index < old_line {
10561                    let take = old_line - old_index;
10562                    return (new_index + take).min(n_new);
10563                }
10564                return new_index.min(n_new);
10565            }
10566            DiffOp::Delete {
10567                old_index,
10568                old_len,
10569                new_index,
10570            } => {
10571                if old_index + old_len <= old_line {
10572                    n = new_index;
10573                    continue;
10574                }
10575                if old_index < old_line {
10576                    return new_index.min(n_new);
10577                }
10578            }
10579            DiffOp::Insert {
10580                old_index,
10581                new_index,
10582                new_len,
10583            } => {
10584                if old_index < old_line {
10585                    n = new_index + new_len;
10586                    continue;
10587                }
10588                if old_index == old_line {
10589                    // `old_line` is an exclusive end or insertion point aligned with this insert
10590                    // (e.g. EOF append maps to after the inserted block).
10591                    return (new_index + new_len).min(n_new);
10592                }
10593                return new_index.min(n_new);
10594            }
10595            DiffOp::Replace {
10596                old_index,
10597                old_len,
10598                new_index,
10599                new_len,
10600            } => {
10601                if old_index + old_len <= old_line {
10602                    n = new_index + new_len;
10603                    continue;
10604                }
10605                if old_index < old_line {
10606                    let into_old = old_line - old_index;
10607                    let mapped = new_index + into_old.min(new_len);
10608                    return mapped.min(n_new);
10609                }
10610                return new_index.min(n_new);
10611            }
10612        }
10613    }
10614    n.min(n_new)
10615}
10616
10617/// Parse `@@ -old +new @@` into 0-based half-open ranges in each file.
10618fn parse_unified_hunk_header_ranges(header: &str) -> Option<(usize, usize, usize, usize)> {
10619    let rest = header.strip_prefix("@@ ")?;
10620    let (old_tok, rest2) = rest.split_once(" +")?;
10621    let old_tok = old_tok.strip_prefix('-')?;
10622    let new_tok = rest2.split_once(" @@").map(|(a, _)| a)?;
10623
10624    fn parse_side(spec: &str) -> Option<(usize, usize)> {
10625        let spec = spec.trim();
10626        let (start_one_based, count) = if let Some((a, b)) = spec.split_once(',') {
10627            (a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)
10628        } else {
10629            let s = spec.parse::<usize>().ok()?;
10630            (s, 1usize)
10631        };
10632        let s0 = start_one_based.saturating_sub(1);
10633        let e0 = s0.saturating_add(count);
10634        Some((s0, e0))
10635    }
10636
10637    let (os, oe) = parse_side(old_tok)?;
10638    let (ns, ne) = parse_side(new_tok)?;
10639    Some((os, oe, ns, ne))
10640}
10641
10642/// Git `xemit.c`: when a hunk only inserts at EOF (first inserted line is `new_index == n_old`)
10643/// and the added text already contains a funcname line, do not pull extra context from the preimage.
10644fn append_with_whole_function_added(
10645    ops: &[similar::DiffOp],
10646    n_old: usize,
10647    n_new: usize,
10648    new_lines: &[&str],
10649    matcher: Option<&FuncnameMatcher>,
10650) -> bool {
10651    use similar::DiffOp;
10652    if n_old == 0 {
10653        return false;
10654    }
10655    let mut only_ins_or_eq = true;
10656    let mut min_new_ins = usize::MAX;
10657    for op in ops {
10658        match *op {
10659            DiffOp::Equal { .. } => {}
10660            DiffOp::Insert {
10661                new_index, new_len, ..
10662            } => {
10663                min_new_ins = min_new_ins.min(new_index);
10664                if new_len == 0 {
10665                    only_ins_or_eq = false;
10666                }
10667            }
10668            DiffOp::Delete { .. } | DiffOp::Replace { .. } => {
10669                only_ins_or_eq = false;
10670            }
10671        }
10672    }
10673    let mut insert_at_eof = false;
10674    for op in ops {
10675        if let DiffOp::Insert { old_index, .. } = *op {
10676            if old_index == n_old {
10677                insert_at_eof = true;
10678                break;
10679            }
10680        }
10681    }
10682    let append_at_eof = min_new_ins == n_old || insert_at_eof;
10683    if !only_ins_or_eq || !append_at_eof || min_new_ins == usize::MAX {
10684        return false;
10685    }
10686    // Git only skips preimage pull when the inserted block is clearly a new logical
10687    // function (see `xemit.c` walking `xdf2` for `is_func_rec`). A loose "any line
10688    // looks like a function" check would match `return` / `printf` and break `-W`
10689    // hunks that still need preimage context (t4051 `extended`).
10690    let mut j = min_new_ins;
10691    while j < n_new {
10692        let line = new_lines[j];
10693        if line.trim().is_empty() {
10694            j += 1;
10695            continue;
10696        }
10697        if let Some(m) = matcher {
10698            if m.match_line(line).is_some() {
10699                return true;
10700            }
10701        } else if inserted_block_starts_with_c_like_function_definition(line) {
10702            return true;
10703        }
10704        j += 1;
10705    }
10706    false
10707}
10708
10709fn inserted_block_starts_with_c_like_function_definition(line: &str) -> bool {
10710    let t = line.trim_start();
10711    let Some(open_paren) = t.find('(') else {
10712        return false;
10713    };
10714    let head = &t[..open_paren];
10715    let tokens: Vec<&str> = head.split_whitespace().collect();
10716    if tokens.len() < 2 {
10717        // `printf(...)`, `return (`, etc. — not `return_type name(`.
10718        return false;
10719    }
10720    let nameish = tokens.last().copied().unwrap_or("");
10721    let name = nameish.trim_end_matches(['*', '&']);
10722    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
10723        return false;
10724    }
10725    let type_or_modifier = |tok: &str| {
10726        matches!(
10727            tok,
10728            "static"
10729                | "extern"
10730                | "inline"
10731                | "void"
10732                | "int"
10733                | "char"
10734                | "short"
10735                | "long"
10736                | "float"
10737                | "double"
10738                | "unsigned"
10739                | "signed"
10740                | "struct"
10741                | "enum"
10742                | "union"
10743                | "const"
10744                | "volatile"
10745                | "typedef"
10746        )
10747    };
10748    tokens[..tokens.len() - 1]
10749        .iter()
10750        .any(|tok| type_or_modifier(tok))
10751}
10752
10753fn hunk_old_change_end_exclusive(ops: &[similar::DiffOp]) -> usize {
10754    use similar::DiffOp;
10755    let mut max_o = 0usize;
10756    for op in ops {
10757        match *op {
10758            DiffOp::Delete {
10759                old_index, old_len, ..
10760            } => {
10761                max_o = max_o.max(old_index + old_len);
10762            }
10763            DiffOp::Replace {
10764                old_index, old_len, ..
10765            } => {
10766                max_o = max_o.max(old_index + old_len);
10767            }
10768            DiffOp::Insert { old_index, .. } => {
10769                // Pure insertions do not consume old lines; Git's post-context anchor is the
10770                // insertion point (`old_index`), not 0 (t4051 `extended`).
10771                max_o = max_o.max(old_index);
10772            }
10773            DiffOp::Equal { .. } => {}
10774        }
10775    }
10776    max_o
10777}
10778
10779fn func_context_old_anchor(ops: &[similar::DiffOp], n_old: usize) -> usize {
10780    use similar::DiffOp;
10781    let mut has_delete_or_replace = false;
10782    let mut min_del = usize::MAX;
10783    let mut min_ins_old = usize::MAX;
10784
10785    for op in ops {
10786        match *op {
10787            DiffOp::Delete {
10788                old_index, old_len, ..
10789            } => {
10790                has_delete_or_replace = true;
10791                min_del = min_del.min(old_index);
10792                min_del = min_del.min(old_index + old_len.saturating_sub(1));
10793            }
10794            DiffOp::Replace {
10795                old_index, old_len, ..
10796            } => {
10797                has_delete_or_replace = true;
10798                min_del = min_del.min(old_index);
10799                min_del = min_del.min(old_index + old_len.saturating_sub(1));
10800            }
10801            DiffOp::Insert { old_index, .. } => {
10802                min_ins_old = min_ins_old.min(old_index);
10803            }
10804            DiffOp::Equal { .. } => {}
10805        }
10806    }
10807
10808    let mut i1 = if has_delete_or_replace {
10809        min_del
10810    } else if min_ins_old != usize::MAX {
10811        min_ins_old
10812    } else {
10813        0
10814    };
10815
10816    let pure_insert = ops
10817        .iter()
10818        .all(|op| matches!(op, DiffOp::Insert { .. } | DiffOp::Equal { .. }))
10819        && ops.iter().any(|op| matches!(op, DiffOp::Insert { .. }));
10820
10821    if pure_insert && i1 >= n_old && n_old > 0 {
10822        i1 = n_old - 1;
10823    }
10824
10825    i1.min(n_old.saturating_sub(1))
10826}
10827
10828fn expand_func_pre_start(
10829    s1: usize,
10830    i1: usize,
10831    n_old: usize,
10832    old_lines: &[&str],
10833    matcher: Option<&FuncnameMatcher>,
10834) -> usize {
10835    if n_old == 0 {
10836        return s1;
10837    }
10838    let i1 = i1.min(n_old.saturating_sub(1));
10839    let mut fs1 = get_func_line_backward(old_lines, i1, matcher).unwrap_or(i1);
10840    while fs1 > 0
10841        && !is_line_empty_for_func_context(old_lines[fs1 - 1])
10842        && !is_func_line(old_lines[fs1 - 1], matcher)
10843    {
10844        fs1 -= 1;
10845    }
10846    s1.min(fs1)
10847}
10848
10849fn expand_func_post_end(
10850    e1: usize,
10851    i1_end: usize,
10852    n_old: usize,
10853    old_lines: &[&str],
10854    matcher: Option<&FuncnameMatcher>,
10855) -> usize {
10856    let from = i1_end.min(n_old);
10857    let fe1 = get_func_line_forward(old_lines, from, matcher).unwrap_or(n_old);
10858    let mut fe1_adj = fe1;
10859    while fe1_adj > 0 && is_line_empty_for_func_context(old_lines[fe1_adj - 1]) {
10860        fe1_adj -= 1;
10861    }
10862    e1.max(fe1_adj).min(n_old)
10863}
10864
10865fn is_line_empty_for_func_context(line: &str) -> bool {
10866    line.chars().all(|c| c.is_whitespace())
10867}
10868
10869pub(crate) fn is_func_line(line: &str, matcher: Option<&FuncnameMatcher>) -> bool {
10870    if let Some(m) = matcher {
10871        return m.match_line(line).is_some();
10872    }
10873    let t = line.trim_end_matches(['\n', '\r']);
10874    if t.is_empty() {
10875        return false;
10876    }
10877    let b = t.as_bytes()[0];
10878    if !(b.is_ascii_alphabetic() || b == b'_' || b == b'$') {
10879        return false;
10880    }
10881    true
10882}
10883
10884fn get_func_line_backward(
10885    old_lines: &[&str],
10886    start: usize,
10887    matcher: Option<&FuncnameMatcher>,
10888) -> Option<usize> {
10889    let mut l = start.min(old_lines.len().saturating_sub(1));
10890    if old_lines.is_empty() {
10891        return None;
10892    }
10893    loop {
10894        if is_func_line(old_lines[l], matcher) {
10895            return Some(l);
10896        }
10897        if l == 0 {
10898            break;
10899        }
10900        l -= 1;
10901    }
10902    None
10903}
10904
10905fn get_func_line_forward(
10906    old_lines: &[&str],
10907    start: usize,
10908    matcher: Option<&FuncnameMatcher>,
10909) -> Option<usize> {
10910    let mut l = start;
10911    while l < old_lines.len() {
10912        if is_func_line(old_lines[l], matcher) {
10913            return Some(l);
10914        }
10915        l += 1;
10916    }
10917    None
10918}
10919#[expect(clippy::too_many_arguments)]
10920/// Compute a unified diff with anchored lines.
10921///
10922/// Anchored lines that appear exactly once in both old and new content are
10923/// forced to match, splitting the diff into segments around those anchor points.
10924/// This produces diffs where the anchored text stays as context and surrounding
10925/// lines are shown as additions/removals.
10926///
10927/// Segment diffs use `algorithm`. When `use_git_histogram` is true, histogram uses the
10928/// Git-compatible histogram engine; otherwise `algorithm` is passed to `similar`.
10929pub fn anchored_unified_diff(
10930    old_content: &str,
10931    new_content: &str,
10932    old_path: &str,
10933    new_path: &str,
10934    context_lines: usize,
10935    anchors: &[String],
10936    algorithm: similar::Algorithm,
10937    use_git_histogram: bool,
10938    indent_heuristic: bool,
10939    quote_path_fully: bool,
10940) -> String {
10941    use crate::quote_path::format_diff_path_with_prefix;
10942    use similar::TextDiff;
10943
10944    let old_lines: Vec<&str> = old_content.lines().collect();
10945    let new_lines: Vec<&str> = new_content.lines().collect();
10946
10947    // Find anchored lines that appear exactly once in both old and new
10948    let mut anchor_pairs: Vec<(usize, usize)> = Vec::new(); // (old_idx, new_idx)
10949
10950    for anchor in anchors {
10951        let anchor_str = anchor.as_str();
10952
10953        // Count occurrences in old
10954        let old_positions: Vec<usize> = old_lines
10955            .iter()
10956            .enumerate()
10957            .filter(|(_, l)| l.trim_end() == anchor_str)
10958            .map(|(i, _)| i)
10959            .collect();
10960
10961        // Count occurrences in new
10962        let new_positions: Vec<usize> = new_lines
10963            .iter()
10964            .enumerate()
10965            .filter(|(_, l)| l.trim_end() == anchor_str)
10966            .map(|(i, _)| i)
10967            .collect();
10968
10969        // Only anchor if unique in both
10970        if old_positions.len() == 1 && new_positions.len() == 1 {
10971            anchor_pairs.push((old_positions[0], new_positions[0]));
10972        }
10973    }
10974
10975    // If no valid anchors, fall back to normal diff
10976    if anchor_pairs.is_empty() {
10977        return unified_diff_with_prefix_and_funcname_and_algorithm(
10978            old_content,
10979            new_content,
10980            old_path,
10981            new_path,
10982            context_lines,
10983            0,
10984            "a/",
10985            "b/",
10986            None,
10987            algorithm,
10988            false,
10989            use_git_histogram,
10990            indent_heuristic,
10991            quote_path_fully,
10992        );
10993    }
10994
10995    // Sort anchor pairs by their position in the old file
10996    anchor_pairs.sort_by_key(|&(old_idx, _)| old_idx);
10997
10998    // Filter to only keep pairs where new positions are also increasing
10999    // (longest increasing subsequence of new positions)
11000    let mut filtered: Vec<(usize, usize)> = Vec::new();
11001    for &pair in &anchor_pairs {
11002        if filtered.is_empty() || filtered.last().is_some_and(|last| pair.1 > last.1) {
11003            filtered.push(pair);
11004        }
11005    }
11006    let anchor_pairs = filtered;
11007
11008    // Build a modified version of old/new where we diff segments between anchors.
11009    // We'll construct the diff by processing segments:
11010    // - Before first anchor
11011    // - Between consecutive anchors
11012    // - After last anchor
11013    // Each anchor line itself is a fixed context match.
11014
11015    // Collect all diff operations
11016    struct LineDiffOp {
11017        tag: char, // ' ', '+', '-'
11018        line: String,
11019    }
11020
11021    let append_segment_diff =
11022        |ops: &mut Vec<LineDiffOp>, old_seg_input: &str, new_seg_input: &str| {
11023            use similar::ChangeTag;
11024            let old_ls: Vec<&str> = old_seg_input.lines().collect();
11025            let new_ls: Vec<&str> = new_seg_input.lines().collect();
11026            if old_ls.is_empty() && new_ls.is_empty() {
11027                return;
11028            }
11029            let seg_diff = TextDiff::configure()
11030                .algorithm(algorithm)
11031                .diff_slices(&old_ls, &new_ls);
11032            let raw = seg_diff.ops().to_vec();
11033            let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
11034                &raw,
11035                &old_ls,
11036                &new_ls,
11037                indent_heuristic,
11038            );
11039            for op in &compacted {
11040                for ch in op.iter_changes(&old_ls, &new_ls) {
11041                    let t = match ch.tag() {
11042                        ChangeTag::Equal => ' ',
11043                        ChangeTag::Delete => '-',
11044                        ChangeTag::Insert => '+',
11045                    };
11046                    ops.push(LineDiffOp {
11047                        tag: t,
11048                        line: ch.value().to_string(),
11049                    });
11050                }
11051            }
11052        };
11053
11054    let mut ops: Vec<LineDiffOp> = Vec::new();
11055    let mut old_pos = 0usize;
11056    let mut new_pos = 0usize;
11057
11058    for &(old_anchor, new_anchor) in &anchor_pairs {
11059        // Diff the segment before this anchor
11060        let old_segment: Vec<&str> = old_lines[old_pos..old_anchor].to_vec();
11061        let new_segment: Vec<&str> = new_lines[new_pos..new_anchor].to_vec();
11062
11063        let old_seg_text = old_segment.join("\n");
11064        let new_seg_text = new_segment.join("\n");
11065
11066        if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
11067            let old_seg_input = if old_seg_text.is_empty() {
11068                String::new()
11069            } else {
11070                format!("{}\n", old_seg_text)
11071            };
11072            let new_seg_input = if new_seg_text.is_empty() {
11073                String::new()
11074            } else {
11075                format!("{}\n", new_seg_text)
11076            };
11077            append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
11078        }
11079
11080        // The anchor line itself is always context
11081        ops.push(LineDiffOp {
11082            tag: ' ',
11083            line: old_lines[old_anchor].to_string(),
11084        });
11085
11086        old_pos = old_anchor + 1;
11087        new_pos = new_anchor + 1;
11088    }
11089
11090    // Diff the remaining segment after the last anchor
11091    let old_segment: Vec<&str> = old_lines[old_pos..].to_vec();
11092    let new_segment: Vec<&str> = new_lines[new_pos..].to_vec();
11093    let old_seg_text = old_segment.join("\n");
11094    let new_seg_text = new_segment.join("\n");
11095
11096    if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
11097        let old_seg_input = if old_seg_text.is_empty() {
11098            String::new()
11099        } else {
11100            format!("{}\n", old_seg_text)
11101        };
11102        let new_seg_input = if new_seg_text.is_empty() {
11103            String::new()
11104        } else {
11105            format!("{}\n", new_seg_text)
11106        };
11107        append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
11108    }
11109
11110    // Now format as unified diff with hunks
11111    let mut output = String::new();
11112    if old_path == "/dev/null" {
11113        output.push_str("--- /dev/null\n");
11114    } else {
11115        output.push_str("--- ");
11116        output.push_str(&format_diff_path_with_prefix(
11117            "a/",
11118            old_path,
11119            quote_path_fully,
11120        ));
11121        output.push('\n');
11122    }
11123    if new_path == "/dev/null" {
11124        output.push_str("+++ /dev/null\n");
11125    } else {
11126        output.push_str("+++ ");
11127        output.push_str(&format_diff_path_with_prefix(
11128            "b/",
11129            new_path,
11130            quote_path_fully,
11131        ));
11132        output.push('\n');
11133    }
11134
11135    // Group ops into hunks with context
11136    let total_ops = ops.len();
11137    if total_ops == 0 {
11138        return output;
11139    }
11140
11141    // Find ranges of changes
11142    let mut hunks: Vec<(usize, usize)> = Vec::new(); // (start, end) indices into ops
11143    let mut i = 0;
11144    while i < total_ops {
11145        if ops[i].tag != ' ' {
11146            let start = i.saturating_sub(context_lines);
11147            let mut end = i;
11148            // Extend to include consecutive changes and their context
11149            while end < total_ops {
11150                if ops[end].tag != ' ' {
11151                    end += 1;
11152                    continue;
11153                }
11154                // Check if there's another change within context_lines
11155                let mut next_change = end;
11156                while next_change < total_ops && ops[next_change].tag == ' ' {
11157                    next_change += 1;
11158                }
11159                if next_change < total_ops && next_change - end <= context_lines * 2 {
11160                    end = next_change + 1;
11161                } else {
11162                    end = (end + context_lines).min(total_ops);
11163                    break;
11164                }
11165            }
11166            // Merge with previous hunk if overlapping
11167            if let Some(last) = hunks.last_mut() {
11168                if start <= last.1 {
11169                    last.1 = end;
11170                } else {
11171                    hunks.push((start, end));
11172                }
11173            } else {
11174                hunks.push((start, end));
11175            }
11176            i = end;
11177        } else {
11178            i += 1;
11179        }
11180    }
11181
11182    // Output each hunk
11183    for (start, end) in hunks {
11184        // Count old/new lines in this hunk
11185        let mut old_start = 1usize;
11186        let mut new_start = 1usize;
11187        // Calculate line numbers by counting ops before this hunk
11188        for op in &ops[..start] {
11189            match op.tag {
11190                ' ' => {
11191                    old_start += 1;
11192                    new_start += 1;
11193                }
11194                '-' => {
11195                    old_start += 1;
11196                }
11197                '+' => {
11198                    new_start += 1;
11199                }
11200                _ => {}
11201            }
11202        }
11203        let mut old_count = 0usize;
11204        let mut new_count = 0usize;
11205        for op in &ops[start..end] {
11206            match op.tag {
11207                ' ' => {
11208                    old_count += 1;
11209                    new_count += 1;
11210                }
11211                '-' => {
11212                    old_count += 1;
11213                }
11214                '+' => {
11215                    new_count += 1;
11216                }
11217                _ => {}
11218            }
11219        }
11220
11221        output.push_str(&format!(
11222            "@@ -{},{} +{},{} @@\n",
11223            old_start, old_count, new_start, new_count
11224        ));
11225        for op in &ops[start..end] {
11226            output.push(op.tag);
11227            output.push_str(&op.line);
11228            output.push('\n');
11229        }
11230    }
11231
11232    output
11233}
11234
11235/// Extract function context for a hunk header.
11236///
11237/// Given a hunk header like `@@ -8,7 +8,7 @@`, find the last line
11238/// before line 8 in the old content that looks like a function header
11239/// (starts with a non-whitespace character, like Git's default).
11240fn func_context_line_matches(line: &str, funcname_matcher: Option<&FuncnameMatcher>) -> bool {
11241    if let Some(m) = funcname_matcher {
11242        return m.match_line(line).is_some();
11243    }
11244    is_func_line(line, None)
11245}
11246
11247fn extract_function_context(
11248    header: &str,
11249    old_lines: &[&str],
11250    funcname_matcher: Option<&FuncnameMatcher>,
11251    _source_path: Option<&str>,
11252) -> Option<String> {
11253    // Parse the old start line number from "@@ -<start>,<count> ..."
11254    let at_pos = header.find("-")?;
11255    let rest = &header[at_pos + 1..];
11256    let comma_or_space = rest.find([',', ' '])?;
11257    let start_str = &rest[..comma_or_space];
11258    let start_line: usize = start_str.parse().ok()?;
11259
11260    // Parse the old line count; "@@ -<start>,<count> ..." (no comma means count 1).
11261    // Only look for the comma inside the old-range token itself — searching the
11262    // whole remainder would pick up the comma from the new side (e.g. "+0,0").
11263    let old_token_end = rest.find([' ', '\t']).unwrap_or(rest.len());
11264    let old_token = &rest[..old_token_end];
11265    let old_count: usize = if let Some(comma) = old_token.find(',') {
11266        old_token[comma + 1..].parse().unwrap_or(1)
11267    } else {
11268        1
11269    };
11270
11271    if start_line == 0 {
11272        return None;
11273    }
11274
11275    // Look backwards for a line that matches the funcname pattern. start_line is
11276    // 1-indexed. For a normal hunk the first changed pre-image line is
11277    // old_lines[start_line-1], so we search lines strictly before it
11278    // (old_lines[0..start_line-1]). For a pure insertion (old count 0) the
11279    // content is inserted *after* old line start_line, so Git's function search
11280    // begins at that line itself: search old_lines[0..start_line].
11281    if start_line == 0 {
11282        return None;
11283    }
11284    let search_end = if old_count == 0 {
11285        start_line.min(old_lines.len())
11286    } else {
11287        if start_line <= 1 {
11288            return None;
11289        }
11290        (start_line - 1).min(old_lines.len())
11291    };
11292    let truncate = |line: &str| {
11293        let mut end = line.len().min(80);
11294        while end > 0 && !line.is_char_boundary(end) {
11295            end -= 1;
11296        }
11297        let mut out = line[..end].to_owned();
11298        let tail = line[end..].trim_start();
11299        // Git drops trailing whitespace on truncated funcname lines when the
11300        // remainder of the source line continues with normal text (t2021-style
11301        // shell tests), but keeps it when the next token is a string delimiter
11302        // (gettext `.po` msgstr lines).
11303        if tail.is_empty()
11304            || (!tail.starts_with('"')
11305                && !tail.starts_with('\'')
11306                && !line.starts_with("msgstr")
11307                && !line.starts_with("msgid"))
11308        {
11309            out = out.trim_end().to_owned();
11310        }
11311        out
11312    };
11313
11314    for i in (0..search_end).rev() {
11315        let line = old_lines[i];
11316        if line.is_empty() {
11317            continue;
11318        }
11319        if func_context_line_matches(line, funcname_matcher) {
11320            if let Some(matcher) = funcname_matcher {
11321                if let Some(matched) = matcher.match_line(line) {
11322                    return Some(truncate(&matched));
11323                }
11324                continue;
11325            }
11326            return Some(truncate(line));
11327        }
11328    }
11329    None
11330}
11331
11332fn last_line_index_without_trailing_newline(text: &str) -> Option<usize> {
11333    LineSide::from_text(text).incomplete_last_line_index()
11334}
11335
11336fn parse_unified_hunk_starts(header: &str) -> Option<(usize, usize)> {
11337    let rest = header.strip_prefix("@@ ")?;
11338    let (old_part, new_part) = rest.split_once(" +")?;
11339    let old_start = parse_unified_range_start(old_part.strip_prefix('-')?)?;
11340    let new_start = parse_unified_range_start(new_part.split(" @@").next()?)?;
11341    Some((old_start, new_start))
11342}
11343
11344fn parse_unified_range_start(spec: &str) -> Option<usize> {
11345    let spec = spec.trim();
11346    if let Some((start, _)) = spec.split_once(',') {
11347        start.parse().ok()
11348    } else {
11349        spec.parse().ok()
11350    }
11351}
11352
11353/// Tracks hunk line numbers while streaming `\ No newline at end of file` markers.
11354struct UnifiedDiffNoNewlineAnnotator {
11355    old_last: Option<usize>,
11356    new_last: Option<usize>,
11357    old_line: usize,
11358    new_line: usize,
11359    passthrough: bool,
11360}
11361
11362impl UnifiedDiffNoNewlineAnnotator {
11363    fn new(old: &str, new: &str) -> Self {
11364        let old_last = last_line_index_without_trailing_newline(old);
11365        let new_last = last_line_index_without_trailing_newline(new);
11366        Self {
11367            old_last,
11368            new_last,
11369            old_line: 1,
11370            new_line: 1,
11371            passthrough: old_last.is_none() && new_last.is_none(),
11372        }
11373    }
11374
11375    fn write_hunk<W: std::io::Write>(&mut self, out: &mut W, patch: &str) -> Result<()> {
11376        if self.passthrough {
11377            out.write_all(patch.as_bytes())?;
11378            return Ok(());
11379        }
11380        let mut lines = patch.lines().peekable();
11381        while let Some(line) = lines.next() {
11382            self.write_annotated_line(out, line, &mut lines)?;
11383        }
11384        Ok(())
11385    }
11386
11387    fn write_annotated_line<W: std::io::Write>(
11388        &mut self,
11389        out: &mut W,
11390        line: &str,
11391        lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
11392    ) -> Result<()> {
11393        if let Some((o, n)) = line
11394            .strip_prefix("@@ ")
11395            .and_then(|_| parse_unified_hunk_starts(line))
11396        {
11397            self.old_line = o;
11398            self.new_line = n;
11399            writeln!(out, "{line}")?;
11400            return Ok(());
11401        }
11402        if line.starts_with("--- ")
11403            || line.starts_with("+++ ")
11404            || line.starts_with("diff --git ")
11405            || line.starts_with("diff --cc ")
11406            || line.starts_with("diff --combined ")
11407        {
11408            writeln!(out, "{line}")?;
11409            return Ok(());
11410        }
11411        if line.starts_with('-') {
11412            writeln!(out, "{line}")?;
11413            let already_marked = lines
11414                .peek()
11415                .is_some_and(|next| *next == "\\ No newline at end of file");
11416            if !already_marked && self.old_last == Some(self.old_line.saturating_sub(1)) {
11417                writeln!(out, "\\ No newline at end of file")?;
11418            }
11419            self.old_line += 1;
11420            return Ok(());
11421        }
11422        if line.starts_with('+') {
11423            writeln!(out, "{line}")?;
11424            let already_marked = lines
11425                .peek()
11426                .is_some_and(|next| *next == "\\ No newline at end of file");
11427            if !already_marked && self.new_last == Some(self.new_line.saturating_sub(1)) {
11428                writeln!(out, "\\ No newline at end of file")?;
11429            }
11430            self.new_line += 1;
11431            return Ok(());
11432        }
11433        if line.starts_with(' ') {
11434            writeln!(out, "{line}")?;
11435            let already_marked = lines
11436                .peek()
11437                .is_some_and(|next| *next == "\\ No newline at end of file");
11438            if !already_marked {
11439                let old_idx = self.old_line.saturating_sub(1);
11440                let new_idx = self.new_line.saturating_sub(1);
11441                if self.old_last == Some(old_idx) || self.new_last == Some(new_idx) {
11442                    writeln!(out, "\\ No newline at end of file")?;
11443                }
11444            }
11445            self.old_line += 1;
11446            self.new_line += 1;
11447            return Ok(());
11448        }
11449        writeln!(out, "{line}")?;
11450        Ok(())
11451    }
11452}
11453
11454/// Insert Git `\ No newline at end of file` after `-`/`+` hunk lines when that side of the
11455/// file lacks a trailing newline on the last line.
11456#[must_use]
11457pub fn annotate_unified_diff_no_newline_at_eof(patch: &str, old: &str, new: &str) -> String {
11458    let mut annotator = UnifiedDiffNoNewlineAnnotator::new(old, new);
11459    if annotator.passthrough {
11460        return patch.to_string();
11461    }
11462    let mut out = Vec::with_capacity(patch.len() + 64);
11463    let mut lines = patch.lines().peekable();
11464    while let Some(line) = lines.next() {
11465        if annotator
11466            .write_annotated_line(&mut out, line, &mut lines)
11467            .is_err()
11468        {
11469            return patch.to_string();
11470        }
11471    }
11472    String::from_utf8(out).unwrap_or_else(|_| patch.to_string())
11473}
11474
11475/// Normalise one line like Git's `-b` / `--ignore-space-change`.
11476#[must_use]
11477pub fn normalize_ignore_space_change_line(line: &str) -> String {
11478    let mut result = String::with_capacity(line.len());
11479    let mut in_space = false;
11480    for c in line.chars() {
11481        if c.is_whitespace() {
11482            if !in_space {
11483                result.push(' ');
11484                in_space = true;
11485            }
11486        } else {
11487            result.push(c);
11488            in_space = false;
11489        }
11490    }
11491    while result.ends_with(' ') {
11492        result.pop();
11493    }
11494    result
11495}
11496
11497/// Normalise text like Git's `-b` / `--ignore-space-change`: on each line, collapse runs of
11498/// whitespace to a single ASCII space and trim trailing spaces.
11499///
11500/// Line breaks are preserved by splitting on [`str::lines`] and rejoining with `\n` (same approach
11501/// as the porcelain `diff` whitespace handling in `grit`).
11502#[must_use]
11503pub fn normalize_ignore_space_change(content: &str) -> String {
11504    content
11505        .lines()
11506        .map(normalize_ignore_space_change_line)
11507        .collect::<Vec<_>>()
11508        .join("\n")
11509}
11510
11511/// Count insertions and deletions using the given line-diff algorithm.
11512///
11513/// Uses the same compacted line alignment as unified patches and [`crate::diff::line_change_counts`].
11514#[must_use]
11515pub fn count_changes_with_algorithm(
11516    old_content: &str,
11517    new_content: &str,
11518    algorithm: similar::Algorithm,
11519    use_git_histogram: bool,
11520) -> (usize, usize) {
11521    use crate::diff::options::{DiffAlgorithm, DiffOptions};
11522    let diff_algorithm = if use_git_histogram {
11523        DiffAlgorithm::Histogram
11524    } else {
11525        match algorithm {
11526            similar::Algorithm::Myers => DiffAlgorithm::Myers,
11527            similar::Algorithm::Lcs => DiffAlgorithm::Minimal,
11528            similar::Algorithm::Patience => DiffAlgorithm::Patience,
11529            _ => DiffAlgorithm::Myers,
11530        }
11531    };
11532    count_line_changes_with_options(
11533        old_content,
11534        new_content,
11535        &DiffOptions {
11536            algorithm: diff_algorithm,
11537            ..DiffOptions::default()
11538        },
11539    )
11540}
11541
11542/// Count insertions and deletions using Git's histogram line-diff engine.
11543#[must_use]
11544pub fn count_changes_git_histogram(old_content: &str, new_content: &str) -> (usize, usize) {
11545    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, true)
11546}
11547
11548/// Line count for diffstat/`--numstat`, matching Git's `count_lines()` in `diff.c`.
11549///
11550/// Counts newline-terminated lines; a final line without trailing newline still counts as one line.
11551/// An empty buffer yields `0`.
11552#[must_use]
11553pub fn count_git_lines(data: &[u8]) -> usize {
11554    if data.is_empty() {
11555        return 0;
11556    }
11557    let mut count = 0usize;
11558    let mut nl_just_seen = false;
11559    for &ch in data {
11560        if ch == b'\n' {
11561            count += 1;
11562            nl_just_seen = true;
11563        } else {
11564            nl_just_seen = false;
11565        }
11566    }
11567    if !nl_just_seen {
11568        count += 1;
11569    }
11570    count
11571}
11572
11573/// Internal maximum diff score used by Git rename/break heuristics (`MAX_SCORE` in `diffcore.h`).
11574pub const GIT_DIFF_MAX_SCORE: u64 = 60_000;
11575const DIFF_MAX_SCORE: u64 = GIT_DIFF_MAX_SCORE;
11576const DIFF_MINIMUM_BREAK_SIZE: usize = 400;
11577const DIFF_DEFAULT_BREAK_SCORE: u64 = 30_000;
11578/// Default break threshold (`DEFAULT_BREAK_SCORE` in `diffcore.h`), internal 0–[`GIT_DIFF_MAX_SCORE`] scale.
11579pub const GIT_DIFF_DEFAULT_BREAK_SCORE: u64 = DIFF_DEFAULT_BREAK_SCORE;
11580const DIFF_HASHBASE: u32 = 107_927;
11581
11582#[derive(Clone, Copy, Default)]
11583struct SpanSlot {
11584    hashval: u32,
11585    cnt: u32,
11586}
11587
11588struct SpanHashTop {
11589    alloc_log2: u8,
11590    free_slots: i32,
11591    data: Vec<SpanSlot>,
11592}
11593
11594impl SpanHashTop {
11595    fn new(initial_log2: u8) -> Self {
11596        let cap = 1usize << initial_log2;
11597        Self {
11598            alloc_log2: initial_log2,
11599            free_slots: initial_free(initial_log2),
11600            data: vec![SpanSlot::default(); cap],
11601        }
11602    }
11603
11604    fn len(&self) -> usize {
11605        1usize << self.alloc_log2
11606    }
11607
11608    fn add_span(&mut self, hashval: u32, cnt: u32) {
11609        loop {
11610            let lim = self.len();
11611            let mut bucket = (hashval as usize) & (lim - 1);
11612            loop {
11613                let h = &mut self.data[bucket];
11614                if h.cnt == 0 {
11615                    h.hashval = hashval;
11616                    h.cnt = cnt;
11617                    self.free_slots -= 1;
11618                    if self.free_slots < 0 {
11619                        self.rehash();
11620                        break;
11621                    }
11622                    return;
11623                }
11624                if h.hashval == hashval {
11625                    h.cnt = h.cnt.saturating_add(cnt);
11626                    return;
11627                }
11628                bucket += 1;
11629                if bucket >= lim {
11630                    bucket = 0;
11631                }
11632            }
11633        }
11634    }
11635
11636    fn rehash(&mut self) {
11637        let old = std::mem::take(&mut self.data);
11638        let old_log = self.alloc_log2;
11639        self.alloc_log2 = old_log.saturating_add(1);
11640        let new_len = 1usize << self.alloc_log2;
11641        self.free_slots = initial_free(self.alloc_log2);
11642        self.data = vec![SpanSlot::default(); new_len];
11643        let old_sz = 1usize << old_log;
11644        for o in old.iter().take(old_sz) {
11645            let o = *o;
11646            if o.cnt == 0 {
11647                continue;
11648            }
11649            self.add_span_after_rehash(o.hashval, o.cnt);
11650        }
11651    }
11652
11653    fn add_span_after_rehash(&mut self, hashval: u32, cnt: u32) {
11654        loop {
11655            let lim = self.len();
11656            let mut bucket = (hashval as usize) & (lim - 1);
11657            loop {
11658                let h = &mut self.data[bucket];
11659                if h.cnt == 0 {
11660                    h.hashval = hashval;
11661                    h.cnt = cnt;
11662                    self.free_slots -= 1;
11663                    if self.free_slots < 0 {
11664                        self.rehash();
11665                        break;
11666                    }
11667                    return;
11668                }
11669                if h.hashval == hashval {
11670                    h.cnt = h.cnt.saturating_add(cnt);
11671                    return;
11672                }
11673                bucket += 1;
11674                if bucket >= lim {
11675                    bucket = 0;
11676                }
11677            }
11678        }
11679    }
11680
11681    fn sort_by_hashval(&mut self) {
11682        let sz = self.len();
11683        self.data[..sz].sort_by(|a, b| {
11684            if a.cnt == 0 {
11685                return std::cmp::Ordering::Greater;
11686            }
11687            if b.cnt == 0 {
11688                return std::cmp::Ordering::Less;
11689            }
11690            a.hashval.cmp(&b.hashval)
11691        });
11692    }
11693}
11694
11695fn initial_free(sz_log2: u8) -> i32 {
11696    let sz = sz_log2 as i32;
11697    ((1i32 << sz_log2) * (sz - 3) / sz).max(0)
11698}
11699
11700fn hash_blob_spans(buf: &[u8], is_text: bool) -> SpanHashTop {
11701    let mut hash = SpanHashTop::new(9);
11702    let mut n = 0u32;
11703    let mut accum1: u32 = 0;
11704    let mut accum2: u32 = 0;
11705    let mut i = 0usize;
11706    while i < buf.len() {
11707        let c = buf[i] as u32;
11708        let old_1 = accum1;
11709        i += 1;
11710
11711        if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
11712            continue;
11713        }
11714
11715        accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
11716        accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
11717        accum1 = accum1.wrapping_add(c);
11718        n += 1;
11719        if n < 64 && c != b'\n' as u32 {
11720            continue;
11721        }
11722        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
11723        hash.add_span(hashval, n);
11724        n = 0;
11725        accum1 = 0;
11726        accum2 = 0;
11727    }
11728    if n > 0 {
11729        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
11730        hash.add_span(hashval, n);
11731    }
11732    hash.sort_by_hashval();
11733    hash
11734}
11735
11736/// Approximate copied vs added material between two blobs (Git `diffcore_count_changes`).
11737///
11738/// Returns `(copied_bytes_from_src, literal_added_bytes_in_dst)` matching Git's
11739/// `diffcore_count_changes` semantics (used for `--dirstat=changes` damage).
11740#[must_use]
11741pub fn diffcore_count_changes(old: &[u8], new: &[u8]) -> (u64, u64) {
11742    crate::trace::trace_span!("grit.diff.blob");
11743    let src_is_text = !crate::merge_file::is_binary(old);
11744    let dst_is_text = !crate::merge_file::is_binary(new);
11745    let src_count = hash_blob_spans(old, src_is_text);
11746    let dst_count = hash_blob_spans(new, dst_is_text);
11747    let mut sc: u64 = 0;
11748    let mut la: u64 = 0;
11749    let mut si = 0usize;
11750    let mut di = 0usize;
11751    let src_len = src_count.len();
11752    let dst_len = dst_count.len();
11753    loop {
11754        if si >= src_len || src_count.data[si].cnt == 0 {
11755            break;
11756        }
11757        let s_hash = src_count.data[si].hashval;
11758        let s_cnt = u64::from(src_count.data[si].cnt);
11759        while di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval < s_hash {
11760            la += u64::from(dst_count.data[di].cnt);
11761            di += 1;
11762        }
11763        let mut dst_cnt = 0u64;
11764        if di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval == s_hash {
11765            dst_cnt = u64::from(dst_count.data[di].cnt);
11766            di += 1;
11767        }
11768        if s_cnt < dst_cnt {
11769            la += dst_cnt - s_cnt;
11770            sc += s_cnt;
11771        } else {
11772            sc += dst_cnt;
11773        }
11774        si += 1;
11775    }
11776    while di < dst_len && dst_count.data[di].cnt != 0 {
11777        la += u64::from(dst_count.data[di].cnt);
11778        di += 1;
11779    }
11780    (sc, la)
11781}
11782
11783/// Whether an in-place blob edit should be split into delete+create for rename/copy (`should_break`
11784/// in `diffcore-break.c`). `break_score` is on the internal 0–[`GIT_DIFF_MAX_SCORE`] scale (default
11785/// `DIFF_DEFAULT_BREAK_SCORE`).
11786#[must_use]
11787pub fn should_break_rewrite_pair(old: &[u8], new: &[u8], break_score: u64) -> bool {
11788    should_break_rewrite_inner(old, new, break_score)
11789}
11790
11791/// Drop entries that do not match `filter` (Git `--diff-filter`, after rename detection).
11792#[must_use]
11793pub fn apply_diff_filter(
11794    entries: Vec<DiffEntry>,
11795    filter: Option<super::filter::DiffFilter>,
11796) -> Vec<DiffEntry> {
11797    match filter {
11798        Some(f) => f.filter_entries(entries),
11799        None => entries,
11800    }
11801}
11802
11803/// Parse `-M` / `--find-renames` threshold into Git's 0–100 similarity percent.
11804#[must_use]
11805pub fn parse_diff_rename_threshold_percent(arg: &str) -> Option<u8> {
11806    let token = if arg.is_empty() { "50" } else { arg };
11807    let internal = parse_diff_rename_score_token(token)?;
11808    Some((internal.saturating_mul(100) / GIT_DIFF_MAX_SCORE).min(100) as u8)
11809}
11810
11811/// Parse a single Git `parse_rename_score` token (`50`, `50%`, decimal forms) into internal
11812/// 0–[`GIT_DIFF_MAX_SCORE`] units.
11813pub fn parse_diff_rename_score_token(arg: &str) -> Option<u64> {
11814    let mut num: u64 = 0;
11815    let mut scale: u64 = 1;
11816    let mut dot = false;
11817    let mut saw_digit = false;
11818    for ch in arg.chars() {
11819        if !dot && ch == '.' {
11820            scale = 1;
11821            dot = true;
11822            continue;
11823        }
11824        if ch == '%' {
11825            scale = if dot { scale.saturating_mul(100) } else { 100 };
11826            break;
11827        }
11828        if ch.is_ascii_digit() {
11829            saw_digit = true;
11830            if scale < 100_000 {
11831                scale = scale.saturating_mul(10);
11832                num = num.saturating_mul(10) + u64::from(ch as u8 - b'0');
11833            }
11834        } else {
11835            break;
11836        }
11837    }
11838    if !saw_digit {
11839        return None;
11840    }
11841    Some(if num >= scale {
11842        GIT_DIFF_MAX_SCORE
11843    } else {
11844        GIT_DIFF_MAX_SCORE * num / scale
11845    })
11846}
11847
11848/// Git `merge_score` from `diffcore-break.c` when a pair is considered broken: how much of the
11849/// source blob was removed (0–`DIFF_MAX_SCORE` scale). Used for `dissimilarity index` metadata.
11850#[must_use]
11851pub fn rewrite_merge_score(old: &[u8], new: &[u8]) -> Option<u64> {
11852    if old.is_empty() {
11853        return None;
11854    }
11855    let max_size = old.len().max(new.len());
11856    if max_size < DIFF_MINIMUM_BREAK_SIZE {
11857        return None;
11858    }
11859    let (src_copied, _) = diffcore_count_changes(old, new);
11860    let src_copied = src_copied.min(old.len() as u64);
11861    let src_removed = (old.len() as u64).saturating_sub(src_copied);
11862    Some(src_removed * DIFF_MAX_SCORE / old.len() as u64)
11863}
11864
11865/// Percentage shown in `dissimilarity index N%` for a rewrite (`similarity_index` in Git's diff.c).
11866#[must_use]
11867pub fn rewrite_dissimilarity_index_percent(old: &[u8], new: &[u8]) -> Option<u32> {
11868    let score = rewrite_merge_score(old, new)?;
11869    Some((score * 100 / DIFF_MAX_SCORE).min(100) as u32)
11870}
11871
11872fn should_break_rewrite_inner(src: &[u8], dst: &[u8], break_score: u64) -> bool {
11873    if src.is_empty() {
11874        return false;
11875    }
11876    let max_size = src.len().max(dst.len());
11877    if max_size < DIFF_MINIMUM_BREAK_SIZE {
11878        return false;
11879    }
11880    let (src_copied, literal_added) = diffcore_count_changes(src, dst);
11881    let src_copied = src_copied.min(src.len() as u64);
11882    let mut literal_added = literal_added;
11883    let dst_len = dst.len() as u64;
11884    if src_copied < dst_len && literal_added + src_copied > dst_len {
11885        literal_added = dst_len.saturating_sub(src_copied);
11886    }
11887    let src_removed = (src.len() as u64).saturating_sub(src_copied);
11888    let merge_score = src_removed * DIFF_MAX_SCORE / src.len() as u64;
11889    if merge_score > break_score {
11890        return true;
11891    }
11892    let delta_size = src_removed.saturating_add(literal_added);
11893    if delta_size * DIFF_MAX_SCORE / (max_size as u64) < break_score {
11894        return false;
11895    }
11896    let s = src.len() as u64;
11897    if (s * break_score < src_removed * DIFF_MAX_SCORE)
11898        && (literal_added * 20 < src_removed)
11899        && (literal_added * 20 < src_copied)
11900    {
11901        return false;
11902    }
11903    true
11904}
11905
11906// ── Helpers ─────────────────────────────────────────────────────────
11907
11908/// Flatten a tree object recursively into a sorted list of (path, mode, oid).
11909struct FlatEntry {
11910    path: String,
11911    mode: u32,
11912    oid: ObjectId,
11913}
11914
11915fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
11916    flatten_tree_with_index(odb, tree_oid, prefix, None)
11917}
11918
11919fn sparse_index_skips_tree_descent(index: &Index, dir_path: &str, tree_oid: &ObjectId) -> bool {
11920    let mut storage = crate::repo_relpath::storage_bytes(dir_path).into_owned();
11921    if !storage.ends_with(b"/") {
11922        storage.push(b'/');
11923    }
11924    index.find_entry_pos(&storage, 0).is_some_and(|pos| {
11925        let entry = &index.entries[pos];
11926        entry.is_sparse_directory_placeholder() && entry.oid == *tree_oid
11927    })
11928}
11929
11930/// Tree OID at `path` (slash-separated, no trailing slash) under `root`, if every component is a tree.
11931fn subtree_oid_at_path(odb: &Odb, root: &ObjectId, path: &str) -> Result<Option<ObjectId>> {
11932    let mut oid = *root;
11933    for component in path.split('/').filter(|c| !c.is_empty()) {
11934        let entries = read_tree(odb, &oid)?;
11935        let Some(entry) = entries
11936            .iter()
11937            .find(|e| e.name.as_slice() == component.as_bytes())
11938        else {
11939            return Ok(None);
11940        };
11941        if !is_tree_mode(entry.mode) {
11942            return Ok(None);
11943        }
11944        oid = entry.oid;
11945    }
11946    Ok(Some(oid))
11947}
11948
11949/// Report every blob in an index subtree as staged additions (HEAD has no tree at this path).
11950fn emit_flattened_subtree_as_added(
11951    odb: &Odb,
11952    subtree: &ObjectId,
11953    prefix: &str,
11954    ignore_submodules: bool,
11955    result: &mut Vec<DiffEntry>,
11956) -> Result<()> {
11957    for entry in flatten_tree(odb, subtree, prefix)? {
11958        if ignore_submodules && entry.mode == 0o160000 {
11959            continue;
11960        }
11961        result.push(DiffEntry {
11962            status: DiffStatus::Added,
11963            old_path: None,
11964            new_path: Some(entry.path),
11965            old_mode: FileMode::ABSENT,
11966            new_mode: FileMode::new(entry.mode),
11967            old_oid: zero_oid(),
11968            new_oid: entry.oid,
11969            score: None,
11970        });
11971    }
11972    Ok(())
11973}
11974
11975/// Compare two flattened subtrees and append staged diff entries.
11976fn diff_flattened_subtrees(
11977    head: &[FlatEntry],
11978    index: &[FlatEntry],
11979    ignore_submodules: bool,
11980    result: &mut Vec<DiffEntry>,
11981    stage0_paths: &mut std::collections::BTreeSet<String>,
11982    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
11983) {
11984    let mut head_map: std::collections::BTreeMap<&str, &FlatEntry> = head
11985        .iter()
11986        .map(|entry| (entry.path.as_str(), entry))
11987        .collect();
11988    for entry in index {
11989        stage0_paths.insert(entry.path.clone());
11990        tree_map.remove(entry.path.as_str());
11991        match head_map.remove(entry.path.as_str()) {
11992            Some(te) => {
11993                if te.oid != entry.oid || te.mode != entry.mode {
11994                    result.push(DiffEntry {
11995                        status: DiffStatus::for_content_change(te.mode, entry.mode),
11996                        old_path: Some(entry.path.clone()),
11997                        new_path: Some(entry.path.clone()),
11998                        old_mode: FileMode::new(te.mode),
11999                        new_mode: FileMode::new(entry.mode),
12000                        old_oid: te.oid,
12001                        new_oid: entry.oid,
12002                        score: None,
12003                    });
12004                }
12005            }
12006            None => {
12007                result.push(DiffEntry {
12008                    status: DiffStatus::Added,
12009                    old_path: None,
12010                    new_path: Some(entry.path.clone()),
12011                    old_mode: FileMode::ABSENT,
12012                    new_mode: FileMode::new(entry.mode),
12013                    old_oid: zero_oid(),
12014                    new_oid: entry.oid,
12015                    score: None,
12016                });
12017            }
12018        }
12019    }
12020    for (path, te) in head_map {
12021        if ignore_submodules && te.mode == 0o160000 {
12022            continue;
12023        }
12024        tree_map.remove(path);
12025        result.push(DiffEntry {
12026            status: DiffStatus::Deleted,
12027            old_path: Some(path.to_owned()),
12028            new_path: None,
12029            old_mode: FileMode::new(te.mode),
12030            new_mode: FileMode::ABSENT,
12031            old_oid: te.oid,
12032            new_oid: zero_oid(),
12033            score: None,
12034        });
12035    }
12036}
12037
12038fn flatten_tree_with_index(
12039    odb: &Odb,
12040    tree_oid: &ObjectId,
12041    prefix: &str,
12042    index: Option<&Index>,
12043) -> Result<Vec<FlatEntry>> {
12044    let entries = read_tree(odb, tree_oid)?;
12045    let mut result = Vec::new();
12046
12047    for entry in entries {
12048        let name_str = String::from_utf8_lossy(&entry.name);
12049        let path = format_path(prefix, &name_str);
12050        if is_tree_mode(entry.mode) {
12051            if index.is_some_and(|idx| sparse_index_skips_tree_descent(idx, &path, &entry.oid)) {
12052                continue;
12053            }
12054            let nested = flatten_tree_with_index(odb, &entry.oid, &path, index)?;
12055            result.extend(nested);
12056        } else {
12057            result.push(FlatEntry {
12058                path,
12059                mode: entry.mode,
12060                oid: entry.oid,
12061            });
12062        }
12063    }
12064
12065    Ok(result)
12066}
12067
12068/// Paths present in `HEAD`'s tree with mode and blob/commit OID (for status porcelain v2).
12069pub fn head_path_states(
12070    odb: &Odb,
12071    head_tree: Option<&ObjectId>,
12072) -> Result<std::collections::BTreeMap<String, (u32, ObjectId)>> {
12073    let mut m = std::collections::BTreeMap::new();
12074    let Some(t) = head_tree else {
12075        return Ok(m);
12076    };
12077    for fe in flatten_tree(odb, t, "")? {
12078        m.insert(fe.path, (fe.mode, fe.oid));
12079    }
12080    Ok(m)
12081}
12082
12083/// Whether a mode represents a tree (directory).
12084fn is_tree_mode(mode: u32) -> bool {
12085    crate::index::is_tree_mode(mode)
12086}
12087
12088/// Git orders diff lists by raw path bytes, which differs from the stored form of
12089/// non-UTF-8 paths (see [`crate::repo_relpath::storage_bytes`]).
12090fn path_order(a: &DiffEntry, b: &DiffEntry) -> std::cmp::Ordering {
12091    use crate::repo_relpath::storage_bytes;
12092    storage_bytes(a.path()).cmp(&storage_bytes(b.path()))
12093}
12094
12095fn entries_sorted_by_path(entries: &[DiffEntry]) -> bool {
12096    entries.is_sorted_by(|a, b| path_order(a, b).is_le())
12097}
12098
12099fn entries_strictly_sorted_by_path(entries: &[DiffEntry]) -> bool {
12100    entries.is_sorted_by(|a, b| path_order(a, b).is_lt())
12101}
12102
12103/// Stable-sort diff entries by [`DiffEntry::path`] in raw byte order, Git's output order
12104/// for diff lists (non-UTF-8 paths compare by their decoded bytes).
12105///
12106/// Tree diffs and rename detection already return entries in this order, so the common
12107/// case is a single comparison pass with no allocation (a stable sort would otherwise
12108/// allocate scratch space proportional to the list).
12109///
12110/// # Parameters
12111///
12112/// - `entries`: the list to order in place; entries with equal paths keep their order.
12113pub fn sort_entries_by_path(entries: &mut [DiffEntry]) {
12114    if entries_sorted_by_path(entries) {
12115        return;
12116    }
12117    entries.sort_by(path_order);
12118}
12119
12120/// Build a path with an optional prefix.
12121fn format_path(prefix: &str, name: &str) -> String {
12122    if prefix.is_empty() {
12123        name.to_owned()
12124    } else {
12125        let mut path = String::with_capacity(prefix.len() + 1 + name.len());
12126        path.push_str(prefix);
12127        path.push('/');
12128        path.push_str(name);
12129        path
12130    }
12131}
12132
12133/// Format a numeric mode as a zero-padded octal string.
12134pub fn format_mode(mode: u32) -> String {
12135    format!("{mode:06o}")
12136}
12137
12138/// Read the HEAD commit OID from a submodule checkout directory.
12139///
12140/// Returns `None` if the path is missing, not a submodule checkout, or has no resolvable HEAD.
12141#[must_use]
12142pub fn read_submodule_head_for_checkout(sub_dir: &Path) -> Option<ObjectId> {
12143    read_submodule_head(sub_dir)
12144}
12145
12146/// First line of a commit's message for `git diff --submodule=log` output.
12147///
12148/// Honors `encoding` in the commit object (Latin-1 vs UTF-8) using the same
12149/// rules as Git's submodule summary.
12150#[must_use]
12151pub fn submodule_commit_subject_line(c: &CommitData) -> String {
12152    let enc = c.encoding.as_deref().unwrap_or("UTF-8");
12153    let is_latin1 = enc.eq_ignore_ascii_case("ISO8859-1")
12154        || enc.eq_ignore_ascii_case("ISO-8859-1")
12155        || enc.eq_ignore_ascii_case("LATIN1")
12156        || enc.eq_ignore_ascii_case("ISO-8859-15");
12157    if let Some(raw) = c.raw_message.as_deref() {
12158        let line = raw.split(|b| *b == b'\n').next().unwrap_or(raw);
12159        if is_latin1 {
12160            return line
12161                .iter()
12162                .map(|&b| b as char)
12163                .collect::<String>()
12164                .trim()
12165                .to_owned();
12166        }
12167        return String::from_utf8_lossy(line).trim().to_string();
12168    }
12169    c.message.lines().next().unwrap_or("").trim().to_owned()
12170}
12171
12172/// True when a gitlink directory has no initialized submodule (no embedded `.git`), so the
12173/// recorded index OID is treated as the worktree reference (Git `ce_compare_gitlink` when
12174/// `resolve_gitlink_ref` fails).
12175fn uninitialized_gitlink_worktree_matches_index(sub_dir: &Path) -> bool {
12176    submodule_embedded_git_dir(sub_dir).is_none()
12177}
12178
12179fn read_submodule_head(sub_dir: &Path) -> Option<ObjectId> {
12180    read_submodule_head_oid(sub_dir)
12181}
12182
12183/// Resolve the embedded git directory for a submodule work tree (`sub_dir/.git`).
12184#[must_use]
12185pub fn submodule_embedded_git_dir(sub_dir: &Path) -> Option<PathBuf> {
12186    let gitfile = sub_dir.join(".git");
12187    if gitfile.is_file() {
12188        let content = fs::read_to_string(&gitfile).ok()?;
12189        let gitdir = content
12190            .lines()
12191            .find_map(|l| l.strip_prefix("gitdir: "))?
12192            .trim();
12193        Some(if Path::new(gitdir).is_absolute() {
12194            PathBuf::from(gitdir)
12195        } else {
12196            sub_dir.join(gitdir)
12197        })
12198    } else if gitfile.is_dir() {
12199        Some(gitfile)
12200    } else {
12201        None
12202    }
12203}
12204
12205/// Whether `abs` is a nested Git checkout (embedded `.git`) not already indexed as a gitlink.
12206///
12207/// Matches Git's `is_nonbare_repository_dir` / `is_git_directory`: the embedded git dir must look
12208/// like a real repository (valid `HEAD` plus `objects/`, or `objects/` and `refs/`), not a bare
12209/// `.git` directory placeholder.
12210#[must_use]
12211pub(crate) fn is_unregistered_embedded_git_repo(index: &Index, rel: &str, abs: &Path) -> bool {
12212    if rel.is_empty() {
12213        return false;
12214    }
12215    if index
12216        .entries
12217        .iter()
12218        .any(|e| e.path.as_slice() == rel.as_bytes() && e.mode == crate::index::MODE_GITLINK)
12219    {
12220        return false;
12221    }
12222    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
12223        return false;
12224    };
12225    crate::submodule_gitdir::is_valid_git_dir(&git_dir)
12226}
12227
12228/// Walk upward from `sub_dir` to find the nearest containing Git work tree.
12229fn find_superproject_git(sub_dir: &Path) -> Option<(PathBuf, PathBuf)> {
12230    let mut cur = sub_dir.parent()?;
12231    loop {
12232        let git_path = cur.join(".git");
12233        if git_path.exists() {
12234            let gd = if git_path.is_file() {
12235                let content = fs::read_to_string(&git_path).ok()?;
12236                let line = content
12237                    .lines()
12238                    .find_map(|l| l.strip_prefix("gitdir: "))?
12239                    .trim();
12240                if Path::new(line).is_absolute() {
12241                    PathBuf::from(line)
12242                } else {
12243                    cur.join(line)
12244                }
12245            } else {
12246                git_path
12247            };
12248            return Some((cur.to_path_buf(), gd));
12249        }
12250        cur = cur.parent()?;
12251    }
12252}
12253
12254/// Read the HEAD commit OID from a submodule working tree directory.
12255///
12256/// Handles both embedded `.git` directories and `gitdir:` gitfiles pointing at
12257/// `.git/modules/...` (or other locations). Returns `None` if the path is not
12258/// a checkout or has no resolvable HEAD.
12259pub fn read_submodule_head_oid(sub_dir: &Path) -> Option<ObjectId> {
12260    // Submodule `.git` may be a gitfile pointing at `.git/modules/<name>` in another superproject
12261    // after `cp -R`. Prefer the current superproject's module dir when present.
12262    let mut git_dir = submodule_embedded_git_dir(sub_dir)?;
12263    if let Some((super_wt, super_git_dir)) = find_superproject_git(sub_dir) {
12264        let rel = sub_dir.strip_prefix(&super_wt).ok()?;
12265        let rel_str = rel.to_string_lossy().replace('\\', "/");
12266        let local_mod = super_git_dir
12267            .join("modules")
12268            .join(rel_str.trim_start_matches('/'));
12269        if local_mod.join("HEAD").exists() {
12270            let sg = super_git_dir
12271                .canonicalize_simplified()
12272                .unwrap_or(super_git_dir);
12273            let cur = git_dir
12274                .canonicalize_simplified()
12275                .unwrap_or_else(|_| git_dir.clone());
12276            if !cur.starts_with(&sg) {
12277                git_dir = local_mod;
12278            }
12279        }
12280    }
12281    let head_content = fs::read_to_string(git_dir.join("HEAD")).ok()?;
12282    let head_trimmed = head_content.trim();
12283    if head_trimmed.starts_with("ref: ") {
12284        // Use the full ref resolver so packed-refs and worktrees match Git. If `HEAD` is a stale
12285        // symref (e.g. still `refs/heads/master` while only `main` exists), fall back like
12286        // `resolve_gitlink_ref` / `git add` on embedded repos (`t6437-submodule-merge`).
12287        match crate::refs::resolve_ref(&git_dir, "HEAD") {
12288            Ok(oid) => Some(oid),
12289            Err(_) => {
12290                let mut found = None;
12291                for branch in ["main", "master"] {
12292                    let p = git_dir.join("refs/heads").join(branch);
12293                    if let Ok(s) = fs::read_to_string(&p) {
12294                        if let Ok(o) = ObjectId::from_hex(s.trim()) {
12295                            found = Some(o);
12296                            break;
12297                        }
12298                    }
12299                }
12300                found
12301            }
12302        }
12303    } else {
12304        ObjectId::from_hex(head_trimmed).ok()
12305    }
12306}
12307
12308/// True when a populated submodule checkout is *broken*: its `HEAD` resolves to a commit OID, but
12309/// that commit object cannot be read from the submodule's own object database.
12310///
12311/// This mirrors Git's `is_submodule_modified`, which shells out to `git status --porcelain=2`
12312/// inside the submodule; when the submodule's object store is corrupt (e.g. `rm -r .git/objects`),
12313/// that inner status fails and Git aborts the surrounding `status`/`diff`/`fetch`. We detect the
12314/// same condition in-process so the superproject operation can return a fatal error rather than
12315/// silently treating the submodule as clean (t5526 "fetching submodule into a broken repository").
12316///
12317/// Returns `false` when the submodule is not checked out, has no embedded git dir, or has an
12318/// unresolvable HEAD (those are handled separately by the unpopulated/placeholder logic).
12319#[must_use]
12320pub fn submodule_head_object_broken(sub_dir: &Path) -> bool {
12321    let Some(sub_git_dir) = submodule_embedded_git_dir(sub_dir) else {
12322        return false;
12323    };
12324    let Some(head_oid) = read_submodule_head_oid(sub_dir) else {
12325        return false;
12326    };
12327    let odb = Odb::new(&sub_git_dir.join("objects"));
12328    match odb.read(&head_oid) {
12329        // HEAD object present but not a commit would be a different kind of corruption; only the
12330        // missing-object case is the broken-repo scenario Git guards against here.
12331        Ok(obj) => parse_commit(&obj.data).is_err(),
12332        Err(_) => true,
12333    }
12334}
12335
12336/// Submodule dirty bits aligned with Git's `DIRTY_SUBMODULE_*` / porcelain v2 `S???` token.
12337#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12338pub struct SubmodulePorcelainFlags {
12339    /// Submodule checkout HEAD differs from the gitlink OID recorded in the parent index.
12340    pub new_commits: bool,
12341    /// The submodule has its own staged or unstaged changes (`DIRTY_SUBMODULE_MODIFIED`).
12342    pub modified: bool,
12343    /// The submodule work tree contains paths not in its index (`DIRTY_SUBMODULE_UNTRACKED`).
12344    pub untracked: bool,
12345}
12346
12347/// Inspect a checked-out submodule at `rel_path` (relative to `super_worktree`) and return
12348/// flags used for `git status --porcelain=v2` submodule tokens.
12349///
12350/// `recorded_oid` is the gitlink OID stored in the **parent** index (stage 0). When the
12351/// submodule is not checked out or cannot be opened, returns [`Default::default()`].
12352pub fn submodule_porcelain_flags(
12353    super_worktree: &Path,
12354    rel_path: &str,
12355    recorded_oid: ObjectId,
12356) -> SubmodulePorcelainFlags {
12357    let sub_dir = super_worktree.join(rel_path);
12358    let Some(sub_git_dir) = submodule_embedded_git_dir(&sub_dir) else {
12359        return SubmodulePorcelainFlags::default();
12360    };
12361    let Some(sub_head) = read_submodule_head_oid(&sub_dir) else {
12362        return SubmodulePorcelainFlags::default();
12363    };
12364
12365    let new_commits = sub_head != recorded_oid;
12366
12367    let index_path = sub_git_dir.join("index");
12368    let mut sub_index = match crate::index::Index::load(&index_path) {
12369        Ok(ix) => ix,
12370        Err(_) => {
12371            return SubmodulePorcelainFlags {
12372                new_commits,
12373                ..Default::default()
12374            }
12375        }
12376    };
12377
12378    let tracked: std::collections::BTreeSet<String> = sub_index
12379        .entries
12380        .iter()
12381        .filter(|e| e.stage() == 0)
12382        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
12383        .collect();
12384    let untracked = submodule_dir_has_untracked_inner(&sub_dir, &sub_dir, &tracked, &sub_index);
12385
12386    let objects_dir = sub_git_dir.join("objects");
12387    let odb = Odb::new(&objects_dir);
12388
12389    let sub_head_tree = (|| -> Option<ObjectId> {
12390        let h = fs::read_to_string(sub_git_dir.join("HEAD")).ok()?;
12391        let h_str = h.trim();
12392        let commit_oid = if let Some(r) = h_str.strip_prefix("ref: ") {
12393            let oid_hex = fs::read_to_string(sub_git_dir.join(r)).ok()?;
12394            ObjectId::from_hex(oid_hex.trim()).ok()?
12395        } else {
12396            ObjectId::from_hex(h_str).ok()?
12397        };
12398        let obj = odb.read(&commit_oid).ok()?;
12399        let commit = parse_commit(&obj.data).ok()?;
12400        Some(commit.tree)
12401    })();
12402
12403    let staged_dirty = sub_head_tree
12404        .as_ref()
12405        .map(|t| diff_index_to_tree(&odb, &sub_index, Some(t), false).map(|v| !v.is_empty()))
12406        .unwrap_or(Ok(false));
12407    let staged_dirty = staged_dirty.unwrap_or(false);
12408
12409    let unstaged_dirty = diff_index_to_worktree(&odb, &mut sub_index, &sub_dir, false, true)
12410        .map(|v| !v.is_empty())
12411        .unwrap_or(false);
12412
12413    let mut modified = staged_dirty || unstaged_dirty;
12414
12415    // Nested submodule has its own index: OR `modified` from immediate gitlink children so a
12416    // dirty nested checkout (e.g. staged `file` under `sub1/sub2`) marks the parent gitlink as
12417    // modified in the superproject (t7506). Do **not** OR `untracked` — untracked-only inside a
12418    // nested submodule must stay `S..U` on the parent, not `S.U` / `S.M.`.
12419    for e in &sub_index.entries {
12420        if e.stage() != 0 || e.mode != 0o160000 {
12421            continue;
12422        }
12423        let child = String::from_utf8_lossy(&e.path).into_owned();
12424        let full_rel = if rel_path.is_empty() {
12425            child
12426        } else {
12427            format!("{rel_path}/{child}")
12428        };
12429        let nested = submodule_porcelain_flags(super_worktree, &full_rel, e.oid);
12430        modified |= nested.modified;
12431    }
12432
12433    SubmodulePorcelainFlags {
12434        new_commits,
12435        modified,
12436        untracked,
12437    }
12438}
12439
12440fn submodule_dir_has_untracked_inner(
12441    dir: &Path,
12442    root: &Path,
12443    tracked: &std::collections::BTreeSet<String>,
12444    owning_index: &Index,
12445) -> bool {
12446    let entries = match fs::read_dir(dir) {
12447        Ok(e) => e,
12448        Err(_) => return false,
12449    };
12450    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
12451    sorted.sort_by_key(|e| e.file_name());
12452
12453    for entry in sorted {
12454        let name = entry.file_name().to_string_lossy().to_string();
12455        if name == ".git" {
12456            continue;
12457        }
12458        let path = entry.path();
12459        let rel = path
12460            .strip_prefix(root)
12461            .map(|p| p.to_string_lossy().to_string())
12462            .unwrap_or_else(|_| name.clone());
12463
12464        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
12465        if is_dir {
12466            let is_gitlink = owning_index
12467                .get(rel.as_bytes(), 0)
12468                .is_some_and(|e| e.mode == 0o160000);
12469            if is_gitlink {
12470                let Some(nested_git) = submodule_embedded_git_dir(&path) else {
12471                    continue;
12472                };
12473                let nested_index_path = nested_git.join("index");
12474                let Ok(nested_ix) = crate::index::Index::load(&nested_index_path) else {
12475                    continue;
12476                };
12477                let nested_tracked: std::collections::BTreeSet<String> = nested_ix
12478                    .entries
12479                    .iter()
12480                    .filter(|e| e.stage() == 0)
12481                    .map(|e| String::from_utf8_lossy(&e.path).into_owned())
12482                    .collect();
12483                if submodule_dir_has_untracked_inner(&path, &path, &nested_tracked, &nested_ix) {
12484                    return true;
12485                }
12486            } else if submodule_dir_has_untracked_inner(&path, root, tracked, owning_index) {
12487                return true;
12488            }
12489        } else if !tracked.contains(&rel) {
12490            return true;
12491        }
12492    }
12493    false
12494}
12495
12496#[cfg(test)]
12497mod line_compare_key_tests {
12498    use super::*;
12499    use crate::diff::unified_emit::line_diff_token;
12500    use crate::diff::WhitespaceMode;
12501
12502    #[test]
12503    fn borrowed_keys_match_owned_line_tokens() {
12504        let texts = [
12505            "",
12506            "\n",
12507            "a",
12508            "a\n",
12509            "a\nb",
12510            "a\r\nb\r\n",
12511            "a\r\nb\r",
12512            "\n\n\nlast",
12513            "x\n\r\n\ry\n",
12514        ];
12515        for text in texts {
12516            let side = LineSide::from_text(text);
12517            let mut normalized = Vec::new();
12518            let keys = line_compare_keys(text, &side, WhitespaceMode::default(), &mut normalized);
12519            let expected: Vec<String> = side.lines.iter().map(line_diff_token).collect();
12520            assert_eq!(keys, expected, "text {text:?}");
12521            let source = text.as_bytes().as_ptr_range();
12522            assert!(
12523                keys.iter()
12524                    .all(|key| source.contains(&key.as_ptr()) || key.is_empty()),
12525                "text {text:?}"
12526            );
12527        }
12528    }
12529
12530    #[test]
12531    fn whitespace_modes_compare_normalized_bodies() {
12532        let text = "a  b\nab \n";
12533        let side = LineSide::from_text(text);
12534        let ws = WhitespaceMode {
12535            ignore_all_space: true,
12536            ..WhitespaceMode::default()
12537        };
12538        let mut normalized = Vec::new();
12539        let keys = line_compare_keys(text, &side, ws, &mut normalized);
12540        assert_eq!(keys, vec!["ab", "ab"]);
12541    }
12542
12543    #[test]
12544    fn xdiff_marks_only_edited_lines_in_large_file() {
12545        let old: String = (0..5_000).map(|i| format!("line{i}\n")).collect();
12546        let new: String = (0..5_000)
12547            .map(|i| {
12548                if i % 100 == 0 {
12549                    format!("LINE{i}\n")
12550                } else {
12551                    format!("line{i}\n")
12552                }
12553            })
12554            .collect();
12555        let body =
12556            git_xdiff_unified_body_raw(&old, &new, 3, 0, true, false, WhitespaceMode::default());
12557        assert_eq!(body.matches("\n@@ ").count() + 1, 50);
12558        assert_eq!(body.lines().filter(|l| l.starts_with('-')).count(), 50);
12559        assert_eq!(body.lines().filter(|l| l.starts_with('+')).count(), 50);
12560        assert!(body.starts_with("@@ -1,4 +1,4 @@\n-line0\n+LINE0\n line1\n"));
12561    }
12562}
12563
12564#[cfg(test)]
12565mod entry_order_tests {
12566    use super::*;
12567
12568    fn entry(status: DiffStatus, path: &str, score: Option<u32>) -> DiffEntry {
12569        let mode = |present: bool| {
12570            if present {
12571                FileMode::REGULAR
12572            } else {
12573                FileMode::ABSENT
12574            }
12575        };
12576        DiffEntry {
12577            status,
12578            old_path: (status != DiffStatus::Added).then(|| path.to_owned()),
12579            new_path: (status != DiffStatus::Deleted).then(|| path.to_owned()),
12580            old_mode: mode(status != DiffStatus::Added),
12581            new_mode: mode(status != DiffStatus::Deleted),
12582            old_oid: ObjectId::zero(),
12583            new_oid: ObjectId::zero(),
12584            score,
12585        }
12586    }
12587
12588    #[test]
12589    fn sort_entries_by_path_orders_by_path_and_keeps_equal_paths_in_place() {
12590        let mut entries = vec![
12591            entry(DiffStatus::Modified, "b", None),
12592            entry(DiffStatus::Deleted, "a/x", Some(1)),
12593            entry(DiffStatus::Added, "a/x", Some(2)),
12594            entry(DiffStatus::Added, "a", None),
12595        ];
12596        sort_entries_by_path(&mut entries);
12597        let order: Vec<_> = entries.iter().map(|e| (e.path(), e.score)).collect();
12598        assert_eq!(
12599            order,
12600            [("a", None), ("a/x", Some(1)), ("a/x", Some(2)), ("b", None)]
12601        );
12602        let sorted = entries.clone();
12603        sort_entries_by_path(&mut entries);
12604        assert_eq!(entries, sorted);
12605        sort_entries_by_path(&mut []);
12606    }
12607
12608    #[test]
12609    fn sort_entries_by_path_compares_non_utf8_paths_by_raw_bytes() {
12610        let raw = crate::repo_relpath::format_path_stored("", b"raw-\xff");
12611        assert!(raw.starts_with("grit+pathhex:"));
12612        let mut entries = vec![
12613            entry(DiffStatus::Modified, &raw, None),
12614            entry(DiffStatus::Modified, "node/leaf", None),
12615            entry(DiffStatus::Modified, "zeta", None),
12616        ];
12617        sort_entries_by_path(&mut entries);
12618        let paths: Vec<_> = entries.iter().map(DiffEntry::path).collect();
12619        assert_eq!(paths, ["node/leaf", raw.as_str(), "zeta"]);
12620    }
12621
12622    #[test]
12623    fn rename_pass_returns_unpairable_lists_in_path_order() {
12624        let config = ConfigSet::new();
12625        let policy = ThreadPolicy::default();
12626        let odb = Odb::new(Path::new("/nonexistent-objects"));
12627        let run = |entries: Vec<DiffEntry>| {
12628            detect_renames_with_policy(&odb, None, entries, 50, &config, &policy, None)
12629                .expect("no blob is read without both adds and deletes")
12630        };
12631        let sorted = vec![
12632            entry(DiffStatus::Deleted, "a", None),
12633            entry(DiffStatus::Modified, "b", None),
12634            entry(DiffStatus::Deleted, "c", None),
12635        ];
12636        assert_eq!(run(sorted.clone()), sorted);
12637        let unsorted = vec![
12638            entry(DiffStatus::Added, "z", None),
12639            entry(DiffStatus::Modified, "m", None),
12640            entry(DiffStatus::Added, "a", None),
12641        ];
12642        let paths: Vec<_> = run(unsorted).iter().map(|e| e.path().to_owned()).collect();
12643        assert_eq!(paths, ["a", "m", "z"]);
12644        // A duplicate path takes the regrouping path: modifications before additions.
12645        let duplicate = vec![
12646            entry(DiffStatus::Added, "p", None),
12647            entry(DiffStatus::Modified, "p", None),
12648        ];
12649        let statuses: Vec<_> = run(duplicate).iter().map(|e| e.status).collect();
12650        assert_eq!(statuses, [DiffStatus::Modified, DiffStatus::Added]);
12651    }
12652}
12653
12654#[cfg(test)]
12655mod diff_index_to_tree_cache_tree_tests {
12656    use std::rc::Rc;
12657    use std::sync::atomic::{AtomicUsize, Ordering};
12658
12659    use super::*;
12660    use crate::index::{Index, IndexEntry, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
12661    use crate::objects::ObjectKind;
12662    use crate::write_tree::{build_cache_tree_from_index, write_tree_from_index};
12663    use tempfile::TempDir;
12664
12665    struct TreeReadCounter {
12666        counter: Rc<AtomicUsize>,
12667    }
12668
12669    impl TreeReadCounter {
12670        fn start() -> Self {
12671            let counter = Rc::new(AtomicUsize::new(0));
12672            set_diff_tree_read_hook_for_tests(Some(counter.clone()));
12673            Self { counter }
12674        }
12675
12676        fn get(&self) -> usize {
12677            self.counter.load(Ordering::Relaxed)
12678        }
12679    }
12680
12681    impl Drop for TreeReadCounter {
12682        fn drop(&mut self) {
12683            set_diff_tree_read_hook_for_tests(None);
12684        }
12685    }
12686
12687    fn diff_index_to_tree_flatten_reference(
12688        odb: &Odb,
12689        index: &Index,
12690        head_tree: &ObjectId,
12691        ignore_submodules: bool,
12692    ) -> Result<Vec<DiffEntry>> {
12693        let mut flat_index = index.clone();
12694        flat_index.clear_cache_tree();
12695        diff_index_to_tree(odb, &flat_index, Some(head_tree), ignore_submodules)
12696    }
12697
12698    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
12699        IndexEntry {
12700            ctime_sec: 0,
12701            ctime_nsec: 0,
12702            mtime_sec: 0,
12703            mtime_nsec: 0,
12704            dev: 0,
12705            ino: 0,
12706            mode,
12707            uid: 0,
12708            gid: 0,
12709            size: 0,
12710            oid,
12711            flags: path.len().min(0xfff) as u16,
12712            flags_extended: None,
12713            path: path.as_bytes().to_vec(),
12714            base_index_pos: 0,
12715        }
12716    }
12717
12718    fn repo_with_tree(depth: usize, width: usize) -> (TempDir, Odb, ObjectId, Index) {
12719        let temp = TempDir::new().expect("tempdir");
12720        let odb = Odb::new(temp.path());
12721        let mut index = Index::new();
12722        for d in 0..depth {
12723            for w in 0..width {
12724                let path = format!("unchanged/d{d}/f{w}.txt");
12725                let oid = odb
12726                    .write(ObjectKind::Blob, format!("body-{d}-{w}").as_bytes())
12727                    .expect("blob");
12728                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
12729            }
12730        }
12731        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
12732        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
12733        index.set_cache_tree(cache);
12734        index.cache_tree_root = Some(head_tree);
12735        (temp, odb, head_tree, index)
12736    }
12737
12738    #[test]
12739    fn diff_index_to_tree_cache_tree_fast_path_matches_slow() {
12740        let (_temp, odb, head_tree, mut index) = repo_with_tree(4, 5);
12741        assert!(index.cache_tree.as_ref().is_some_and(|c| c.is_valid()));
12742
12743        let mut state: u64 = 0xDEAD_BEEF;
12744        for _ in 0..24 {
12745            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
12746            let d = (state % 4) as usize;
12747            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
12748            let w = (state % 5) as usize;
12749            let path = format!("unchanged/d{d}/f{w}.txt");
12750            index.invalidate_cache_tree_for_path(path.as_bytes());
12751            assert!(
12752                index.cache_tree_root.is_none(),
12753                "partial invalidation clears cache_tree_root like Index::add"
12754            );
12755            assert!(
12756                index.cache_tree.as_ref().is_some_and(|r| !r.is_valid()),
12757                "root cache node must be invalid after partial invalidation"
12758            );
12759            if let Some(e) = index.get_mut(path.as_bytes(), 0) {
12760                let new_oid = odb
12761                    .write(ObjectKind::Blob, format!("new-{d}-{w}").as_bytes())
12762                    .expect("blob");
12763                e.oid = new_oid;
12764            }
12765            let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12766            let flat = diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false)
12767                .expect("flat");
12768            assert_eq!(fast, flat, "mismatch after mutating {path}");
12769        }
12770    }
12771
12772    #[test]
12773    fn diff_index_to_tree_skips_unchanged_subtrees() {
12774        let (_temp, odb, head_tree, index) = repo_with_tree(5, 6);
12775        let reads = TreeReadCounter::start();
12776        diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("cached diff");
12777        let cached_reads = reads.get();
12778
12779        let mut no_cache = index.clone();
12780        no_cache.clear_cache_tree();
12781        let reads = TreeReadCounter::start();
12782        diff_index_to_tree(&odb, &no_cache, Some(&head_tree), false).expect("flat diff");
12783        let flat_reads = reads.get();
12784
12785        assert!(
12786            cached_reads < flat_reads,
12787            "expected fewer tree reads with cache-tree (cached={cached_reads}, flat={flat_reads})"
12788        );
12789        assert_eq!(
12790            cached_reads, 1,
12791            "unchanged repo should only read the root tree"
12792        );
12793    }
12794
12795    #[test]
12796    fn diff_index_to_tree_partial_invalidation_skips_sibling_subtree() {
12797        let (_temp, odb, head_tree, mut index) = repo_with_tree(5, 6);
12798        let path = "unchanged/d1/f2.txt";
12799        index.invalidate_cache_tree_for_path(path.as_bytes());
12800        let new_oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
12801        index.get_mut(path.as_bytes(), 0).expect("entry").oid = new_oid;
12802
12803        let flat =
12804            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
12805        let reads = TreeReadCounter::start();
12806        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12807        let cached_reads = reads.get();
12808        assert_eq!(fast, flat);
12809
12810        let reads = TreeReadCounter::start();
12811        diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
12812        let flat_reads = reads.get();
12813
12814        assert!(
12815            cached_reads < flat_reads,
12816            "partial cache-tree should skip sibling dirs (cached={cached_reads}, flat={flat_reads})"
12817        );
12818    }
12819
12820    fn cache_leaf(name: &str) -> CacheTreeNode {
12821        CacheTreeNode::valid(name.as_bytes().to_vec(), 1, ObjectId::zero(), Vec::new())
12822    }
12823
12824    #[test]
12825    fn cache_children_lookup_matches_linear_scan_on_wide_unsorted_nodes() {
12826        let mut children: Vec<CacheTreeNode> = (0..CACHE_CHILD_LINEAR_SCAN_MAX * 3)
12827            .rev()
12828            .map(|i| cache_leaf(&format!("d{i:03}")))
12829            .collect();
12830        let mut first_dup = cache_leaf("d007");
12831        first_dup.entry_count = 7;
12832        children.insert(3, first_dup);
12833        let root = CacheTreeNode::valid(Vec::new(), 0, ObjectId::zero(), children);
12834
12835        let lookup = CacheChildren::new(Some(&root));
12836        assert!(
12837            !lookup.sorted.is_empty(),
12838            "wide nodes must use the sorted lookup"
12839        );
12840        for name in ["d000", "d007", "d020", "d047", "zzz", "d", ""] {
12841            let linear = root.children.iter().find(|c| c.name == name.as_bytes());
12842            let fast = lookup.get(name.as_bytes());
12843            assert_eq!(
12844                fast.map(|c| (&c.name, c.entry_count)),
12845                linear.map(|c| (&c.name, c.entry_count)),
12846                "lookup of {name:?}"
12847            );
12848        }
12849        assert_eq!(lookup.get(b"d007").map(|c| c.entry_count), Some(7));
12850
12851        let narrow = CacheTreeNode::valid(
12852            Vec::new(),
12853            0,
12854            ObjectId::zero(),
12855            vec![cache_leaf("b"), cache_leaf("a")],
12856        );
12857        let lookup = CacheChildren::new(Some(&narrow));
12858        assert!(lookup.sorted.is_empty());
12859        assert_eq!(lookup.get(b"a").map(|c| c.name.as_slice()), Some(&b"a"[..]));
12860        assert!(lookup.get(b"c").is_none());
12861        assert!(CacheChildren::new(None).get(b"a").is_none());
12862    }
12863
12864    #[test]
12865    fn index_relative_in_dir_requires_a_component_boundary() {
12866        assert_eq!(index_relative_in_dir(b"a/b", b""), Some(&b"a/b"[..]));
12867        assert_eq!(index_relative_in_dir(b"a/b", b"a"), Some(&b"b"[..]));
12868        assert_eq!(index_relative_in_dir(b"a", b"a"), Some(&b""[..]));
12869        assert_eq!(index_relative_in_dir(b"ab/c", b"a"), None);
12870        assert_eq!(index_relative_in_dir(b"a.c", b"a"), None);
12871        assert_eq!(index_relative_in_dir(b"b", b"a"), None);
12872        assert_eq!(join_index_path(b"", b"x"), b"x".to_vec());
12873        assert_eq!(join_index_path(b"a/b", b"x"), b"a/b/x".to_vec());
12874    }
12875
12876    /// Wide root (more children than the linear-scan cutoff), cache-tree children stored in
12877    /// reverse order, a few changed paths, plus additions and deletions at both levels.
12878    #[test]
12879    fn diff_index_to_tree_wide_unsorted_cache_tree_matches_flatten() {
12880        let temp = TempDir::new().expect("tempdir");
12881        let odb = Odb::new(temp.path());
12882        let mut index = Index::new();
12883        let width = CACHE_CHILD_LINEAR_SCAN_MAX * 4;
12884        for d in 0..width {
12885            for f in 0..2 {
12886                let path = format!("dir{d:03}/sub/f{f}.txt");
12887                let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
12888                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
12889            }
12890        }
12891        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
12892        let mut cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
12893        cache.children.reverse();
12894        index.set_cache_tree(cache);
12895        index.cache_tree_root = Some(head_tree);
12896
12897        let changed = [
12898            "dir003/sub/f1.txt",
12899            "dir031/sub/f0.txt",
12900            "dir063/sub/f1.txt",
12901        ];
12902        for path in changed {
12903            index.invalidate_cache_tree_for_path(path.as_bytes());
12904            let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
12905            index.get_mut(path.as_bytes(), 0).expect("entry").oid = oid;
12906        }
12907        for path in ["dir010/sub/new.txt", "dir999/new.txt", "top.txt"] {
12908            index.invalidate_cache_tree_for_path(path.as_bytes());
12909            let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
12910            index.add_or_replace(index_entry(path, MODE_REGULAR, oid));
12911        }
12912        for path in [
12913            "dir020/sub/f0.txt",
12914            "dir020/sub/f1.txt",
12915            "dir021/sub/f0.txt",
12916        ] {
12917            index.invalidate_cache_tree_for_path(path.as_bytes());
12918            index.remove(path.as_bytes());
12919        }
12920
12921        let flat =
12922            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
12923        let reads = TreeReadCounter::start();
12924        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12925        let cached_reads = reads.get();
12926        assert_eq!(fast, flat);
12927        assert_eq!(fast.len(), changed.len() + 3 + 3);
12928        assert!(
12929            cached_reads <= 1 + 2 * 6,
12930            "unchanged siblings must be skipped through the cache-tree (reads={cached_reads})"
12931        );
12932    }
12933
12934    #[test]
12935    fn diff_index_to_tree_cache_tree_handles_non_utf8_directory_names() {
12936        let temp = TempDir::new().expect("tempdir");
12937        let odb = Odb::new(temp.path());
12938        let mut index = Index::new();
12939        let raw_dirs: [&[u8]; 3] = [b"caf\xe9", b"caf\xef\xbf\xbd", b"plain"];
12940        for dir in raw_dirs {
12941            for f in [&b"a.txt"[..], b"b.txt"] {
12942                let path = [dir, b"/", f].concat();
12943                let oid = odb.write(ObjectKind::Blob, &path).expect("blob");
12944                let mut entry = index_entry("x", MODE_REGULAR, oid);
12945                entry.flags = path.len().min(0xfff) as u16;
12946                entry.path = path;
12947                index.add_or_replace(entry);
12948            }
12949        }
12950        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
12951        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
12952        index.set_cache_tree(cache);
12953        index.cache_tree_root = Some(head_tree);
12954
12955        let changed: &[u8] = b"caf\xe9/b.txt";
12956        index.invalidate_cache_tree_for_path(changed);
12957        let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
12958        index.get_mut(changed, 0).expect("entry").oid = oid;
12959
12960        // Directory prefixes are compared as raw bytes, so `caf\xe9` and a directory literally
12961        // named `caf\u{fffd}` stay distinct even though they print the same.
12962        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12963        assert_eq!(fast.len(), 1, "{fast:?}");
12964        assert_eq!(fast[0].status, DiffStatus::Modified);
12965        assert_eq!(
12966            fast[0].old_oid,
12967            odb.write(ObjectKind::Blob, changed).expect("blob")
12968        );
12969        assert_eq!(fast[0].new_oid, oid);
12970    }
12971
12972    #[test]
12973    fn diff_index_to_tree_reports_mode_gitlink_and_intent_to_add() {
12974        let temp = TempDir::new().expect("tempdir");
12975        let odb = Odb::new(temp.path());
12976        let blob = odb.write(ObjectKind::Blob, b"x").expect("blob");
12977        let mut index = Index::new();
12978        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, blob));
12979        let head_tree = write_tree_from_index(&odb, &index, "").expect("tree");
12980        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
12981        index.set_cache_tree(cache);
12982
12983        let mode_entry = index_entry("tracked.txt", MODE_EXECUTABLE, blob);
12984        index.add_or_replace(mode_entry);
12985        index.invalidate_cache_tree_for_path(b"tracked.txt");
12986
12987        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
12988        assert_eq!(diffs.len(), 1);
12989        assert_eq!(diffs[0].status, DiffStatus::Modified);
12990        assert_eq!(diffs[0].old_mode, FileMode::new(MODE_REGULAR));
12991        assert_eq!(diffs[0].new_mode, FileMode::new(MODE_EXECUTABLE));
12992
12993        // Intent-to-add is omitted from staged diff.
12994        let mut ita = index_entry("new.txt", MODE_REGULAR, blob);
12995        ita.set_intent_to_add(true);
12996        index.add_or_replace(ita);
12997        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
12998        assert!(
12999            diffs.iter().all(|d| d.path() != "new.txt"),
13000            "intent-to-add must not appear in staged diff"
13001        );
13002
13003        // Gitlink with ignore_submodules skips diff entry but still matches tree.
13004        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13005            .parse()
13006            .expect("oid");
13007        let mut sub_index = Index::new();
13008        sub_index.add_or_replace(index_entry("sub", MODE_GITLINK, gitlink));
13009        let sub_tree = write_tree_from_index(&odb, &sub_index, "").expect("tree");
13010        let sub_cache = build_cache_tree_from_index(&odb, &sub_index).expect("cache");
13011        sub_index.set_cache_tree(sub_cache);
13012        let diffs =
13013            diff_index_to_tree(&odb, &sub_index, Some(&sub_tree), true).expect("gitlink diff");
13014        assert!(
13015            diffs.is_empty(),
13016            "ignored gitlink should not diff: {diffs:?}"
13017        );
13018    }
13019
13020    #[test]
13021    fn ignore_submodules_skips_gitlink_in_added_directory_cache_walk() {
13022        let temp = TempDir::new().expect("tempdir");
13023        let odb = Odb::new(temp.path());
13024        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13025            .parse()
13026            .expect("oid");
13027        let head_tree = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
13028        let mut index = Index::new();
13029        index.add_or_replace(index_entry("newdir/sub", MODE_GITLINK, gitlink));
13030        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
13031        index.set_cache_tree(cache);
13032        index.invalidate_cache_tree_for_path(b"newdir/sub");
13033
13034        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
13035        let flat =
13036            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
13037        assert_eq!(fast, flat);
13038        assert!(
13039            fast.is_empty(),
13040            "nested gitlinks in added dirs must be ignored: {fast:?}"
13041        );
13042    }
13043
13044    #[test]
13045    fn ignore_submodules_skips_gitlink_in_deleted_directory_cache_walk() {
13046        let temp = TempDir::new().expect("tempdir");
13047        let odb = Odb::new(temp.path());
13048        let blob = odb.write(ObjectKind::Blob, b"keep").expect("blob");
13049        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13050            .parse()
13051            .expect("oid");
13052        let mut index = Index::new();
13053        index.add_or_replace(index_entry("keep.txt", MODE_REGULAR, blob));
13054        index.add_or_replace(index_entry("olddir/sub", MODE_GITLINK, gitlink));
13055        let head_tree = write_tree_from_index(&odb, &index, "").expect("head");
13056        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
13057        index.set_cache_tree(cache);
13058        index
13059            .entries
13060            .retain(|e| !String::from_utf8_lossy(&e.path).starts_with("olddir/"));
13061        index.invalidate_cache_tree_for_path(b"olddir/sub");
13062
13063        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
13064        let flat =
13065            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
13066        assert_eq!(fast, flat);
13067        assert!(
13068            fast.is_empty(),
13069            "nested gitlinks in deleted dirs must be ignored: {fast:?}"
13070        );
13071    }
13072
13073    #[test]
13074    fn diff_trees_skips_identical_subtree_oids() {
13075        let temp = TempDir::new().expect("tempdir");
13076        let odb = Odb::new(temp.path());
13077        let leaf = odb.write(ObjectKind::Blob, b"leaf").expect("blob");
13078        let mut deep = Index::new();
13079        for i in 0..20 {
13080            deep.add_or_replace(index_entry(&format!("deep/n{i}.txt"), MODE_REGULAR, leaf));
13081        }
13082        let tree = write_tree_from_index(&odb, &deep, "").expect("tree");
13083        let reads = TreeReadCounter::start();
13084        let entries = diff_trees(&odb, Some(&tree), Some(&tree), "").expect("diff");
13085        assert!(entries.is_empty());
13086        assert_eq!(
13087            reads.get(),
13088            0,
13089            "identical tree oids should short-circuit without reading tree objects"
13090        );
13091    }
13092
13093    #[test]
13094    fn diff_tree_path_change_count_matches_diff_trees() {
13095        let temp = TempDir::new().expect("tempdir");
13096        let odb = Odb::new(temp.path());
13097        let a = odb.write(ObjectKind::Blob, b"a").expect("blob");
13098        let b = odb.write(ObjectKind::Blob, b"b").expect("blob");
13099        let mut left = Index::new();
13100        left.add_or_replace(index_entry("f.txt", MODE_REGULAR, a));
13101        let mut right = Index::new();
13102        right.add_or_replace(index_entry("f.txt", MODE_REGULAR, b));
13103        right.add_or_replace(index_entry("g.txt", MODE_REGULAR, a));
13104        let old_tree = write_tree_from_index(&odb, &left, "").expect("old");
13105        let new_tree = write_tree_from_index(&odb, &right, "").expect("new");
13106        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
13107        let count =
13108            diff_tree_path_change_count(&odb, Some(&old_tree), Some(&new_tree), "").expect("count");
13109        assert_eq!(count, entries.len());
13110        assert_eq!(count, 2);
13111    }
13112
13113    #[test]
13114    fn diff_trees_matches_whether_trees_were_cached_parsed_or_raw() {
13115        let temp = TempDir::new().expect("tempdir");
13116        let a = Odb::new(temp.path())
13117            .write(ObjectKind::Blob, b"a")
13118            .expect("blob");
13119        let b = Odb::new(temp.path())
13120            .write(ObjectKind::Blob, b"b")
13121            .expect("blob");
13122        let mut left = Index::new();
13123        let mut right = Index::new();
13124        for i in 0..50 {
13125            left.add_or_replace(index_entry(&format!("same{i:02}.txt"), MODE_REGULAR, a));
13126            right.add_or_replace(index_entry(&format!("same{i:02}.txt"), MODE_REGULAR, a));
13127        }
13128        left.add_or_replace(index_entry("dir/changed.txt", MODE_REGULAR, a));
13129        left.add_or_replace(index_entry("dir/kept.txt", MODE_REGULAR, a));
13130        left.add_or_replace(index_entry("gone/x.txt", MODE_REGULAR, a));
13131        left.add_or_replace(index_entry("mode.sh", MODE_REGULAR, a));
13132        left.add_or_replace(index_entry("swap", MODE_REGULAR, a));
13133        right.add_or_replace(index_entry("dir/changed.txt", MODE_REGULAR, b));
13134        right.add_or_replace(index_entry("dir/kept.txt", MODE_REGULAR, a));
13135        right.add_or_replace(index_entry("mode.sh", MODE_EXECUTABLE, a));
13136        right.add_or_replace(index_entry("new/y.txt", MODE_REGULAR, b));
13137        right.add_or_replace(index_entry("swap/inner.txt", MODE_REGULAR, b));
13138        let setup = Odb::new(temp.path());
13139        let old_tree = write_tree_from_index(&setup, &left, "").expect("old");
13140        let new_tree = write_tree_from_index(&setup, &right, "").expect("new");
13141
13142        let raw = diff_trees(&Odb::new(temp.path()), Some(&old_tree), Some(&new_tree), "")
13143            .expect("raw diff");
13144        let changed: Vec<(DiffStatus, &str)> = raw.iter().map(|e| (e.status, e.path())).collect();
13145        assert_eq!(
13146            changed,
13147            vec![
13148                (DiffStatus::Modified, "dir/changed.txt"),
13149                (DiffStatus::Deleted, "gone/x.txt"),
13150                (DiffStatus::Modified, "mode.sh"),
13151                (DiffStatus::Added, "new/y.txt"),
13152                (DiffStatus::Deleted, "swap"),
13153                (DiffStatus::Added, "swap/inner.txt"),
13154            ]
13155        );
13156
13157        let parsed = Odb::new(temp.path());
13158        parsed.cached_tree_entries(&old_tree).expect("parse old");
13159        parsed.cached_tree_entries(&new_tree).expect("parse new");
13160        assert_eq!(
13161            diff_trees(&parsed, Some(&old_tree), Some(&new_tree), "").expect("parsed diff"),
13162            raw
13163        );
13164
13165        let mixed = Odb::new(temp.path());
13166        mixed.cached_tree_entries(&new_tree).expect("parse new");
13167        assert_eq!(
13168            diff_trees(&mixed, Some(&old_tree), Some(&new_tree), "").expect("mixed diff"),
13169            raw
13170        );
13171        let reparsed = mixed
13172            .cached_tree_entries(&old_tree)
13173            .expect("parse cached raw");
13174        assert_eq!(
13175            reparsed.as_slice(),
13176            crate::objects::parse_tree(&mixed.read(&old_tree).expect("read").data)
13177                .expect("parse")
13178                .as_slice(),
13179            "a tree cached raw by a diff parses to the same entries"
13180        );
13181    }
13182
13183    #[test]
13184    fn diff_trees_reports_a_corrupt_tree() {
13185        let temp = TempDir::new().expect("tempdir");
13186        let odb = Odb::new(temp.path());
13187        let good = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
13188        let corrupt = odb
13189            .write(ObjectKind::Tree, b"100644 name-without-oid")
13190            .expect("tree");
13191        let err = diff_trees(&odb, Some(&good), Some(&corrupt), "").expect_err("corrupt");
13192        assert!(matches!(err, Error::CorruptObject(_)), "{err:?}");
13193    }
13194}
13195
13196#[cfg(test)]
13197mod changed_flags_shared_tests {
13198    use super::git_xdiff;
13199    use crate::line_interner::LineInterner;
13200
13201    /// Cutting identical leading and trailing records out and passing their occurrence counts
13202    /// gives the same changed records as diffing the whole files.
13203    #[test]
13204    fn shared_counts_stand_in_for_records_left_out() {
13205        // `}` is frequent only outside the cut, which makes xdiff discard the middle `}` between
13206        // unique lines instead of matching it.
13207        let mut old: Vec<String> = Vec::new();
13208        let mut new: Vec<String> = Vec::new();
13209        for i in 0..60 {
13210            for side in [&mut old, &mut new] {
13211                side.push(format!("fn f{i}() {{\n"));
13212                side.push("}\n".to_owned());
13213            }
13214        }
13215        old.extend(
13216            [
13217                "u1\n", "u2\n", "u3\n", "u4\n", "}\n", "u5\n", "u6\n", "u7\n",
13218            ]
13219            .map(str::to_owned),
13220        );
13221        new.extend(
13222            [
13223                "v1\n", "v2\n", "v3\n", "v4\n", "}\n", "v5\n", "v6\n", "v7\n",
13224            ]
13225            .map(str::to_owned),
13226        );
13227        for i in 0..40 {
13228            for side in [&mut old, &mut new] {
13229                side.push(format!("fn g{i}() {{\n"));
13230                side.push("}\n".to_owned());
13231            }
13232        }
13233        let old: Vec<&str> = old.iter().map(String::as_str).collect();
13234        let new: Vec<&str> = new.iter().map(String::as_str).collect();
13235        let (head, tail) = (120, 80);
13236
13237        let mut interner = LineInterner::with_capacity(old.len() + new.len());
13238        let ids1 = interner.intern_all(&old);
13239        let ids2 = interner.intern_all(&new);
13240        let (full1, full2) = git_xdiff::changed_flags(&ids1, &ids2, false);
13241
13242        let mut cut = LineInterner::default();
13243        let cut1 = cut.intern_all(&old[head..old.len() - tail]);
13244        let cut2 = cut.intern_all(&new[head..new.len() - tail]);
13245        let mut shared = vec![0u32; cut.len()];
13246        for line in old[..head].iter().chain(&old[old.len() - tail..]) {
13247            if let Some(id) = cut.get(line) {
13248                shared[id as usize] += 1;
13249            }
13250        }
13251        let (part1, part2) =
13252            git_xdiff::changed_flags_with_shared(&cut1, &cut2, &shared, head + tail, false);
13253        assert_eq!(part1, full1[head..old.len() - tail]);
13254        assert_eq!(part2, full2[head..new.len() - tail]);
13255        assert!(full1[..head]
13256            .iter()
13257            .chain(&full1[old.len() - tail..])
13258            .all(|c| !c));
13259        // Like `git diff`, the whole middle is one change: the `}` among unique lines is
13260        // discarded because it is frequent in the whole file.
13261        assert!(full1[head..old.len() - tail].iter().all(|&c| c));
13262        let (alone1, _) = git_xdiff::changed_flags(&cut1, &cut2, false);
13263        assert!(
13264            !alone1[4],
13265            "counted alone, the cut keeps the `}}` as a match"
13266        );
13267    }
13268}
13269
13270#[cfg(test)]
13271mod gitlink_tree_worktree_tests {
13272    use std::fs;
13273
13274    use crate::index::{Index, IndexEntry, MODE_GITLINK};
13275    use crate::objects::{ObjectId, ObjectKind};
13276    use crate::repo::init_repository;
13277
13278    use super::diff_tree_to_worktree;
13279
13280    fn encode_tree_gitlink(name: &str, oid: &ObjectId) -> Vec<u8> {
13281        let mut data = format!("160000 {name}\0").into_bytes();
13282        data.extend_from_slice(oid.as_bytes());
13283        data
13284    }
13285
13286    #[test]
13287    fn unpopulated_gitlink_matching_index_produces_no_diff_entry() {
13288        let dir = tempfile::tempdir().expect("tempdir");
13289        let work_tree = dir.path();
13290        fs::create_dir(work_tree.join("sub")).expect("sub placeholder");
13291
13292        let repo = init_repository(
13293            work_tree,
13294            false,
13295            "main",
13296            None,
13297            crate::RefStorageFormat::Files,
13298        )
13299        .expect("init");
13300        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13301            .parse()
13302            .expect("gitlink oid");
13303        let tree_oid = repo
13304            .odb()
13305            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
13306            .expect("write tree");
13307
13308        let path = b"sub".to_vec();
13309        let mut index = Index::new();
13310        index.add_or_replace(IndexEntry {
13311            ctime_sec: 0,
13312            ctime_nsec: 0,
13313            mtime_sec: 0,
13314            mtime_nsec: 0,
13315            dev: 0,
13316            ino: 0,
13317            mode: MODE_GITLINK,
13318            uid: 0,
13319            gid: 0,
13320            size: 0,
13321            oid: gitlink,
13322            flags: (path.len().min(0xfff)) as u16,
13323            flags_extended: None,
13324            path,
13325            base_index_pos: 0,
13326        });
13327
13328        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
13329            .expect("diff_tree_to_worktree");
13330        assert!(
13331            entries.is_empty(),
13332            "clean uninitialized gitlink should not appear in tree→worktree diff: {entries:?}"
13333        );
13334    }
13335
13336    #[test]
13337    fn unpopulated_gitlink_with_stray_file_produces_no_diff_entry() {
13338        let dir = tempfile::tempdir().expect("tempdir");
13339        let work_tree = dir.path();
13340        let sub = work_tree.join("sub");
13341        fs::create_dir(&sub).expect("sub placeholder");
13342        fs::write(sub.join("x.txt"), b"stray\n").expect("stray file");
13343
13344        let repo = init_repository(
13345            work_tree,
13346            false,
13347            "main",
13348            None,
13349            crate::RefStorageFormat::Files,
13350        )
13351        .expect("init");
13352        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
13353            .parse()
13354            .expect("gitlink oid");
13355        let tree_oid = repo
13356            .odb()
13357            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
13358            .expect("write tree");
13359
13360        let path = b"sub".to_vec();
13361        let mut index = Index::new();
13362        index.add_or_replace(IndexEntry {
13363            ctime_sec: 0,
13364            ctime_nsec: 0,
13365            mtime_sec: 0,
13366            mtime_nsec: 0,
13367            dev: 0,
13368            ino: 0,
13369            mode: MODE_GITLINK,
13370            uid: 0,
13371            gid: 0,
13372            size: 0,
13373            oid: gitlink,
13374            flags: (path.len().min(0xfff)) as u16,
13375            flags_extended: None,
13376            path,
13377            base_index_pos: 0,
13378        });
13379
13380        let tree_entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
13381            .expect("diff_tree_to_worktree");
13382        assert!(
13383            tree_entries.is_empty(),
13384            "uninitialized gitlink with stray files should not diff: {tree_entries:?}"
13385        );
13386
13387        let wt_entries =
13388            super::diff_index_to_worktree(&repo.odb(), &mut index.clone(), work_tree, false, true)
13389                .expect("diff_index_to_worktree");
13390        assert!(
13391            wt_entries.is_empty(),
13392            "uninitialized gitlink with stray files should not diff: {wt_entries:?}"
13393        );
13394
13395        let ie = index.entries()[0].clone();
13396        assert!(
13397            !super::worktree_differs_from_index_entry(
13398                repo.odb(),
13399                repo.git_dir(),
13400                work_tree,
13401                &ie,
13402                false,
13403                None,
13404                None,
13405            )
13406            .expect("differs"),
13407            "stray files in uninitialized submodule should not differ from index"
13408        );
13409    }
13410
13411    #[cfg_attr(
13412        not(feature = "full-tests"),
13413        ignore = "starts system git or is heavy; run with --features full-tests"
13414    )]
13415    #[test]
13416    fn untracked_only_in_checked_out_submodule_produces_no_diff_entry() {
13417        use std::process::Command;
13418
13419        let dir = tempfile::tempdir().expect("tempdir");
13420        let work_tree = dir.path();
13421        fs::create_dir(work_tree.join("sub")).expect("sub dir");
13422
13423        let repo = init_repository(
13424            work_tree,
13425            false,
13426            "main",
13427            None,
13428            crate::RefStorageFormat::Files,
13429        )
13430        .expect("init");
13431        let sub = work_tree.join("sub");
13432        for args in [
13433            &["init", "-q"][..],
13434            &["config", "user.name", "T"][..],
13435            &["config", "user.email", "t@e.com"][..],
13436        ] {
13437            let status = Command::new("git")
13438                .current_dir(&sub)
13439                .args(args)
13440                .status()
13441                .expect("git");
13442            assert!(status.success(), "git {args:?} failed");
13443        }
13444        fs::write(sub.join("tracked.txt"), b"v1\n").expect("write");
13445        for args in [&["add", "tracked.txt"][..], &["commit", "-qm", "sub"][..]] {
13446            let status = Command::new("git")
13447                .current_dir(&sub)
13448                .args(args)
13449                .status()
13450                .expect("git");
13451            assert!(status.success());
13452        }
13453        let head_out = Command::new("git")
13454            .current_dir(&sub)
13455            .args(["rev-parse", "HEAD"])
13456            .output()
13457            .expect("rev-parse");
13458        let gitlink = std::str::from_utf8(&head_out.stdout)
13459            .expect("utf8")
13460            .trim()
13461            .parse::<ObjectId>()
13462            .expect("oid");
13463
13464        let tree_oid = repo
13465            .odb()
13466            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
13467            .expect("tree");
13468        let path = b"sub".to_vec();
13469        let mut index = Index::new();
13470        index.add_or_replace(IndexEntry {
13471            ctime_sec: 0,
13472            ctime_nsec: 0,
13473            mtime_sec: 0,
13474            mtime_nsec: 0,
13475            dev: 0,
13476            ino: 0,
13477            mode: MODE_GITLINK,
13478            uid: 0,
13479            gid: 0,
13480            size: 0,
13481            oid: gitlink,
13482            flags: (path.len().min(0xfff)) as u16,
13483            flags_extended: None,
13484            path,
13485            base_index_pos: 0,
13486        });
13487
13488        fs::write(sub.join("untracked.txt"), b"extra\n").expect("untracked");
13489
13490        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
13491            .expect("diff_tree_to_worktree");
13492        assert!(
13493            entries.is_empty(),
13494            "untracked-only submodule dirt must not appear in default tree→worktree diff: {entries:?}"
13495        );
13496    }
13497}
13498
13499#[cfg(test)]
13500mod racy_index_mtime_diff_tests {
13501    use super::*;
13502    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
13503    use crate::objects::{HashAlgo, ObjectKind};
13504    use filetime::FileTime;
13505    use std::fs;
13506    use std::path::PathBuf;
13507
13508    const INDEX_MTIME: (u32, u32) = (1_700_000_000, 123_456_789);
13509
13510    fn pin_mtime(path: &Path, sec: u32, nsec: u32) {
13511        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
13512            .expect("set mtime");
13513    }
13514
13515    fn minimal_sha256_repo(work_tree: &Path) -> (PathBuf, Odb) {
13516        let git_dir = work_tree.join(".git");
13517        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
13518        fs::write(
13519            git_dir.join("config"),
13520            "[extensions]\n\tobjectformat = sha256\n",
13521        )
13522        .expect("config");
13523        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
13524        (git_dir, odb)
13525    }
13526
13527    #[test]
13528    fn detects_same_size_change_when_entry_mtime_matches_index_mtime() {
13529        let td = tempfile::tempdir().expect("tempdir");
13530        let wt = td.path();
13531        let content_a = b"same length!!";
13532        let content_b = b"same length??";
13533        assert_eq!(content_a.len(), content_b.len());
13534
13535        fs::write(wt.join("f.txt"), content_a).expect("write");
13536        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
13537        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13538        let git_dir = wt.join(".git");
13539        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
13540        let odb = Odb::new(&git_dir.join("objects"));
13541        let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
13542        let entry = entry_from_metadata(&meta, b"f.txt", oid_a, MODE_REGULAR);
13543
13544        let mut index = Index::new();
13545        index.source_mtime = Some(INDEX_MTIME);
13546        index.entries.push(entry);
13547
13548        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13549        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
13550
13551        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
13552        assert_eq!(
13553            diff.len(),
13554            1,
13555            "racy entry must be re-hashed, not stat-trusted"
13556        );
13557        assert_eq!(diff[0].path(), "f.txt");
13558    }
13559
13560    #[test]
13561    #[cfg(unix)]
13562    fn non_utf8_symlink_clean_racy_equal_mtime_reports_no_diff() {
13563        use crate::index::MODE_SYMLINK;
13564        use std::os::unix::ffi::OsStrExt as _;
13565        use std::os::unix::fs::symlink;
13566
13567        let td = tempfile::tempdir().expect("tempdir");
13568        let wt = td.path();
13569        let target = std::ffi::OsStr::from_bytes(b"bad-\xff-target");
13570        let link_path = wt.join("link");
13571        symlink(target, &link_path).expect("symlink");
13572
13573        let ft = FileTime::from_unix_time(i64::from(INDEX_MTIME.0), INDEX_MTIME.1);
13574        filetime::set_symlink_file_times(&link_path, ft, ft).expect("set symlink mtime");
13575        let meta = fs::symlink_metadata(&link_path).expect("stat");
13576        let git_dir = wt.join(".git");
13577        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
13578        let odb = Odb::new(&git_dir.join("objects"));
13579        let oid = odb.hash(ObjectKind::Blob, target.as_bytes()).expect("hash");
13580        let entry = entry_from_metadata(&meta, b"link", oid, MODE_SYMLINK);
13581
13582        let mut index = Index::new();
13583        index.source_mtime = Some(INDEX_MTIME);
13584        index.entries.push(entry);
13585
13586        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
13587        assert!(
13588            diff.is_empty(),
13589            "clean non-UTF-8 symlink must not be reported modified: {diff:?}"
13590        );
13591    }
13592
13593    #[test]
13594    fn sha256_clean_racy_equal_mtime_reports_no_diff() {
13595        let td = tempfile::tempdir().expect("tempdir");
13596        let wt = td.path();
13597        let (_git_dir, odb) = minimal_sha256_repo(wt);
13598        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
13599
13600        let bytes = b"sha256 width!!";
13601        fs::write(wt.join("f.txt"), bytes).expect("write");
13602        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
13603        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13604        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
13605        let entry = entry_from_metadata(&meta, b"f.txt", oid, MODE_REGULAR);
13606
13607        let mut index = Index::new();
13608        index.hash_algo = HashAlgo::Sha256;
13609        index.source_mtime = Some(INDEX_MTIME);
13610        index.entries.push(entry);
13611
13612        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
13613        assert!(
13614            diff.is_empty(),
13615            "clean SHA-256 racy entry must not be reported modified: {diff:?}"
13616        );
13617    }
13618}
13619
13620#[cfg(test)]
13621mod smudge_racily_clean_tests {
13622    use super::*;
13623    use crate::config::ConfigSet;
13624    use crate::crlf::{self, FileAttrs};
13625    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
13626    use crate::objects::{HashAlgo, ObjectKind};
13627    use filetime::FileTime;
13628    use std::fs;
13629    use std::path::PathBuf;
13630
13631    const INDEX_MTIME_PIN: (u32, u32) = (1_700_000_000, 123_456_789);
13632
13633    fn mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
13634        #[cfg(unix)]
13635        {
13636            use std::os::unix::fs::MetadataExt;
13637            (meta.mtime() as u32, meta.mtime_nsec() as u32)
13638        }
13639        #[cfg(not(unix))]
13640        {
13641            use std::time::UNIX_EPOCH;
13642            let mtime = meta
13643                .modified()
13644                .ok()
13645                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
13646                .unwrap_or_default();
13647            (mtime.as_secs() as u32, mtime.subsec_nanos())
13648        }
13649    }
13650
13651    /// Pin mtime then read back the value the index would store (NTFS quantizes subseconds).
13652    fn pin_mtime_and_sample(path: &Path, sec: u32, nsec: u32) -> (u32, u32) {
13653        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
13654            .expect("set mtime");
13655        let meta = fs::symlink_metadata(path).expect("stat file");
13656        mtime_from_metadata(&meta)
13657    }
13658
13659    /// Sample the on-disk index mtime used in racy-git tests (matches filesystem quantization).
13660    fn setup_index_mtime(work_tree: &Path) -> (u32, u32) {
13661        let probe = work_tree.join(".grit-index-mtime-probe");
13662        fs::write(&probe, b"").expect("probe file");
13663        pin_mtime_and_sample(&probe, INDEX_MTIME_PIN.0, INDEX_MTIME_PIN.1)
13664    }
13665
13666    fn minimal_repo(work_tree: &Path, config: &str) -> (PathBuf, Odb) {
13667        let git_dir = work_tree.join(".git");
13668        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
13669        fs::write(git_dir.join("config"), config).expect("config");
13670        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
13671        (git_dir, odb)
13672    }
13673
13674    fn staged_entry_for_file(
13675        odb: &Odb,
13676        work_tree: &Path,
13677        rel: &str,
13678        bytes: &[u8],
13679        mtime: (u32, u32),
13680    ) -> IndexEntry {
13681        let path = work_tree.join(rel);
13682        fs::write(&path, bytes).expect("write file");
13683        pin_mtime_and_sample(&path, mtime.0, mtime.1);
13684        let meta = fs::symlink_metadata(&path).expect("stat file");
13685        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
13686        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
13687    }
13688
13689    fn staged_entry_with_oid(
13690        work_tree: &Path,
13691        rel: &str,
13692        worktree_bytes: &[u8],
13693        oid: crate::objects::ObjectId,
13694        mtime: (u32, u32),
13695    ) -> IndexEntry {
13696        let path = work_tree.join(rel);
13697        fs::write(&path, worktree_bytes).expect("write file");
13698        pin_mtime_and_sample(&path, mtime.0, mtime.1);
13699        let meta = fs::symlink_metadata(&path).expect("stat file");
13700        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
13701    }
13702
13703    #[test]
13704    fn index_stat_cache_trustworthy_rejects_unknown_mtime() {
13705        assert!(!super::index_stat_cache_trustworthy(None));
13706        assert!(!super::index_stat_cache_trustworthy(Some((0, 0))));
13707        assert!(super::index_stat_cache_trustworthy(Some((1, 0))));
13708    }
13709
13710    #[test]
13711    fn index_entry_skips_content_hash_after_stat_match_when_trusted_and_not_racy() {
13712        use crate::index::StatMatchPolicy;
13713        let td = tempfile::tempdir().expect("tempdir");
13714        let wt = td.path();
13715        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = true\n");
13716        let rel = "trusted.txt";
13717        let file_mtime = (1_700_000_000, 0);
13718        let mut entry = staged_entry_for_file(&odb, wt, rel, b"payload\n", file_mtime);
13719        let index_mtime = (file_mtime.0 + 1, 0);
13720        let policy = StatMatchPolicy::from_config(None);
13721        assert!(
13722            super::index_entry_skips_worktree_content_hash_after_stat_match(
13723                &entry,
13724                Some(index_mtime),
13725                policy,
13726            )
13727        );
13728        entry.size = 0;
13729        entry.mtime_sec = 0;
13730        entry.mtime_nsec = 0;
13731        assert!(
13732            !super::index_entry_skips_worktree_content_hash_after_stat_match(
13733                &entry,
13734                Some(index_mtime),
13735                policy,
13736            )
13737        );
13738        let _ = git_dir;
13739    }
13740
13741    #[cfg(unix)]
13742    #[test]
13743    fn symlink_dir_cache_detects_symlinked_ancestors_across_threads() {
13744        let td = tempfile::tempdir().expect("tempdir");
13745        let wt = td.path();
13746        fs::create_dir_all(wt.join("real/deep")).expect("mkdir");
13747        fs::create_dir_all(wt.join("target/deep")).expect("mkdir");
13748        std::os::unix::fs::symlink(wt.join("target"), wt.join("linked")).expect("symlink");
13749        std::thread::scope(|scope| {
13750            for _ in 0..4 {
13751                scope.spawn(|| {
13752                    let mut cache = super::SymlinkDirCache::default();
13753                    assert!(!cache.has_symlink_in_path_bytes(wt, b"top.txt", false, false));
13754                    assert!(!cache.has_symlink_in_path_bytes(wt, b"linked", false, false));
13755                    assert!(!cache.has_symlink_in_path_bytes(wt, b"real/deep/f", false, false));
13756                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/f", false, false));
13757                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/deep/f", false, false));
13758                    assert!(!cache.has_symlink_in_path_bytes(wt, b"\xff\xfe", false, false));
13759                });
13760            }
13761        });
13762    }
13763
13764    #[cfg(unix)]
13765    #[test]
13766    fn symlink_dir_cache_accepts_a_work_tree_reached_through_a_symlink() {
13767        let td = tempfile::tempdir().expect("tempdir");
13768        let real = td.path().join("real");
13769        fs::create_dir_all(real.join("a/b/c")).expect("mkdir");
13770        fs::create_dir_all(real.join("t/b/c")).expect("mkdir");
13771        std::os::unix::fs::symlink(real.join("t"), real.join("l")).expect("symlink");
13772        let wt = td.path().join("alias");
13773        std::os::unix::fs::symlink(&real, &wt).expect("symlink");
13774        let mut cache = super::SymlinkDirCache::default();
13775        assert!(!cache.has_symlink_in_path_bytes(&wt, b"a/b/c/f", false, false));
13776        assert!(cache.has_symlink_in_path_bytes(&wt, b"l/b/c/f", false, false));
13777        assert!(!cache.has_symlink_in_path_bytes(&real, b"a/b/c/f", false, false));
13778        assert!(cache.has_symlink_in_path_bytes(&real, b"l/b/c/f", false, false));
13779    }
13780
13781    #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
13782    #[test]
13783    fn real_dir_chain_confirms_only_symlink_free_directories() {
13784        let td = tempfile::tempdir().expect("tempdir");
13785        let wt = td.path();
13786        fs::create_dir_all(wt.join("a/b/c")).expect("mkdir");
13787        fs::create_dir_all(wt.join("t/c")).expect("mkdir");
13788        fs::write(wt.join("a/file"), b"f").expect("write");
13789        std::os::unix::fs::symlink(wt.join("t"), wt.join("a/l")).expect("symlink");
13790        let mut chain = crate::worktree_scan::RealDirChain::open(wt);
13791        assert_eq!(chain.work_tree(), wt);
13792        assert!(chain.confirms(b"a"));
13793        assert!(chain.confirms(b"a/b/c"));
13794        assert!(!chain.confirms(b"a/l"));
13795        assert!(!chain.confirms(b"a/l/c"));
13796        assert!(!chain.confirms(b"a/file"));
13797        assert!(!chain.confirms(b"a/missing/c"));
13798        assert!(!chain.confirms(b""));
13799        assert!(!chain.confirms(b"a/\0b"));
13800        assert!(chain.confirms(b"a/b"));
13801    }
13802
13803    #[test]
13804    fn symlink_dir_cache_probes_each_new_directory_once_in_index_order() {
13805        let td = tempfile::tempdir().expect("tempdir");
13806        let wt = td.path();
13807        let depth = 12;
13808        let chains = 20;
13809        let mut paths = Vec::new();
13810        for chain in 0..chains {
13811            let dir: Vec<String> = (0..depth)
13812                .map(|level| format!("c{chain:02}l{level:02}"))
13813                .collect();
13814            let dir = format!("shared/{}", dir.join("/"));
13815            fs::create_dir_all(wt.join(&dir)).expect("mkdir");
13816            paths.extend((0..3).map(|file| format!("{dir}/f{file}")));
13817        }
13818        paths.sort();
13819        let mut cache = super::SymlinkDirCache::default();
13820        for rel in &paths {
13821            assert!(!cache.has_symlink_in_path_bytes(wt, rel.as_bytes(), false, false));
13822        }
13823        // Each chain's directories are new; `shared` and sibling files are already known.
13824        let unique_dirs = 1 + chains * depth;
13825        assert!(
13826            cache.fs_queries <= unique_dirs,
13827            "{} filesystem queries for {unique_dirs} directories",
13828            cache.fs_queries
13829        );
13830        #[cfg(all(target_os = "linux", target_pointer_width = "64"))]
13831        assert!(
13832            cache.fs_queries <= chains + 1,
13833            "one chain walk per new directory: {} queries for {chains} chains",
13834            cache.fs_queries
13835        );
13836    }
13837
13838    #[test]
13839    fn common_dir_prefix_len_stops_at_whole_components() {
13840        use super::{common_dir_prefix_len, path_within_dir};
13841        assert_eq!(common_dir_prefix_len(b"a/b/c", b"a/b/c"), 5);
13842        assert_eq!(common_dir_prefix_len(b"a/b/c", b"a/b"), 3);
13843        assert_eq!(common_dir_prefix_len(b"a/b", b"a/b/c"), 3);
13844        assert_eq!(common_dir_prefix_len(b"a/bc/d", b"a/b"), 1);
13845        assert_eq!(common_dir_prefix_len(b"a/b", b"a/bc/d"), 1);
13846        assert_eq!(common_dir_prefix_len(b"ab", b"a"), 0);
13847        assert_eq!(common_dir_prefix_len(b"a/b", b""), 0);
13848        assert!(path_within_dir(b"a/b", b"a/b"));
13849        assert!(path_within_dir(b"a/b/c", b"a/b"));
13850        assert!(!path_within_dir(b"a/bc", b"a/b"));
13851        assert!(!path_within_dir(b"a", b"a/b"));
13852    }
13853
13854    /// Answer the ancestor-symlink question without any caching, one probe per ancestor.
13855    #[cfg(unix)]
13856    fn naive_has_symlink_ancestor(wt: &Path, rel: &str) -> bool {
13857        let Some((dir, _)) = rel.rsplit_once('/') else {
13858            return false;
13859        };
13860        let mut prefix = wt.to_path_buf();
13861        dir.split('/').any(|component| {
13862            prefix.push(component);
13863            fs::symlink_metadata(&prefix).is_ok_and(|m| m.file_type().is_symlink())
13864        })
13865    }
13866
13867    #[cfg(unix)]
13868    #[test]
13869    fn symlink_dir_cache_matches_uncached_probe_in_any_query_order() {
13870        let td = tempfile::tempdir().expect("tempdir");
13871        let wt = td.path();
13872        fs::create_dir_all(wt.join("a/b/c/d")).expect("mkdir");
13873        fs::create_dir_all(wt.join("a/bb/c")).expect("mkdir");
13874        fs::create_dir_all(wt.join("a/b2")).expect("mkdir");
13875        fs::create_dir_all(wt.join("t/c/d")).expect("mkdir");
13876        fs::create_dir_all(wt.join("x/y")).expect("mkdir");
13877        fs::write(wt.join("a/file"), b"f").expect("write");
13878        fs::write(wt.join("x/y/plain"), b"f").expect("write");
13879        std::os::unix::fs::symlink(wt.join("t"), wt.join("a/b/link")).expect("symlink");
13880        std::os::unix::fs::symlink(wt.join("t"), wt.join("x/l")).expect("symlink");
13881        std::os::unix::fs::symlink(wt.join("t"), wt.join("top")).expect("symlink");
13882
13883        let paths = [
13884            "a/b/c/d/f",
13885            "a/b/c/f",
13886            "a/b/f",
13887            "a/b/link/f",
13888            "a/b/link/c/d/f",
13889            "a/b/linker/f",
13890            "a/b2/f",
13891            "a/bb/c/f",
13892            "a/file/under/f",
13893            "a/missing/deeper/f",
13894            "a/missing/f",
13895            "a/f",
13896            "f",
13897            "top/c/f",
13898            "top/f",
13899            "topper/f",
13900            "x/l/c/d/f",
13901            "x/y/f",
13902            "x/y/plain/f",
13903        ];
13904        let mut orders: Vec<Vec<&str>> = vec![paths.to_vec()];
13905        orders.push(paths.iter().rev().copied().collect());
13906        orders.push(
13907            paths
13908                .iter()
13909                .step_by(2)
13910                .chain(paths.iter().skip(1).step_by(2))
13911                .copied()
13912                .collect(),
13913        );
13914        orders.push(paths.iter().flat_map(|p| [*p, *p]).collect());
13915        for order in orders {
13916            let mut cache = super::SymlinkDirCache::default();
13917            for rel in order {
13918                assert_eq!(
13919                    cache.has_symlink_in_path_bytes(wt, rel.as_bytes(), false, false),
13920                    naive_has_symlink_ancestor(wt, rel),
13921                    "ancestor symlink answer for {rel}"
13922                );
13923            }
13924        }
13925    }
13926
13927    #[test]
13928    fn content_hash_skip_trusts_empty_blob_but_not_smudged_size() {
13929        use crate::index::StatMatchPolicy;
13930        let td = tempfile::tempdir().expect("tempdir");
13931        let wt = td.path();
13932        let (_git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
13933        let file_mtime = (1_700_000_000, 0);
13934        let index_mtime = Some((file_mtime.0 + 1, 0));
13935        let policy = StatMatchPolicy::from_config(None);
13936
13937        let empty = staged_entry_for_file(&odb, wt, "empty.txt", b"", file_mtime);
13938        assert_eq!(empty.size, 0);
13939        assert!(
13940            super::index_entry_skips_worktree_content_hash_after_stat_match(
13941                &empty,
13942                index_mtime,
13943                policy,
13944            )
13945        );
13946
13947        let smudged_oid = odb.hash(ObjectKind::Blob, b"abc\n").expect("hash");
13948        let smudged = staged_entry_with_oid(wt, "smudged.txt", b"", smudged_oid, file_mtime);
13949        assert_eq!(smudged.size, 0);
13950        assert!(
13951            !super::index_entry_skips_worktree_content_hash_after_stat_match(
13952                &smudged,
13953                index_mtime,
13954                policy,
13955            )
13956        );
13957    }
13958
13959    #[test]
13960    fn status_diff_reports_smudged_entry_whose_file_became_empty() {
13961        let td = tempfile::tempdir().expect("tempdir");
13962        let wt = td.path();
13963        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
13964        let oid = odb.write(ObjectKind::Blob, b"abc\n").expect("write blob");
13965        let file_mtime = (1_700_000_000, 0);
13966        let mut index = Index::new();
13967        index.entries.push(staged_entry_with_oid(
13968            wt,
13969            "smudged.txt",
13970            b"",
13971            oid,
13972            file_mtime,
13973        ));
13974        let (entries, _) = super::diff_index_to_worktree_with_options(
13975            &odb,
13976            &mut index,
13977            wt,
13978            super::DiffIndexToWorktreeOptions {
13979                repository_git_dir: Some(git_dir),
13980                index_mtime: Some((file_mtime.0 + 1, 0)),
13981                for_status: true,
13982                stat_parallel_threads: Some(1),
13983                ..Default::default()
13984            },
13985        )
13986        .expect("diff");
13987        assert_eq!(entries.len(), 1, "{entries:?}");
13988        assert_eq!(entries[0].status, DiffStatus::Modified);
13989    }
13990
13991    #[test]
13992    fn status_diff_verifies_content_when_index_stat_cache_unset() {
13993        let td = tempfile::tempdir().expect("tempdir");
13994        let wt = td.path();
13995        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13996        let rel = "unset-stat.txt";
13997        let bytes = b"same bytes\n";
13998        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
13999        fs::write(wt.join(rel), bytes).expect("write");
14000        let mut index = Index::new();
14001        let mut entry = staged_entry_for_file(&odb, wt, rel, bytes, (100, 0));
14002        entry.size = 0;
14003        entry.mtime_sec = 0;
14004        entry.mtime_nsec = 0;
14005        entry.oid = oid;
14006        index.entries.push(entry);
14007        let (entries, _) = super::diff_index_to_worktree_with_options(
14008            &odb,
14009            &mut index,
14010            wt,
14011            super::DiffIndexToWorktreeOptions {
14012                repository_git_dir: Some(git_dir),
14013                for_status: true,
14014                ..Default::default()
14015            },
14016        )
14017        .expect("diff");
14018        assert!(
14019            entries.is_empty(),
14020            "unset stat must not size-mismatch a matching worktree: {entries:?}"
14021        );
14022    }
14023
14024    #[cfg_attr(
14025        not(feature = "full-tests"),
14026        ignore = "starts system git or is heavy; run with --features full-tests"
14027    )]
14028    #[test]
14029    fn status_reports_modified_when_ctime_differs_but_mtime_sec_and_size_match_non_racy() {
14030        let td = tempfile::tempdir().expect("tempdir");
14031        let wt = td.path();
14032        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14033        let rel = "tracked.txt";
14034        let original = b"original\n";
14035        let modified = b"modified\n";
14036        assert_eq!(original.len(), modified.len());
14037
14038        let entry_mtime = (1_700_000_000u32, 0u32);
14039        let index_file_mtime = (1_700_000_100u32, 0u32);
14040
14041        std::process::Command::new("git")
14042            .args(["-C", &wt.to_string_lossy(), "init", "-q"])
14043            .status()
14044            .expect("git init");
14045        std::process::Command::new("git")
14046            .args([
14047                "-C",
14048                &wt.to_string_lossy(),
14049                "config",
14050                "user.email",
14051                "t@example.com",
14052            ])
14053            .status()
14054            .expect("git config email");
14055        std::process::Command::new("git")
14056            .args(["-C", &wt.to_string_lossy(), "config", "user.name", "T"])
14057            .status()
14058            .expect("git config name");
14059
14060        fs::write(wt.join(rel), original).expect("write original");
14061        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
14062        std::process::Command::new("git")
14063            .args(["-C", &wt.to_string_lossy(), "add", rel])
14064            .status()
14065            .expect("git add");
14066        std::process::Command::new("git")
14067            .args([
14068                "-C",
14069                &wt.to_string_lossy(),
14070                "commit",
14071                "-qm",
14072                "seed",
14073                "--no-gpg-sign",
14074            ])
14075            .status()
14076            .expect("git commit");
14077
14078        let mut index = Index::new();
14079        index.source_mtime = Some(index_file_mtime);
14080        let staged = staged_entry_for_file(&odb, wt, rel, original, entry_mtime);
14081        let oid_original = staged.oid;
14082        index.entries.push(staged);
14083
14084        fs::write(wt.join(rel), modified).expect("modify worktree");
14085        let git_dirty = std::process::Command::new("git")
14086            .args([
14087                "-C",
14088                &wt.to_string_lossy(),
14089                "--no-optional-locks",
14090                "status",
14091                "--short",
14092            ])
14093            .output()
14094            .expect("git status");
14095        assert!(
14096            git_dirty.status.success(),
14097            "git status failed: {}",
14098            String::from_utf8_lossy(&git_dirty.stderr)
14099        );
14100        assert!(
14101            String::from_utf8_lossy(&git_dirty.stdout).contains("tracked.txt"),
14102            "system git must see modification before mtime pin: {}",
14103            String::from_utf8_lossy(&git_dirty.stdout)
14104        );
14105
14106        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
14107
14108        let meta = fs::symlink_metadata(wt.join(rel)).expect("stat");
14109        let ie = &mut index.entries[0];
14110        assert_eq!(ie.oid, oid_original);
14111        if stat_matches(ie, &meta) {
14112            ie.ctime_sec = ie.ctime_sec.wrapping_sub(3600);
14113        }
14114        assert!(
14115            !stat_matches(ie, &meta),
14116            "ctime/inode change must fail full stat match"
14117        );
14118        let stat_policy = crate::index::StatMatchPolicy::default();
14119        assert!(
14120            !entry_is_racy_with_policy(ie, Some(index_file_mtime), stat_policy),
14121            "fixture must be non-racy"
14122        );
14123
14124        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
14125            &odb,
14126            &mut index,
14127            wt,
14128            super::DiffIndexToWorktreeOptions {
14129                index_mtime: Some(index_file_mtime),
14130                repository_git_dir: Some(git_dir),
14131                refresh_index_stat_in_pass: true,
14132                for_status: true,
14133                ..Default::default()
14134            },
14135        )
14136        .expect("diff");
14137        assert_eq!(
14138            entries.len(),
14139            1,
14140            "must report modified, not refresh stat clean: {entries:?}, index_changed={index_changed}"
14141        );
14142        assert_eq!(entries[0].path(), rel);
14143    }
14144
14145    #[test]
14146    fn diff_name_status_skips_big_worktree_read_on_size_mismatch() {
14147        let td = tempfile::tempdir().expect("tempdir");
14148        let wt = td.path();
14149        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tbigFileThreshold = 4k\n");
14150        let rel = "big.bin";
14151        let indexed = vec![b'x'; 100];
14152        let mut index = Index::new();
14153        index
14154            .entries
14155            .push(staged_entry_for_file(&odb, wt, rel, &indexed, (100, 0)));
14156        fs::write(wt.join(rel), vec![b'y'; 5000]).expect("rewrite bigger");
14157        let metrics = odb.hot_path_metrics();
14158        metrics.reset_blob_content_reads();
14159        metrics.set_blob_counting(true);
14160        let (entries, _) = super::diff_index_to_worktree_with_options(
14161            &odb,
14162            &mut index,
14163            wt,
14164            super::DiffIndexToWorktreeOptions {
14165                repository_git_dir: Some(git_dir),
14166                for_status: false,
14167                ..Default::default()
14168            },
14169        )
14170        .expect("diff");
14171        metrics.set_blob_counting(false);
14172        assert_eq!(entries.len(), 1);
14173        assert_eq!(entries[0].status, super::DiffStatus::Modified);
14174        assert_eq!(entries[0].new_oid, super::zero_oid());
14175        assert_eq!(
14176            metrics.blob_content_reads(),
14177            0,
14178            "large size-only drift must not hash or read the worktree"
14179        );
14180    }
14181
14182    #[test]
14183    fn status_diff_skips_hash_when_worktree_size_differs() {
14184        let td = tempfile::tempdir().expect("tempdir");
14185        let wt = td.path();
14186        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14187        let rel = "size-change.txt";
14188        let original = b"short\n";
14189        let mut index = Index::new();
14190        index
14191            .entries
14192            .push(staged_entry_for_file(&odb, wt, rel, original, (100, 0)));
14193        fs::write(wt.join(rel), b"longer content\n").expect("rewrite");
14194        let metrics = odb.hot_path_metrics();
14195        metrics.reset_blob_content_reads();
14196        metrics.set_blob_counting(true);
14197        let (entries, _) = super::diff_index_to_worktree_with_options(
14198            &odb,
14199            &mut index,
14200            wt,
14201            super::DiffIndexToWorktreeOptions {
14202                repository_git_dir: Some(git_dir),
14203                for_status: true,
14204                ..Default::default()
14205            },
14206        )
14207        .expect("diff");
14208        metrics.set_blob_counting(false);
14209        assert_eq!(entries.len(), 1);
14210        assert_eq!(entries[0].status, super::DiffStatus::Modified);
14211        assert_eq!(entries[0].path(), rel);
14212        assert_eq!(entries[0].old_oid, index.entries[0].oid);
14213        assert_eq!(entries[0].new_oid, super::zero_oid());
14214        assert_eq!(
14215            metrics.blob_content_reads(),
14216            0,
14217            "identity conversion: a size change alone proves the file modified"
14218        );
14219    }
14220
14221    #[test]
14222    fn status_diff_hashes_size_change_when_eol_conversion_restores_blob() {
14223        for refresh_index_stat_in_pass in [false, true] {
14224            let td = tempfile::tempdir().expect("tempdir");
14225            let wt = td.path();
14226            let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14227            fs::write(wt.join(".gitattributes"), "*.txt text eol=crlf\n").expect("attrs");
14228            let rel = "n.txt";
14229            let blob = odb
14230                .write(ObjectKind::Blob, b"one\ntwo\n")
14231                .expect("write blob");
14232            let mut index = Index::new();
14233            index.entries.push(staged_entry_with_oid(
14234                wt,
14235                rel,
14236                b"one\r\ntwo\r\n",
14237                blob,
14238                (100, 0),
14239            ));
14240            fs::write(wt.join(rel), b"one\ntwo\n").expect("rewrite as LF");
14241            let (entries, index_changed) = super::diff_index_to_worktree_with_options(
14242                &odb,
14243                &mut index,
14244                wt,
14245                super::DiffIndexToWorktreeOptions {
14246                    index_mtime: Some((200, 0)),
14247                    repository_git_dir: Some(git_dir),
14248                    refresh_index_stat_in_pass,
14249                    for_status: true,
14250                    ..Default::default()
14251                },
14252            )
14253            .expect("diff");
14254            assert!(
14255                entries.is_empty(),
14256                "refresh={refresh_index_stat_in_pass}: LF worktree cleans to the indexed blob: \
14257                 {entries:?}"
14258            );
14259            if refresh_index_stat_in_pass {
14260                assert!(index_changed, "verified entry adopts the new stat");
14261                assert_eq!(index.entries[0].size, 8);
14262            }
14263        }
14264    }
14265
14266    #[test]
14267    fn status_diff_reads_size_changed_converted_file_once() {
14268        for refresh_index_stat_in_pass in [false, true] {
14269            let td = tempfile::tempdir().expect("tempdir");
14270            let wt = td.path();
14271            let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14272            fs::write(wt.join(".gitattributes"), "*.txt text eol=crlf\n").expect("attrs");
14273            let rel = "n.txt";
14274            let blob = odb
14275                .write(ObjectKind::Blob, b"one\ntwo\n")
14276                .expect("write blob");
14277            let mut index = Index::new();
14278            index.entries.push(staged_entry_with_oid(
14279                wt,
14280                rel,
14281                b"one\r\ntwo\r\n",
14282                blob,
14283                (100, 0),
14284            ));
14285            fs::write(wt.join(rel), b"one\ntwo\nthree\n").expect("real edit");
14286            let metrics = odb.hot_path_metrics();
14287            metrics.reset_blob_content_reads();
14288            metrics.set_blob_counting(true);
14289            let (entries, _) = super::diff_index_to_worktree_with_options(
14290                &odb,
14291                &mut index,
14292                wt,
14293                super::DiffIndexToWorktreeOptions {
14294                    index_mtime: Some((200, 0)),
14295                    repository_git_dir: Some(git_dir),
14296                    refresh_index_stat_in_pass,
14297                    for_status: true,
14298                    ..Default::default()
14299                },
14300            )
14301            .expect("diff");
14302            metrics.set_blob_counting(false);
14303            assert_eq!(entries.len(), 1, "refresh={refresh_index_stat_in_pass}");
14304            assert_eq!(entries[0].status, super::DiffStatus::Modified);
14305            assert_eq!(entries[0].new_oid, zero_oid());
14306            assert_eq!(
14307                index.entries[0].size, 10,
14308                "a modified file keeps its stale stat"
14309            );
14310            assert_eq!(
14311                metrics.blob_content_reads(),
14312                1,
14313                "refresh={refresh_index_stat_in_pass}: read once, not again after the refresh"
14314            );
14315        }
14316    }
14317
14318    #[test]
14319    fn stat_matches_ignores_subsecond_nsec_by_default() {
14320        let td = tempfile::tempdir().expect("tempdir");
14321        let wt = td.path();
14322        fs::write(wt.join("f.txt"), b"x\n").expect("write");
14323        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
14324        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14325        let mut entry = entry_from_metadata(&meta, b"f.txt", zero_oid(), MODE_REGULAR);
14326        entry.mtime_nsec = entry.mtime_nsec.wrapping_add(1_000_000);
14327        assert!(stat_matches(&entry, &meta));
14328    }
14329
14330    #[test]
14331    fn classify_add_up_to_date_when_stat_matches() {
14332        let td = tempfile::tempdir().expect("tempdir");
14333        let wt = td.path();
14334        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
14335        let bytes = b"hello world\n";
14336        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, (100, 0));
14337        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14338        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14339        let attrs = FileAttrs::default();
14340        let params = WorktreeAddRefreshParams {
14341            odb: &odb,
14342            ie: &entry,
14343            meta: &meta,
14344            abs_path: &wt.join("f.txt"),
14345            rel_path: "f.txt",
14346            conv: &conv,
14347            file_attrs: &attrs,
14348            index_mtime: Some((200, 0)),
14349            staged_mode: MODE_REGULAR,
14350            filter_process: None,
14351        };
14352        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
14353        assert_eq!(refresh, WorktreeAddRefresh::UpToDate);
14354    }
14355
14356    #[test]
14357    fn classify_add_rehashes_when_index_mtime_unknown_and_stat_matches() {
14358        let td = tempfile::tempdir().expect("tempdir");
14359        let wt = td.path();
14360        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
14361        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
14362        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
14363        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
14364        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14365        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14366        let attrs = FileAttrs::default();
14367        let params = WorktreeAddRefreshParams {
14368            odb: &odb,
14369            ie: &entry,
14370            meta: &meta,
14371            abs_path: &wt.join("f.txt"),
14372            rel_path: "f.txt",
14373            conv: &conv,
14374            file_attrs: &attrs,
14375            index_mtime: None,
14376            staged_mode: MODE_REGULAR,
14377            filter_process: None,
14378        };
14379        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
14380        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
14381    }
14382
14383    #[test]
14384    fn classify_add_needs_restaging_when_content_differs() {
14385        let td = tempfile::tempdir().expect("tempdir");
14386        let wt = td.path();
14387        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
14388        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
14389        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
14390        pin_mtime_and_sample(&wt.join("f.txt"), 101, 0);
14391        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14392        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14393        let attrs = FileAttrs::default();
14394        let params = WorktreeAddRefreshParams {
14395            odb: &odb,
14396            ie: &entry,
14397            meta: &meta,
14398            abs_path: &wt.join("f.txt"),
14399            rel_path: "f.txt",
14400            conv: &conv,
14401            file_attrs: &attrs,
14402            index_mtime: Some((200, 0)),
14403            staged_mode: MODE_REGULAR,
14404            filter_process: None,
14405        };
14406        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
14407        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
14408    }
14409
14410    #[test]
14411    fn smudges_racily_clean_modified_entry() {
14412        let td = tempfile::tempdir().expect("tempdir");
14413        let wt = td.path();
14414        let index_mtime = setup_index_mtime(wt);
14415        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14416        let content_a = b"same length!!";
14417        let content_b = b"same length??";
14418        assert_eq!(content_a.len(), content_b.len());
14419
14420        let mut index = Index::new();
14421        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
14422        let stale_oid = entry.oid;
14423
14424        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14425        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14426        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14427        // Stat was refreshed from the worktree but the blob OID is still stale (racy window).
14428        let entry = entry_from_metadata(&meta, b"f.txt", stale_oid, MODE_REGULAR);
14429        index.entries.push(entry.clone());
14430
14431        assert!(stat_matches(&entry, &meta));
14432        assert!(entry_is_racy(&entry, Some(index_mtime)));
14433
14434        let smudged = smudge_racily_clean_entries(
14435            &odb,
14436            &git_dir,
14437            &mut index,
14438            wt,
14439            Some(index_mtime),
14440            None,
14441            None,
14442        );
14443        assert!(smudged);
14444        assert_eq!(index.entries[0].size, 0);
14445
14446        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
14447        assert_eq!(diff.len(), 1);
14448        assert_eq!(diff[0].path(), "f.txt");
14449    }
14450
14451    /// A racy index row plus the git dir and odb it lives in; the worktree no longer matches.
14452    fn racy_stale_entry(wt: &Path) -> (PathBuf, Odb, Index, (u32, u32), ObjectId) {
14453        let index_mtime = setup_index_mtime(wt);
14454        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14455        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
14456        let stale_oid = entry.oid;
14457        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
14458        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14459        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
14460        let mut index = Index::new();
14461        index.entries.push(entry_from_metadata(
14462            &meta,
14463            b"f.txt",
14464            stale_oid,
14465            MODE_REGULAR,
14466        ));
14467        (git_dir, odb, index, index_mtime, stale_oid)
14468    }
14469
14470    #[test]
14471    fn content_verified_entry_is_not_reread_and_the_mark_is_consumed() {
14472        let td = tempfile::tempdir().expect("tempdir");
14473        let wt = td.path();
14474        let (git_dir, odb, mut index, index_mtime, stale_oid) = racy_stale_entry(wt);
14475        index.mark_content_verified(b"f.txt", stale_oid);
14476
14477        let smudge = |index: &mut Index| {
14478            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
14479        };
14480        assert!(!smudge(&mut index), "a verified entry is trusted");
14481        assert_ne!(index.entries[0].size, 0);
14482        assert!(index.content_verified.is_empty());
14483        assert!(smudge(&mut index), "without the mark the entry is checked");
14484        assert_eq!(index.entries[0].size, 0);
14485    }
14486
14487    #[test]
14488    fn content_verified_entry_bit_is_trusted_and_consumed() {
14489        let td = tempfile::tempdir().expect("tempdir");
14490        let wt = td.path();
14491        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
14492        index.entries[0].mark_content_verified();
14493
14494        let smudge = |index: &mut Index| {
14495            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
14496        };
14497        assert!(!smudge(&mut index), "a marked entry is trusted");
14498        assert_ne!(index.entries[0].size, 0);
14499        assert_eq!(
14500            index.entries[0].flags_extended, None,
14501            "the mark is consumed"
14502        );
14503        assert!(smudge(&mut index), "without the mark the entry is checked");
14504        assert_eq!(index.entries[0].size, 0);
14505    }
14506
14507    #[test]
14508    fn content_verified_mark_for_another_oid_does_not_skip_the_check() {
14509        let td = tempfile::tempdir().expect("tempdir");
14510        let wt = td.path();
14511        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
14512        let other = odb.hash(ObjectKind::Blob, b"other").expect("hash");
14513        index.mark_content_verified(b"f.txt", other);
14514        assert!(smudge_racily_clean_entries(
14515            &odb,
14516            &git_dir,
14517            &mut index,
14518            wt,
14519            Some(index_mtime),
14520            None,
14521            None,
14522        ));
14523        assert_eq!(index.entries[0].size, 0);
14524    }
14525
14526    #[test]
14527    fn leaves_non_racy_entry_alone() {
14528        let td = tempfile::tempdir().expect("tempdir");
14529        let wt = td.path();
14530        let index_mtime = setup_index_mtime(wt);
14531        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14532        let content_a = b"same length!!";
14533        let content_b = b"same length??";
14534        let entry_mtime = (index_mtime.0 - 10, 0);
14535
14536        let mut index = Index::new();
14537        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, entry_mtime);
14538        index.entries.push(entry.clone());
14539
14540        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14541        pin_mtime_and_sample(&wt.join("f.txt"), entry_mtime.0, entry_mtime.1);
14542
14543        assert!(!entry_is_racy(&entry, Some(index_mtime)));
14544        let smudged = smudge_racily_clean_entries(
14545            &odb,
14546            &git_dir,
14547            &mut index,
14548            wt,
14549            Some(index_mtime),
14550            None,
14551            None,
14552        );
14553        assert!(!smudged);
14554        assert_eq!(index.entries[0].size, entry.size);
14555    }
14556
14557    #[test]
14558    fn leaves_matching_content_alone() {
14559        let td = tempfile::tempdir().expect("tempdir");
14560        let wt = td.path();
14561        let index_mtime = setup_index_mtime(wt);
14562        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14563        let bytes = b"unchanged!!!!";
14564
14565        let mut index = Index::new();
14566        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
14567        index.entries.push(entry.clone());
14568
14569        assert!(entry_is_racy(&entry, Some(index_mtime)));
14570        let smudged = smudge_racily_clean_entries(
14571            &odb,
14572            &git_dir,
14573            &mut index,
14574            wt,
14575            Some(index_mtime),
14576            None,
14577            None,
14578        );
14579        assert!(!smudged);
14580        assert_eq!(index.entries[0].size, entry.size);
14581    }
14582
14583    #[test]
14584    fn status_diff_skips_refresh_rewrite_for_racy_clean_matching_stat() {
14585        let td = tempfile::tempdir().expect("tempdir");
14586        let wt = td.path();
14587        let index_mtime = setup_index_mtime(wt);
14588        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14589        let bytes = b"racy clean payload\n";
14590
14591        let mut index = Index::new();
14592        index.source_mtime = Some(index_mtime);
14593        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
14594        index.entries.push(entry.clone());
14595        assert!(entry_is_racy(&entry, Some(index_mtime)));
14596
14597        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
14598            &odb,
14599            &mut index,
14600            wt,
14601            super::DiffIndexToWorktreeOptions {
14602                repository_git_dir: Some(git_dir),
14603                index_mtime: Some(index_mtime),
14604                refresh_index_stat_in_pass: true,
14605                for_status: true,
14606                ..Default::default()
14607            },
14608        )
14609        .expect("status diff");
14610        assert!(entries.is_empty(), "unexpected unstaged: {entries:?}");
14611        assert!(
14612            !index_changed,
14613            "matching stat on a racy entry must skip refresh without rewriting the index"
14614        );
14615    }
14616
14617    #[test]
14618    fn leaves_matching_sha256_racy_content_alone() {
14619        let td = tempfile::tempdir().expect("tempdir");
14620        let wt = td.path();
14621        let index_mtime = setup_index_mtime(wt);
14622        let (git_dir, odb) = minimal_repo(wt, "[extensions]\n\tobjectformat = sha256\n");
14623        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
14624
14625        let bytes = b"sha256 width!!";
14626        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
14627        let mut index = Index::new();
14628        let entry = staged_entry_with_oid(wt, "f.txt", bytes, oid, index_mtime);
14629        index.entries.push(entry.clone());
14630
14631        assert!(entry_is_racy(&entry, Some(index_mtime)));
14632        let smudged = smudge_racily_clean_entries(
14633            &odb,
14634            &git_dir,
14635            &mut index,
14636            wt,
14637            Some(index_mtime),
14638            None,
14639            None,
14640        );
14641        assert!(!smudged, "matching SHA-256 blob must not be smudged");
14642        assert_eq!(index.entries[0].size, entry.size);
14643    }
14644
14645    #[test]
14646    fn materialize_big_binary_under_autocrlf_streams_into_pack() {
14647        use std::io::Write;
14648
14649        let td = tempfile::tempdir().expect("tempdir");
14650        let wt = td.path();
14651        let (git_dir, odb) =
14652            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 64k\n");
14653        let path = wt.join("big.bin");
14654        {
14655            let mut f = fs::File::create(&path).expect("create");
14656            f.write_all(&[0u8; 9000]).expect("nul prefix");
14657            let chunk = vec![1u8; 256 * 1024];
14658            for _ in 0..20 {
14659                f.write_all(&chunk).expect("chunk");
14660            }
14661        }
14662        let meta = fs::metadata(&path).expect("stat");
14663        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14664        conv.autocrlf = crlf::AutoCrlf::True;
14665        let attrs = FileAttrs::default();
14666        let oid =
14667            materialize_worktree_blob(&odb, &path, &meta, &conv, &attrs, "big.bin", None, None)
14668                .expect("materialize");
14669        assert!(
14670            !odb.test_loose_object_path(&oid).exists(),
14671            "big binary under autocrlf should pack, not write loose"
14672        );
14673        let pack_dir = git_dir.join("objects/pack");
14674        let names: Vec<_> = fs::read_dir(&pack_dir)
14675            .expect("pack dir")
14676            .map(|e| e.expect("entry").file_name())
14677            .collect();
14678        assert!(
14679            names.iter().any(|n| n.to_string_lossy().ends_with(".pack")),
14680            "{names:?}"
14681        );
14682        assert!(odb.read(&oid).expect("read blob").data.len() > 9000);
14683    }
14684
14685    #[test]
14686    fn indexed_binary_under_autocrlf_rehash_streams() {
14687        use std::io::Write;
14688
14689        let td = tempfile::tempdir().expect("tempdir");
14690        let wt = td.path();
14691        let (_git_dir, odb) =
14692            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 1m\n");
14693        let path = wt.join("big.bin");
14694        {
14695            let mut f = fs::File::create(&path).expect("create");
14696            f.write_all(&[0u8; 9000]).expect("nul prefix");
14697            let chunk = vec![1u8; 512 * 1024];
14698            for _ in 0..3 {
14699                f.write_all(&chunk).expect("chunk");
14700            }
14701        }
14702        let meta = fs::symlink_metadata(&path).expect("stat");
14703        let oid = hash_worktree_file(
14704            &odb,
14705            &path,
14706            &meta,
14707            &crlf::ConversionConfig::from_config(&ConfigSet::new()),
14708            &FileAttrs::default(),
14709            "big.bin",
14710            None,
14711            None,
14712        )
14713        .expect("hash");
14714        let entry = entry_from_metadata(&meta, b"big.bin", oid, MODE_REGULAR);
14715        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
14716        conv.autocrlf = crlf::AutoCrlf::True;
14717        let attrs = FileAttrs::default();
14718        let content = read_regular_worktree_blob(
14719            &odb,
14720            &path,
14721            &conv,
14722            &attrs,
14723            "big.bin",
14724            Some(&entry),
14725            None,
14726            1024 * 1024,
14727        )
14728        .expect("read blob");
14729        assert!(
14730            matches!(content, WorktreeBlobContent::Stream { .. }),
14731            "indexed binary re-hash must stream, not buffer"
14732        );
14733        assert_eq!(content.hash(&odb).expect("hash"), entry.oid);
14734    }
14735
14736    #[test]
14737    fn leaves_matching_crlf_worktree_content_alone() {
14738        let td = tempfile::tempdir().expect("tempdir");
14739        let wt = td.path();
14740        let index_mtime = setup_index_mtime(wt);
14741        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tautocrlf = true\n");
14742        let git_form = b"line one\nline two\n";
14743        let worktree_form = b"line one\r\nline two\r\n";
14744        let oid = odb.hash(ObjectKind::Blob, git_form).expect("hash");
14745
14746        let mut index = Index::new();
14747        let entry = staged_entry_with_oid(wt, "f.txt", worktree_form, oid, index_mtime);
14748        index.entries.push(entry.clone());
14749
14750        assert!(entry_is_racy(&entry, Some(index_mtime)));
14751        let smudged = smudge_racily_clean_entries(
14752            &odb,
14753            &git_dir,
14754            &mut index,
14755            wt,
14756            Some(index_mtime),
14757            None,
14758            None,
14759        );
14760        assert!(
14761            !smudged,
14762            "CRLF worktree matching clean index OID must not be smudged"
14763        );
14764        assert_eq!(index.entries[0].size, entry.size);
14765    }
14766
14767    #[test]
14768    fn skips_skip_worktree_and_intent_to_add() {
14769        let td = tempfile::tempdir().expect("tempdir");
14770        let wt = td.path();
14771        let index_mtime = setup_index_mtime(wt);
14772        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14773        let content_a = b"same length!!";
14774        let content_b = b"same length??";
14775
14776        let mut index = Index::new();
14777        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
14778        entry.set_skip_worktree(true);
14779        index.entries.push(entry);
14780        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14781        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14782        assert!(!smudge_racily_clean_entries(
14783            &odb,
14784            &git_dir,
14785            &mut index,
14786            wt,
14787            Some(index_mtime),
14788            None,
14789            None,
14790        ));
14791        assert_ne!(index.entries[0].size, 0);
14792
14793        let mut index = Index::new();
14794        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
14795        entry.set_intent_to_add(true);
14796        index.entries.push(entry);
14797        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
14798        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14799        assert!(!smudge_racily_clean_entries(
14800            &odb,
14801            &git_dir,
14802            &mut index,
14803            wt,
14804            Some(index_mtime),
14805            None,
14806            None,
14807        ));
14808        assert_ne!(index.entries[0].size, 0);
14809    }
14810
14811    #[test]
14812    fn smudges_nothing_without_prior_index_mtime() {
14813        let td = tempfile::tempdir().expect("tempdir");
14814        let wt = td.path();
14815        let index_mtime = setup_index_mtime(wt);
14816        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
14817        let mut index = Index::new();
14818        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
14819        index.entries.push(entry);
14820
14821        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
14822        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
14823
14824        let smudged = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, None, None);
14825        assert!(!smudged);
14826        assert_ne!(index.entries[0].size, 0);
14827    }
14828}
14829
14830#[cfg(all(test, unix))]
14831mod read_worktree_bytes_for_mode_tests {
14832    use super::*;
14833    use crate::index::MODE_SYMLINK;
14834    use std::fs;
14835    use std::os::unix::fs::symlink;
14836
14837    #[test]
14838    fn symlink_mode_reads_link_target_not_followed_file() {
14839        let td = tempfile::tempdir().expect("tempdir");
14840        let wt = td.path();
14841        fs::write(wt.join("payload.txt"), b"secret\n").expect("write");
14842        symlink("payload.txt", wt.join("link")).expect("symlink");
14843        let bytes =
14844            read_worktree_bytes_for_mode(&wt.join("link"), MODE_SYMLINK).expect("read link");
14845        assert_eq!(bytes, b"payload.txt");
14846    }
14847
14848    #[test]
14849    fn symlink_mode_reads_plain_file_holding_the_target() {
14850        let td = tempfile::tempdir().expect("tempdir");
14851        let path = td.path().join("link");
14852        fs::write(&path, b"payload.txt").expect("write");
14853        let bytes = read_worktree_bytes_for_mode(&path, MODE_SYMLINK).expect("read plain link");
14854        assert_eq!(bytes, b"payload.txt");
14855    }
14856
14857    #[test]
14858    fn regular_mode_reads_file_contents() {
14859        let td = tempfile::tempdir().expect("tempdir");
14860        let path = td.path().join("f.txt");
14861        fs::write(&path, b"hello\n").expect("write");
14862        let bytes = read_worktree_bytes_for_mode(&path, crate::index::MODE_REGULAR).expect("read");
14863        assert_eq!(bytes, b"hello\n");
14864    }
14865
14866    #[test]
14867    fn annotate_marks_context_line_when_eof_line_is_unchanged() {
14868        let old = "a\nb\nc";
14869        let new = "a\nx\nc";
14870        let raw = super::unified_diff_histogram_hunks_only(old, new, 3, 0, false);
14871        let out = annotate_unified_diff_no_newline_at_eof(&raw, old, new);
14872        assert!(
14873            out.contains(" c\n\\ No newline at end of file"),
14874            "histogram hunk should mark trailing context line:\n{out}"
14875        );
14876    }
14877
14878    #[test]
14879    fn annotate_does_not_treat_removed_dash_lines_as_file_headers() {
14880        use super::unified_diff_with_prefix_and_funcname_and_algorithm;
14881        use similar::Algorithm;
14882
14883        let old = "--old";
14884        let new = "--new";
14885        let raw = unified_diff_with_prefix_and_funcname_and_algorithm(
14886            old,
14887            new,
14888            "a/x",
14889            "b/x",
14890            3,
14891            0,
14892            "",
14893            "",
14894            None,
14895            Algorithm::Myers,
14896            false,
14897            false,
14898            false,
14899            false,
14900        );
14901        assert_eq!(
14902            raw.matches("\\ No newline at end of file").count(),
14903            2,
14904            "expected two markers like git, got:\n{raw}"
14905        );
14906    }
14907}
14908
14909#[cfg(test)]
14910mod annotate_no_newline_tests {
14911    use super::annotate_unified_diff_no_newline_at_eof;
14912
14913    #[test]
14914    fn annotate_marks_both_sides_without_trailing_newline() {
14915        let raw = "@@ -1 +1 @@\n-foo\n+f o o\n";
14916        let out = annotate_unified_diff_no_newline_at_eof(raw, "foo", "f o o");
14917        assert!(out.contains("\\ No newline at end of file"));
14918        assert_eq!(out.matches("\\ No newline at end of file").count(), 2);
14919    }
14920}
14921
14922#[cfg(test)]
14923mod blob_diff_algorithm_smoke {
14924    use super::{
14925        anchored_unified_diff, count_changes_with_algorithm, unified_diff_histogram_hunks_only,
14926        unified_diff_with_prefix_and_funcname_and_algorithm, unified_hunk_body_for_diff_options,
14927    };
14928    use similar::Algorithm;
14929
14930    const OLD: &str = "alpha\nbeta\ngamma\n";
14931    const NEW: &str = "alpha\nBETA\ngamma\ndelta\n";
14932
14933    #[test]
14934    fn seed15_histogram_u0_body_matches_git() {
14935        use crate::diff::options::{DiffAlgorithm, DiffOptions};
14936        use std::collections::hash_map::DefaultHasher;
14937        use std::hash::{Hash, Hasher};
14938        let seed = 15u64;
14939        let mut hasher = DefaultHasher::new();
14940        seed.hash(&mut hasher);
14941        let h = hasher.finish();
14942        let line_count = 4 + (h as usize % 12);
14943        let mut old = String::new();
14944        let mut new = String::new();
14945        for i in 0..line_count {
14946            let token = (h.wrapping_add(i as u64 * 131) % 7) as u8 + b'a';
14947            let ch = token as char;
14948            old.push(ch);
14949            if (h >> (i % 8)) & 1 == 1 {
14950                new.push(((token as u8).wrapping_add(1)) as char);
14951            } else {
14952                new.push(ch);
14953            }
14954            old.push('\n');
14955            new.push('\n');
14956        }
14957        let opts = DiffOptions {
14958            context_lines: 0,
14959            algorithm: DiffAlgorithm::Histogram,
14960            indent_heuristic: false,
14961            ..DiffOptions::default()
14962        };
14963        let body = unified_hunk_body_for_diff_options(&old, &new, &opts);
14964        assert_eq!(
14965            body.matches("@@ ").count(),
14966            2,
14967            "git 2.55 splits u0 histogram hunks at seed 15: {body}"
14968        );
14969    }
14970
14971    #[test]
14972    fn t4071_histogram_changed_flags_git_myers_completes() {
14973        let old: Vec<&str> = "x\nx\nx\nx\n".lines().collect();
14974        let new: Vec<&str> = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n".lines().collect();
14975        let (c1, c2) = super::git_xdiff::histogram_changed_flags(&old, &new);
14976        assert_eq!(c1.len(), 4);
14977        assert_eq!(c2.len(), 11);
14978        let ops = super::changed_flags_to_ops(&c1, &c2, old.len(), new.len());
14979        assert!(!ops.is_empty());
14980    }
14981
14982    #[test]
14983    fn t4071_histogram_compacted_ops_completes() {
14984        use crate::diff::WhitespaceMode;
14985        let old = "x\nx\nx\nx\n";
14986        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
14987        let ops = super::histogram_compacted_line_ops(old, new, false, WhitespaceMode::default());
14988        assert!(!ops.is_empty(), "expected diff ops");
14989    }
14990
14991    #[test]
14992    fn t4071_histogram_u0_body_completes() {
14993        use crate::diff::options::{DiffAlgorithm, DiffOptions};
14994        let old = "x\nx\nx\nx\n";
14995        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
14996        let opts = DiffOptions {
14997            context_lines: 0,
14998            algorithm: DiffAlgorithm::Histogram,
14999            indent_heuristic: false,
15000            ..DiffOptions::default()
15001        };
15002        let body = unified_hunk_body_for_diff_options(old, new, &opts);
15003        assert!(body.contains("@@"), "expected hunk header: {body}");
15004    }
15005
15006    #[test]
15007    fn t4071_minimal_u0_matches_git_hunk_headers() {
15008        use crate::diff::options::{DiffAlgorithm, DiffOptions};
15009        let old = "x\nx\nx\nx\n";
15010        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
15011        let opts = DiffOptions {
15012            context_lines: 0,
15013            algorithm: DiffAlgorithm::Minimal,
15014            indent_heuristic: false,
15015            ..DiffOptions::default()
15016        };
15017        let body = unified_hunk_body_for_diff_options(old, new, &opts);
15018        assert_eq!(
15019            body.matches("@@ ").count(),
15020            2,
15021            "git 2.55+ splits u0 minimal hunks at repeated anchors: {body}"
15022        );
15023    }
15024
15025    #[test]
15026    fn hunks_do_not_slide_onto_final_line_without_newline() {
15027        use crate::diff::options::{DiffAlgorithm, DiffOptions};
15028
15029        let old = "{\n  \"a\": 1\n}\n";
15030        let new = "{\n  \"a\": 2\n}\n{\n  \"b\": 2\n}";
15031        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";
15032        for algorithm in [
15033            DiffAlgorithm::Myers,
15034            DiffAlgorithm::Minimal,
15035            DiffAlgorithm::Patience,
15036            DiffAlgorithm::Histogram,
15037        ] {
15038            for indent_heuristic in [true, false] {
15039                let options = DiffOptions {
15040                    algorithm,
15041                    indent_heuristic,
15042                    ..DiffOptions::default()
15043                };
15044                assert_eq!(
15045                    unified_hunk_body_for_diff_options(old, new, &options),
15046                    expected,
15047                    "{algorithm:?}, indent heuristic {indent_heuristic}"
15048                );
15049            }
15050        }
15051    }
15052
15053    #[test]
15054    fn myers_patience_histogram_minimal_emit_unified_hunks() {
15055        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
15056            let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
15057                OLD,
15058                NEW,
15059                "a/old.txt",
15060                "b/new.txt",
15061                3,
15062                0,
15063                "a/",
15064                "b/",
15065                None,
15066                algo,
15067                false,
15068                false,
15069                true,
15070                false,
15071            );
15072            assert!(patch.contains("@@"), "algo {algo:?} should emit hunks");
15073            assert!(patch.contains("-beta") || patch.contains("-BETA"));
15074        }
15075    }
15076
15077    #[test]
15078    fn git_histogram_printer_and_anchored_diff_smoke() {
15079        let hist = unified_diff_histogram_hunks_only(OLD, NEW, 3, 0, false);
15080        assert!(!hist.is_empty());
15081        let anchors = vec!["beta".to_string()];
15082        let anchored = anchored_unified_diff(
15083            OLD,
15084            NEW,
15085            "old",
15086            "new",
15087            3,
15088            &anchors,
15089            Algorithm::Myers,
15090            false,
15091            true,
15092            false,
15093        );
15094        assert!(anchored.contains("@@"));
15095    }
15096
15097    #[test]
15098    fn count_changes_git_histogram_vs_similar() {
15099        let hist = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, true);
15100        let myers = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, false);
15101        assert!(hist.0 + hist.1 > 0);
15102        assert!(myers.0 + myers.1 > 0);
15103    }
15104}
15105
15106#[cfg(test)]
15107mod line_coverage_tests {
15108    use super::*;
15109    use crate::config::ConfigSet;
15110    use crate::index::StatMatchPolicy;
15111    use crate::index::{Index, IndexEntry, MODE_REGULAR};
15112    use crate::objects::ObjectKind;
15113    use crate::parallel::ThreadPolicy;
15114    use crate::write_tree::write_tree_from_index;
15115    use similar::Algorithm;
15116    use tempfile::TempDir;
15117
15118    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
15119        IndexEntry {
15120            ctime_sec: 0,
15121            ctime_nsec: 0,
15122            mtime_sec: 0,
15123            mtime_nsec: 0,
15124            dev: 0,
15125            ino: 0,
15126            mode,
15127            uid: 0,
15128            gid: 0,
15129            size: 0,
15130            oid,
15131            flags: path.len().min(0xfff) as u16,
15132            flags_extended: None,
15133            path: path.as_bytes().to_vec(),
15134            base_index_pos: 0,
15135        }
15136    }
15137
15138    fn write_tree(odb: &Odb, entries: &[(&str, ObjectId)]) -> ObjectId {
15139        let mut body = Vec::new();
15140        for (name, oid) in entries {
15141            body.extend_from_slice(format!("100644 {name}\0").as_bytes());
15142            body.extend_from_slice(oid.as_bytes());
15143        }
15144        odb.write(ObjectKind::Tree, &body).expect("tree")
15145    }
15146
15147    fn pair_of_trees_with_rename() -> (TempDir, Odb, ObjectId, ObjectId) {
15148        let temp = TempDir::new().expect("tempdir");
15149        let odb = Odb::new(temp.path());
15150        let old_blob = odb
15151            .write(ObjectKind::Blob, b"line one\nline two\n")
15152            .expect("old");
15153        let new_blob = odb
15154            .write(ObjectKind::Blob, b"line one\nline TWO\nline three\n")
15155            .expect("new");
15156        let old_tree = write_tree(&odb, &[("old.txt", old_blob)]);
15157        let new_tree = write_tree(&odb, &[("new.txt", new_blob), ("kept.txt", old_blob)]);
15158        (temp, odb, old_tree, new_tree)
15159    }
15160
15161    #[test]
15162    fn detect_copies_reports_source_modes_without_tree_entries() {
15163        let temp = TempDir::new().expect("tempdir");
15164        let odb = Odb::new(temp.path());
15165        let content = odb
15166            .write(ObjectKind::Blob, b"shared content\n")
15167            .expect("blob");
15168        let changed = odb.write(ObjectKind::Blob, b"changed\n").expect("blob");
15169        let old_tree = write_tree(&odb, &[("gone.txt", content), ("mod.txt", content)]);
15170        let new_tree = write_tree(
15171            &odb,
15172            &[
15173                ("copy.txt", content),
15174                ("mod.txt", changed),
15175                ("moved.txt", content),
15176            ],
15177        );
15178        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
15179        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
15180        let pairs: Vec<_> = result
15181            .iter()
15182            .filter(|e| matches!(e.status, DiffStatus::Renamed | DiffStatus::Copied))
15183            .map(|e| (e.old_mode, e.new_mode))
15184            .collect();
15185        assert_eq!(pairs.len(), 2, "{result:?}");
15186        assert!(
15187            pairs
15188                .iter()
15189                .all(|p| *p == (FileMode::REGULAR, FileMode::REGULAR)),
15190            "{pairs:?}"
15191        );
15192    }
15193
15194    #[test]
15195    fn detect_copies_keeps_modifications_as_modifications() {
15196        // `git diff -C` reports `b` as modified even though its new content equals the
15197        // old content of the also-modified `a`: only created paths become copies.
15198        let temp = TempDir::new().expect("tempdir");
15199        let odb = Odb::new(temp.path());
15200        let one = odb.write(ObjectKind::Blob, b"1\n").expect("blob");
15201        let two = odb.write(ObjectKind::Blob, b"2\n").expect("blob");
15202        let old_tree = write_tree(&odb, &[("a", one), ("b", two)]);
15203        let new_tree = write_tree(&odb, &[("a", two), ("b", one)]);
15204        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
15205        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
15206        let statuses: Vec<_> = result.iter().map(|e| (e.path(), e.status)).collect();
15207        assert_eq!(
15208            statuses,
15209            [("a", DiffStatus::Modified), ("b", DiffStatus::Modified)]
15210        );
15211    }
15212
15213    #[test]
15214    fn diff_helper_and_format_sweep() {
15215        let cfg = ConfigSet::new();
15216        assert!(resolve_indent_heuristic(&cfg, false, false));
15217        assert!(!resolve_indent_heuristic(&cfg, false, true));
15218
15219        let old = "alpha beta\ngamma\n";
15220        let new = "alpha BETA\ngamma delta\n";
15221        let _ = normalize_ignore_space_change(old);
15222        let _ = normalize_ignore_space_change_line("- alpha  beta ");
15223        let _ = count_git_lines(old.as_bytes());
15224        let _ = diffcore_count_changes(old.as_bytes(), new.as_bytes());
15225        assert!(parse_diff_rename_score_token("50%").is_some());
15226        let _ = rewrite_merge_score(b"a", b"b");
15227        let _ = rewrite_dissimilarity_index_percent(b"abc", b"abd");
15228        assert_eq!(format_mode(0o100644), "100644");
15229
15230        let old_lines = ["a", "b", "c"];
15231        let new_lines = ["a", "b", "d"];
15232        let compact = diff_slice_ops_compacted(&old_lines, &new_lines, Algorithm::Myers, false);
15233        assert!(!compact.is_empty());
15234    }
15235
15236    #[test]
15237    fn tree_diff_rename_copy_postprocess_matrix() -> Result<()> {
15238        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
15239        let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
15240        assert!(!raw.is_empty());
15241        let with_renames = diff_trees_with_renames(&odb, Some(&old_tree), Some(&new_tree), "", 50)?;
15242        assert!(!with_renames.is_empty());
15243        let config = ConfigSet::new();
15244        let policy = ThreadPolicy::default();
15245        let detected =
15246            detect_renames_with_policy(&odb, None, raw.clone(), 50, &config, &policy, None)?;
15247        let source_flat = flatten_tree_entries(&odb, &old_tree)?;
15248        let _ = detect_copies_with_policy(
15249            &odb,
15250            None,
15251            detected.clone(),
15252            50,
15253            true,
15254            &source_flat,
15255            &config,
15256            &policy,
15257        )?;
15258        let opts = DiffPostprocessOptions {
15259            break_score: Some(50),
15260            rename_threshold: 50,
15261            copy_threshold: Some(50),
15262            find_copies_harder: true,
15263            materialize_break_worktree_new_oid: false,
15264        };
15265        let processed = postprocess_diff_entries(
15266            &odb,
15267            raw,
15268            Some(&old_tree),
15269            None,
15270            None,
15271            &config,
15272            &policy,
15273            &opts,
15274        )?;
15275        assert!(!processed.is_empty());
15276        let _ = status_apply_rename_copy_detection(
15277            &odb,
15278            with_renames.clone(),
15279            50,
15280            true,
15281            Some(&old_tree),
15282            &config,
15283            &policy,
15284            None,
15285        )?;
15286        let flat = flatten_tree_entries(&odb, &new_tree)?;
15287        assert!(!flat.is_empty());
15288        let _ = diff_trees_show_tree_entries(&odb, Some(&old_tree), Some(&new_tree), "")?;
15289        Ok(())
15290    }
15291
15292    #[test]
15293    fn unified_diff_flag_matrix() {
15294        let old = "fn main() {\n    println!(\"hi\");\n}\n";
15295        let new = "fn main() {\n    println!(\"bye\");\n}\n";
15296        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
15297            for (ws, ws_all, fc, ig) in [(true, false, false, false), (false, true, true, true)] {
15298                let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
15299                    old,
15300                    new,
15301                    "a/main.rs",
15302                    "b/main.rs",
15303                    3,
15304                    0,
15305                    "a/",
15306                    "b/",
15307                    None,
15308                    algo,
15309                    ws,
15310                    ws_all,
15311                    fc,
15312                    ig,
15313                );
15314                assert!(patch.contains("@@"), "algo {algo:?}");
15315            }
15316        }
15317        let hist = unified_diff_histogram_ignore_with_prefix_and_funcname(
15318            old,
15319            new,
15320            "a/x",
15321            "b/x",
15322            3,
15323            0,
15324            "",
15325            "",
15326            None,
15327            false,
15328            false,
15329            false,
15330            |_old, _new| false,
15331        );
15332        assert!(!hist.is_empty());
15333        let raw = format_raw_abbrev(
15334            &DiffEntry {
15335                status: DiffStatus::Modified,
15336                old_path: Some("x".into()),
15337                new_path: Some("x".into()),
15338                old_mode: FileMode::REGULAR,
15339                new_mode: FileMode::REGULAR,
15340                old_oid: empty_blob_oid(),
15341                new_oid: empty_blob_oid(),
15342                score: Some(80),
15343            },
15344            7,
15345        );
15346        assert!(raw.contains('M') || raw.contains('x'));
15347    }
15348
15349    #[test]
15350    fn index_and_commit_diff_paths() -> Result<()> {
15351        let temp = TempDir::new().expect("tempdir");
15352        let odb = Odb::new(temp.path());
15353        let b1 = odb.write(ObjectKind::Blob, b"v1\n").expect("b1");
15354        let b2 = odb.write(ObjectKind::Blob, b"v2\n").expect("b2");
15355        let b3 = odb.write(ObjectKind::Blob, b"v3\n").expect("b3");
15356        let mut index = Index::new();
15357        index.add_or_replace(index_entry("file.txt", MODE_REGULAR, b2));
15358        let head = write_tree_from_index(&odb, &index, "").expect("tree");
15359        index.entries_mut()[0].oid = b1;
15360        let idx_diff = diff_index_to_tree(&odb, &index, Some(&head), false)?;
15361        assert!(!idx_diff.is_empty());
15362        let commit_body = format!(
15363            "tree {head}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
15364        );
15365        let c1 = odb
15366            .write(ObjectKind::Commit, commit_body.as_bytes())
15367            .expect("c1");
15368        let mut index2 = Index::new();
15369        index2.add_or_replace(index_entry("file.txt", MODE_REGULAR, b3));
15370        let head2 = write_tree_from_index(&odb, &index2, "").expect("head2");
15371        let c2_body = format!(
15372            "tree {head2}\nparent {c1}\nauthor T <t@e.com> 2 +0000\ncommitter T <t@e.com> 2 +0000\n\nm2\n"
15373        );
15374        let c2 = odb
15375            .write(ObjectKind::Commit, c2_body.as_bytes())
15376            .expect("c2");
15377        let commit_diff = diff_commits_with_renames(&odb, &c1, &c2, 50)?;
15378        assert!(!commit_diff.is_empty());
15379        let _ = tree_oid_of_commit(&odb, &c2)?;
15380        let mtime = index_mtime_for_diff(&index, Some((1, 0)));
15381        let _ = entry_is_racy(&index.entries()[0], mtime);
15382        let _ = entry_is_racy_with_policy(&index.entries()[0], mtime, StatMatchPolicy::default());
15383        Ok(())
15384    }
15385
15386    #[test]
15387    fn tree_oid_of_commit_reports_invalid_ref_for_non_commits() {
15388        let temp = TempDir::new().expect("tempdir");
15389        let odb = Odb::new(temp.path());
15390        let tree = odb.write(ObjectKind::Tree, &[]).expect("tree");
15391        let body = format!(
15392            "tree {tree}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
15393        );
15394        let commit = odb
15395            .write(ObjectKind::Commit, body.as_bytes())
15396            .expect("commit");
15397        let blob = odb.write(ObjectKind::Blob, b"x\n").expect("blob");
15398
15399        assert_eq!(tree_oid_of_commit(&odb, &commit).expect("tree"), tree);
15400        assert!(matches!(
15401            tree_oid_of_commit(&odb, &blob),
15402            Err(Error::InvalidRef(_))
15403        ));
15404        assert!(matches!(
15405            tree_oid_of_commit(&odb, &tree),
15406            Err(Error::InvalidRef(_))
15407        ));
15408    }
15409
15410    #[test]
15411    fn worktree_index_diff_and_rename_score() -> Result<()> {
15412        let temp = TempDir::new().expect("tempdir");
15413        let wt = temp.path();
15414        let git_dir = wt.join(".git");
15415        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
15416        let objects_dir = git_dir.join("objects");
15417        let odb = Odb::new(&objects_dir);
15418        let content = b"hello world\n";
15419        std::fs::write(wt.join("tracked.txt"), content).expect("write");
15420        let oid = odb.write(ObjectKind::Blob, content).expect("blob");
15421        let mut index = Index::new();
15422        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, oid));
15423        let head = write_tree_from_index(&odb, &index, "").expect("tree");
15424        std::fs::write(wt.join("tracked.txt"), b"hello WORLD\n").expect("mutate");
15425        let mut index_mut = index.clone();
15426        let diff = diff_index_to_worktree(&odb, &mut index_mut, wt, false, false)?;
15427        assert!(!diff.is_empty());
15428        let _ = diff_tree_to_worktree(&odb, Some(&head), wt, &index)?;
15429        assert_eq!(rename_similarity_score(b"a", b"a\n"), 0);
15430        assert_eq!(rename_similarity_score(b"a\n", b"a"), 0);
15431        let score = rename_similarity_score(b"line one\nline two\n", b"line one\nline TWO\n");
15432        assert!(score >= 50);
15433        let _ = detect_renames(&odb, Some(wt), diff, 50);
15434        Ok(())
15435    }
15436
15437    #[test]
15438    fn extended_diff_helpers_sweep() -> Result<()> {
15439        use crate::crlf::{ConversionConfig, FileAttrs};
15440        let temp = TempDir::new().expect("tempdir");
15441        let wt = temp.path();
15442        let git_dir = wt.join(".git");
15443        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
15444        let objects_dir = git_dir.join("objects");
15445        let odb = Odb::new(&objects_dir);
15446        let b_old = odb.write(ObjectKind::Blob, b"alpha\nbeta\n").expect("old");
15447        let b_new = odb
15448            .write(ObjectKind::Blob, b"alpha\nBETA\ngamma\n")
15449            .expect("new");
15450        let old_tree = write_tree(&odb, &[("a.txt", b_old), ("b.txt", b_old)]);
15451        let new_tree = write_tree(
15452            &odb,
15453            &[("a.txt", b_new), ("b.txt", b_new), ("c.txt", b_new)],
15454        );
15455        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
15456        assert!(entries.len() >= 2);
15457
15458        let cfg = ConfigSet::new();
15459        assert!(indent_heuristic_from_config(&cfg));
15460        let _ = unified_diff_histogram_hunks_only("a\nb\n", "a\nB\n", 3, 0, false);
15461        let _ = unified_diff_histogram_with_prefix_and_funcname_indent(
15462            "x\n", "y\n", "a/f", "b/f", 3, 0, "", "", None, false, false,
15463        );
15464        let _ = count_changes_with_algorithm("a\n", "b\n", Algorithm::Myers, false);
15465        let _ = should_break_rewrite_pair(b"short", b"totally different body", 50);
15466        let _ = head_path_states(&odb, Some(&new_tree))?;
15467        std::fs::write(wt.join("tracked.txt"), b"hello\n").expect("wt");
15468        let abs = wt.join("tracked.txt");
15469        let meta = std::fs::metadata(&abs).expect("meta");
15470        let mut index = Index::new();
15471        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, b_old));
15472        let _ = stat_matches_with_policy(&index.entries()[0], &meta, StatMatchPolicy::default());
15473        let _ = worktree_differs_from_index_entry(
15474            &odb,
15475            &git_dir,
15476            wt,
15477            &index.entries()[0],
15478            false,
15479            None,
15480            None,
15481        )?;
15482        let conv = ConversionConfig::from_config(&cfg);
15483        let attrs = FileAttrs::default();
15484        let params = WorktreeAddRefreshParams {
15485            odb: &odb,
15486            ie: &index.entries()[0],
15487            meta: &meta,
15488            abs_path: &abs,
15489            rel_path: "tracked.txt",
15490            conv: &conv,
15491            file_attrs: &attrs,
15492            index_mtime: None,
15493            staged_mode: MODE_REGULAR,
15494            filter_process: None,
15495        };
15496        let _ = classify_worktree_entry_for_add(&params)?;
15497        let _ = hash_worktree_file(
15498            &odb,
15499            &abs,
15500            &meta,
15501            &conv,
15502            &attrs,
15503            "tracked.txt",
15504            Some(&index.entries()[0]),
15505            None,
15506        )?;
15507        let _ = materialize_worktree_blob(
15508            &odb,
15509            &abs,
15510            &meta,
15511            &conv,
15512            &attrs,
15513            "tracked.txt",
15514            Some(&index.entries()[0]),
15515            None,
15516        )?;
15517        let _ = read_worktree_bytes_for_mode(&abs, MODE_REGULAR);
15518        let _ = mode_from_metadata(&meta);
15519        let _ = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, Some(&cfg), None);
15520        let _ = refresh_index_stat_content_verified(
15521            &odb,
15522            &git_dir,
15523            &mut index,
15524            wt,
15525            None,
15526            Some(&cfg),
15527            None,
15528        )?;
15529        let _ = refresh_index_stat_content_verified_with_rules(
15530            &odb,
15531            &git_dir,
15532            &mut index,
15533            wt,
15534            None,
15535            Some(&cfg),
15536            None,
15537            None,
15538        )?;
15539        let _ = path_checkout_skip_blob_write_when_up_to_date_with_config(
15540            &odb,
15541            &git_dir,
15542            wt,
15543            &index,
15544            &index.entries()[0],
15545            &b_old,
15546            MODE_REGULAR,
15547            "tracked.txt",
15548            None,
15549            None,
15550        )?;
15551        let patch = anchored_unified_diff(
15552            "a\nb\n",
15553            "a\nB\n",
15554            "f",
15555            "f",
15556            3,
15557            &[String::from("b")],
15558            Algorithm::Myers,
15559            false,
15560            true,
15561            false,
15562        );
15563        assert!(patch.contains("@@"));
15564        let _ = annotate_unified_diff_no_newline_at_eof("@@\n+a\n\\ No newline\n", "a", "a\n");
15565        Ok(())
15566    }
15567
15568    #[test]
15569    fn unmerged_only_tree_worktree_diff_and_func_heuristics() -> Result<()> {
15570        use similar::DiffOp;
15571        assert!(inserted_block_starts_with_c_like_function_definition(
15572            "static int foo(int bar) {"
15573        ));
15574        assert!(!inserted_block_starts_with_c_like_function_definition(
15575            "printf(hi);"
15576        ));
15577        let ops = vec![
15578            DiffOp::Equal {
15579                old_index: 0,
15580                new_index: 0,
15581                len: 4,
15582            },
15583            DiffOp::Insert {
15584                old_index: 4,
15585                new_index: 4,
15586                new_len: 2,
15587            },
15588        ];
15589        let new_lines = ["a\n", "b\n", "c\n", "d\n", "void added() {\n", "}\n"];
15590        assert!(append_with_whole_function_added(
15591            &ops, 4, 6, &new_lines, None
15592        ));
15593        let _ = hunk_old_change_end_exclusive(&ops);
15594
15595        let temp = TempDir::new().expect("tempdir");
15596        let wt = temp.path();
15597        let git_dir = wt.join(".git");
15598        let objects_dir = git_dir.join("objects");
15599        std::fs::create_dir_all(&objects_dir).expect("objects");
15600        let odb = Odb::new(&objects_dir);
15601        let tree_blob = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
15602        std::fs::write(wt.join("conflict.txt"), b"worktree\n").expect("wt");
15603        let tree = write_tree(&odb, &[("conflict.txt", tree_blob)]);
15604        let mut index = Index::new();
15605        let mut stage1 = index_entry("conflict.txt", MODE_REGULAR, tree_blob);
15606        stage1.flags = (stage1.flags & 0x0FFF) | (1 << 12);
15607        index.entries_mut().push(stage1);
15608        let cfg = ConfigSet::new();
15609        let diff = diff_tree_to_worktree_with_git_dir(
15610            &odb,
15611            Some(&tree),
15612            wt,
15613            &git_dir,
15614            &index,
15615            Some(&cfg),
15616        )?;
15617        assert!(!diff.is_empty());
15618        Ok(())
15619    }
15620
15621    #[test]
15622    fn high_volume_unified_diff_and_postprocess() -> Result<()> {
15623        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
15624        let config = ConfigSet::new();
15625        let policy = ThreadPolicy::default();
15626        for i in 0..600u32 {
15627            let old = format!("seed {i}\nalpha\n");
15628            let new = format!("seed {i}\nalpha\nvoid fn_{i}(int arg) {{\n  return arg;\n}}\n");
15629            let _ = unified_diff_with_prefix_and_funcname_and_algorithm(
15630                &old,
15631                &new,
15632                "a/f.c",
15633                "b/f.c",
15634                3,
15635                0,
15636                "a/",
15637                "b/",
15638                None,
15639                Algorithm::Myers,
15640                i % 2 == 0,
15641                i % 3 == 0,
15642                i % 5 == 0,
15643                i % 7 == 0,
15644            );
15645            let threshold = 30 + (i % 40);
15646            let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
15647            let opts = DiffPostprocessOptions {
15648                rename_threshold: threshold,
15649                copy_threshold: Some(threshold),
15650                find_copies_harder: i % 2 == 0,
15651                break_score: Some(threshold as u64),
15652                materialize_break_worktree_new_oid: i % 4 == 0,
15653            };
15654            let _ = postprocess_diff_entries(
15655                &odb,
15656                raw,
15657                Some(&old_tree),
15658                None,
15659                None,
15660                &config,
15661                &policy,
15662                &opts,
15663            )?;
15664        }
15665        Ok(())
15666    }
15667}
15668
15669#[cfg(test)]
15670mod refresh_checks_tests {
15671    use super::{RefreshChecks, RefreshContent};
15672    use crate::objects::ObjectId;
15673
15674    #[test]
15675    fn empty_checks_answer_none_for_every_entry() {
15676        let checks = RefreshChecks::default();
15677        assert!(checks.0.is_empty());
15678        assert!(checks.get(0).is_none());
15679        assert!(checks.get(9_999).is_none());
15680    }
15681
15682    #[test]
15683    fn filled_checks_answer_by_entry_position() {
15684        let oid = ObjectId::from_bytes(&[7u8; 20]).unwrap();
15685        let other = ObjectId::from_bytes(&[8u8; 20]).unwrap();
15686        let checks = RefreshChecks(vec![None, Some(RefreshContent::Hashed(oid)), None]);
15687        assert!(checks.get(0).is_none());
15688        assert!(RefreshContent::matches(checks.get(1), &oid));
15689        assert!(RefreshContent::differs(checks.get(1), &other));
15690        assert!(
15691            checks.get(3).is_none(),
15692            "positions past the end have no check"
15693        );
15694    }
15695}